Java企业常见漏洞

  • 在某互联网二线大厂实习中遇到的:企业中一般常见的漏洞:sql、越权、ssrf、未授权、硬编码 key 等/动态加载脚本(反序列化较少)
    • 在公司审计的代码就不便上传了,用一些网上能找到的例子学习举例

sql

  • sql 注入的防御核心手段——预编译
    • 1 将SQL语句的结构提前发送到数据库进行编译
      • 参数作为数据单独传递、数据库会将占位符中的参数值视为数据,而非 sql 代码
    • 2 自动转移特殊字符

企业 sql 简述

  • 现象:企业级 java 白盒一般使用 sink 点进行告警,然后需要人工审计追踪数据流。但是其中存在大量误报
  • 误报原因:
    • MyBatis 中有两种拼接用户输入的占位符号,到 sql 语句中进行查询
    • ${}是直接拼接,但是不容易报错
    • #{}会对数据进行预编译,但是使用不规范易报错
    • 这就要求开发者在代码健壮性(不易报错)和 sql 注入的风险之间平衡
  • 误报场景:
    • ${}虽然直接拼接,触发了告警策略,但是可能存在以下场景,该用法是安全的
    • 1:在后续的传参追踪中,做了一些类型转换/白名单之类的去掉了可能的注入可能
    • 2:在数据定义时做了限制——限制为整形
    • 3:使用的参数${id}是从数据库中查询出来的
      • 注意:这里可能存在二次注入风险,但是风险较小,一般企业中判断这种情况为误报
  • 审计核心:
    • 1:追踪危险传参的数据流(Mapper.select
      • 白盒 sink 点,会直接正向给出传参,正向追踪即可 post/get-service-impl…
      • 代码审计(没有 sink 点可以用),需要在 idea 中反向追踪
        • 这时候可以采用黑白盒结合的方式(eg:直接 idea 搜索$

    • 2:通过多种手段分析,是否能确定该处不存在注入
  • 存在注入核心:参数可控(不是从数据库查的数据拼接)、未预编译(${})、未限制类型为整形

java 中执行 sql 的方式

java.sql.Statement 执行

  • JDBC :是 java 中用于连接、操作数据库的 api
  • Statement是Java JDBC下执行SQL语句的一种原生方式,执行语句时需要拼接
    • 若拼接的语句没有有效过滤过滤,将出现SQL 注入

JDBC 使用步骤

1
2
3
4
5
6
7
8
9
10
11
//加载驱动
Class.forName("com.mysql.cj.jdbc.Driver");
//建立连接
Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/mydb", "user", "password"
);//DBURL、DBUser、DBPassWord
//创建Statement对象
Statement state = conn.createStatement(); //
//定义&执行sql
String sql="SELECT*FROM user WHERE id="+id+"";
state. executeQuery(sql);

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Class.forName("com.mysql.cj.jdbc.Driver");

Connection conn = DriverManager.getConnection(
"jdbc:mysql://192.168.88.20:3306/iwebsec?useSSL=false&serverTimezone=UTC",
"root",
"root" // 注意密码的引号闭合
);

String id = "2";

String sql = "SELECT * FROM user WHERE id = " + id;

Statement stmt = conn.createStatement(); // 创建Statement对象:应使用PreparedStatement代替

ResultSet rs = stmt.executeQuery(sql); // 执行查询并获取结果集

while (rs.next()) { //遍历打印结果集
System.out.println(
"id: " + rs.getInt("id") +
", username: " + rs.getString("username") +
", password: " + rs.getString("password")
);
}
// 关闭资源
rs.close();
stmt.close();
conn.close();

上面代码没有使用预编译PreparedStatement,存在 sql 注入风险

java.sql.PreparedStatement 执行

  • PreparedStatement是继承Statement的子接口——使用预编译处理参数
    • 占位符?
  • Sql 语句:预先定义结构、但参数值未被指定(多个 IN参数,用?作占位符)
  • 参数设定:
    • 每个占位符的值,通过 sql 执行前的setXXX提供
      • setInt
      • setString
  • 优势:
    • 执行速度更快:提前预编译执行结构、会缓存在数据库
    • 防止 sql 攻击:参数只被当作查询值(而不是 sql 语句),同时转义特殊字符

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import java.sql.*;

public class PreparedStatementExample {

// 数据库连接信息
private static final String URL = "jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC";
private static final String USER = "root";
private static final String PASSWORD = "root";

public static void main(String[] args) {
insertUser("Alice", "alice@example.com", 25); // 新增用户
User user = getUserById(1); // 查询用户
System.out.println("查询结果: " + user);
updateUserAge(1, 30); // 更新用户年龄

// 删除用户
deleteUser(2);
}

// ================== 新增用户(INSERT) ==================
public static void insertUser(String username, String email, int age) {
String sql = "INSERT INTO user (username, email, age) VALUES (?, ?, ?)";

// 使用 try-with-resources 自动关闭资源
try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
PreparedStatement pstmt = conn.prepareStatement(sql)) {

// 设置参数(注意索引从1开始)
pstmt.setString(1, username);
pstmt.setString(2, email);
pstmt.setInt(3, age);

// 执行更新(INSERT/UPDATE/DELETE)
int rowsAffected = pstmt.executeUpdate();
System.out.println("插入成功,影响行数: " + rowsAffected);

} catch (SQLException e) {
e.printStackTrace();
}
}

// ================== 根据ID查询用户(SELECT) ==================
public static User getUserById(int id) {
String sql = "SELECT * FROM user WHERE id = ?";
User user = null;

try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
PreparedStatement pstmt = conn.prepareStatement(sql)) {

pstmt.setInt(1, id);

try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
user = new User(
rs.getInt("id"),
rs.getString("username"),
rs.getString("email"),
rs.getInt("age")
);
}
}

} catch (SQLException e) {
e.printStackTrace();
}
return user;
}

// ================== 更新用户年龄(UPDATE) ==================
public static void updateUserAge(int id, int newAge) {
String sql = "UPDATE user SET age = ? WHERE id = ?";

try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
PreparedStatement pstmt = conn.prepareStatement(sql)) {

pstmt.setInt(1, newAge);
pstmt.setInt(2, id);

int rowsAffected = pstmt.executeUpdate();
System.out.println("更新成功,影响行数: " + rowsAffected);

} catch (SQLException e) {
e.printStackTrace();
}
}
// ================== 删除用户(DELETE) ==================
public static void deleteUser(int id) {
String sql = "DELETE FROM user WHERE id = ?";

try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
PreparedStatement pstmt = conn.prepareStatement(sql)) {

pstmt.setInt(1, id);

int rowsAffected = pstmt.executeUpdate();
System.out.println("删除成功,影响行数: " + rowsAffected);

} catch (SQLException e) {
e.printStackTrace();
}
}
// 用户实体类(辅助数据封装)
static class User {
private int id;
private String username;
private String email;
private int age;

public User(int id, String username, String email, int age) {
this.id = id;
this.username = username;
this.email = email;
this.age = age;
}

@Override
public String toString() {
return String.format("User{id=%d, username='%s', email='%s', age=%d}",
id, username, email, age);
}
}
}

预编译风险

动态拼接结构(非参数部分)

  • 预编译的绕过Statement中的占位符是?
    • 预编译仅对参数值(查询值)进行转义和隔离,sql 的结构部分(表名、列名、排序字段)无法用占位符

案例:动态拼接 sql 结构(非参数部分)

1
2
3
4
5
6
7
String userInput = request.getParameter("orderBy"); // 用户传入 "id; DROP TABLE users--"
String sql = "SELECT * FROM users ORDER BY " + userInput; // 直接拼接

try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
ResultSet rs = pstmt.executeQuery();
// ...
}
  • 用户传参id; DROP TABLE users--闭合语句
    • 实际查询语句SELECT * FROM users ORDER BY id; DROP TABLE users--
  • 解决:白名单-只允许合法字符id、name、email

Mybatis&Mybatis-Plus

  • Mybatis:需要手写所有 sql ,通过 XML 描述符 or 注解把对象与 sql 语句关联
    • (在需要高度自定义 sql 和效率等使用)
  • Mybatis-Plus 是 Mybatis 的超集,保留灵活性的基础上,实现基础的自动化 sql 生成
    • 通过QueryWrapperLambdaQueryWrapper

MyBatis 执行

(一般企业工程中 MyBtis 和 Mybatis-Plus 用的多一点,用原生的 Statement 和 PreparedStatement 很少)

  • MyBatis 有两种方式自定义 SQL:
    • 1 直接在 Mapper 接口上加上注解 (适合简单的)
      • @Select关联
    • 2 在 XML 中定义 SQL 语句(适合复杂动态的)
      • 通过namespace和 sql 中的 id 关联

假设 User 表的实体类定义和 Mapper 接口如下

1
2
3
4
5
6
7
8
9
10
11
public class User {
private Long id;
private String username;
private String email;
// getter/setter 省略
}

public interface UserMapper {
// 方法定义:根据 ID 查询用户
User findUserById(Long id);
}
注解实现

但是不支持复杂动态 sql,eg<if><foreach>,需要借助@SelectProviderorxml

1
2
3
4
public interface UserMapper {
@Select("SELECT id, username, email FROM user WHERE id = #{id}") // 预编译拼接
User findUserById(Long id);
}
XML 实现

在 XML 文件中,通过 <mapper> 标签的 namespace 属性定义命名空间,该值必须与对应的 Mapper 接口的全限定类名一致。例如:

1
2
3
4
5
6
<!-- 路径:src/main/resources/com/example/mapper/UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<select id="findUserById" resultType="com.example.entity.User">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>

Mapper 接口的 全限定类名(即包名 + 接口名)隐式成为其命名空间,无需额外定义。例如:

1
2
3
4
5
6
// 路径:src/main/java/com/example/mapper/UserMapper.java
package com.example.mapper;

public interface UserMapper {
User findUserById(Long id);
}

Mybatis占位符分析

参考好文

  • 两种占位符
    • #{}会预编译,但是使用有比较严格的规范,容易报错
    • ${}直接拼接,不容易报错,但易造成 sql 注入(以下几种情况可以使用${}
      • 1 是从数据库中直接查的数据 (虽然可能存在二次注入,但可能性较小)
      • 2 传参限制了类型为整形
      • 3 做了白名单、严格限制、编译等自定义措施

IN 查询

1
2
3
4
// xml
<select id="selectNews" resultType="News">
SELECT * FROM news WHERE id IN (#{ids})
</select>
  • 预编译了查询参数,若传参ids = "1,2,3"最终执行 sql 如下
    • SELECT * FROM news WHERE id IN ('1,2,3')
    • 数据库查询 id 值等于字符串1,2,3的,而不是查询值等于 1/2/3——会报错
  • 若改用${},不会报错
    • SELECT * FROM news WHERE id IN (1,2,3)
    • 数据库查询值等于 1/2/3,但会存在 sql 注入风险
  • 解决方案:使用<foreach>标签——用于动态生成多个 IN 的占位符
1
2
3
4
5
6
7
//xml
<select id="selectNewsByIds" resultType="News">
SELECT * FROM news WHERE id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
  • <font style="color:rgba(0, 0, 0, 0.85);"><foreach></font> 标签用于遍历一个集合,把集合里的元素插入到 SQL 语句中
    • item临时变量名、collection需遍历的集合名
    • openclose指定在遍历开始和结束前插入的值、separator分隔符
  • 最终执行SELECT * FROM news WHERE id IN (1, 2, 3)
    • 假设 ids 集合是<font style="color:rgba(0, 0, 0, 0.85);">[1, 2, 3]</font>
  • (使用了多个占位符)

Like 查询

  • like 模糊查询(多使用前缀查询可以提高效率)
1
2
3
<select id="searchNews" resultType="News">
SELECT * FROM news WHERE title LIKE '%#{name}%'
</select>
  • 实际执行的 sql 应该是
    • SELECT * FROM news WHERE title LIKE '%?%'
  • 假设传参test,由于用了预编译,实际执行
    • SELECT * FROM news WHERE title LIKE '%'test'%'
    • 这里直接语法错误了,会**报错**
      • 若用${}直接拼接,不会报错,但是会存在 sql 注入风险
      • SELECT * FROM news WHERE title LIKE '%test%'
  • 正确方式:使用concat
1
2
3
<select id="searchNews" resultType="News">
SELECT * FROM news WHERE title LIKE CONCAT('%', #{name}, '%')
</select>
  • 生成 sql 如下
    • SELECT * FROM news WHERE title LIKE CONCAT('%', ?, '%')
  • 假设传参test,由于预编译,虽然被加上单引号,但是因为 concat 机制,不会报错
    • SELECT * FROM news WHERE title LIKE CONCAT('%', 'test', '%')

order by 查询

1
2
3
<select id="selectTest" resultType="Test">
SELECT * FROM test ORDER BY #{columnName}
</select>
  • 执行查询语句
    • SELECT * FROM test ORDER BY '?'
  • 假设传参age
    • SELECT * FROM test ORDER BY 'age'
  • 问题:这里是一个字符串'age',非列名,会报错
    • 若直接用${}拼接,不会报错,但是存在 sql 注入风险
    • SELECT * FROM test ORDER BY age
  • 解决:应该使用${}但是需要作控制
    • 1:转义
    • 2:白名单

SSRF

ssrf总结好文

  • java 中 SSRF 的利用受限,不如 PHP 灵活(可以使用多种伪协议)
    • ftp/file上传下载文件、http(s)攻击内网 Web 应用
    • 在 SSRF 中利用jar协议配合类加载漏洞实现 RCE
      • 1:利用 SSRF 访问远程服务器中的恶意 jar 文件
      • 2:若目标应用存在类加载漏洞URLClassLoader,可 RCE

java 中网络请求类

网络请求有很多:主要是含 http 、url、uri 的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HttpClient.execute
HttpClient.executeMethod
HttpURLConnection.connect
HttpURLConnection.getInputStream
URL.openStream
HttpServletRequest
getParameter
URI
URL
HttpClient
HttpServletRequest
HttpURLConnection
URLConnection
okhttp
BasicHttpEntityEnclosingRequest
DafauleBHttpClientConnection
BasicHttpRequest
...
  • java 支持的协议File、ftp、mailto、http、https、jar、netdoc
    • 含有 http 的类HttpURLConnection、HttpClient、Request、okhttp只支持 http
    • 其他支持 java 支持的所有协议

SSRF 判断

  • 人工主要判断两个
    • 1:网络请求的服务器可控
    • 2:没有做过滤:协议控制、ip 白名单、域名白名单、路径白名单、返回信息过滤、内网 ip 黑名单
  • 自动化检测工具思路
    • 1 遍历语法树找到调用点、污点数据流追踪
      • (通过危险方法列表)
    • 2 误报抑制:忽略白名单校验、安全过滤

SSRF 防御

  • 通用防御方法
    • 协议白名单、ip 白名单、域名白名单、路径白名单、返回信息过滤、内网 ip 黑名单
    • 正则匹配:严格控制 URL 格式,避免@#绕过
    • 严格控制出站流量

越权

  • 其实主要就是看怎么做的鉴权
    • 1:直接用 id 查询
    • 2:用统一身份认证