java 文件有固定结构,按固定顺序读,就不会漏东西。

符号 意思
{} 花括号 圈定范围。类的内容、方法的内容都装在花括号里
; 分号 一句话说完。每个”动作”结尾基本都是它
() 圆括号 装参数。方法名后面跟的括号里写输入
<> 尖括号 泛型,说明”这里面装的是什么类型的东西”
@ 注解 贴给框架看的标签,不影响程序逻辑,但决定框架怎么对待这段代码

此处代码:

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
package com.best.hello.controller.IDOR;

import com.alibaba.fastjson.JSON;
import com.best.hello.entity.User;
import com.best.hello.entity.UserIDOR;
import com.best.hello.mapper.UserMapper;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Api("水平越权")
@Slf4j
@RestController
@RequestMapping("/vulnapi/IDOR")

// /vulnapi/IDOR它是类上的 URL 前缀,不是接口。

public class IDOR1 {

@Autowired
private UserMapper userMapper;

// http://127.0.0.1:8888/IDOR/vul/info?name=admin
@ApiOperation(value = "vul:根据name查询用户信息")
@GetMapping("/vul/info")
public List<UserIDOR> vul(String name) {
log.info("[vul] 基于用户身份越权查询:{}", name);
return userMapper.queryByUser2(name);
}

@ApiOperation(value = "vul:基于对象ID越权查询")
@GetMapping(value = "/vul/userid")
public List<User> vul2(Integer id) {
log.info("[vul] 基于对象ID越权查询:{}", id);
return userMapper.queryByIdAsInterger(id);
}

@ApiOperation(value = "safe:只允许查询自己信息")
@GetMapping("/safe/info")
public Object safe(String name, HttpSession session) {
log.info("[safe] 水平越权查询:{}", name);
String loginUser = (String) session.getAttribute("LoginUser");
Map<String, String> m = new HashMap<>();

// 判断当前session与要查询的用户是否一致
if (loginUser.equals(name)) {
m.put("user", loginUser);
} else {
m.put("info", "无权限查询其他用户!");
}
return JSON.toJSONString(m);
}

@ApiOperation(value = "safe:通过UUID查询用户信息")
@GetMapping("/safe/userid")
public List<User> safe2(@RequestParam("uuid") String uuid) {
log.info("[safe] 查询UUID {}", uuid);
return userMapper.queryByUuid(uuid);
}

}

步骤1:看 package —— 在哪个目录

1
package com.best.hello.controller.IDOR;

人话:对应的就是文件夹层级 com/best/hello/controller/IDOR/

审计:包名必须和文件夹路径一致(Java 的硬性要求),而且包名说明文件的角色 —— controller是接收请求的入口,mapper是查数据库的,util是工具类。拿到陌生项目,先看包名就能大致画出结构。

步骤 2:扫 import —— 用了什么组件

人话:把别的类借到本文件里来用。这文件要用的外部东西,都得先 import 进来。

审计: 逐个扫 import,看到 fastjson、xstream、log4j、shiro 这类名字就标记下来 —— 这是依赖漏洞,可能不用读懂业务逻辑就能出洞。另外看到 javax.servlet 说明是 Web 项目,看到 java.sql 说明碰数据库。

步骤 3:看类声明和类上的注解 —— 这是什么类

人话:public class IDOR1 { 是类声明 —— 类就是一组代码的容器,名字叫 IDOR1,而且类名必须和文件名一致。上面那三行 @ 开头的注解是贴给框架的标签:@RestController 意思是”这个类里方法的返回值直接当 HTTP 响应发出去”,@RequestMapping("/vulnapi/IDOR") 给整个类定一个 URL 前缀。

审计:@RestController + @RequestMapping 凑在一起 = 一个 Web 入口类

真实 URL = 类上的 @RequestMapping + 方法上的路径

步骤 4:看字段

1
2
@Autowired
private UserMapper userMapper; // IDOR1.java:27-28

人话:这两行在类里面、但不在任何方法里,叫字段(也可以叫成员变量)。它像这个类随身带着的工具。private 是访问修饰符,表示”只有本类能用”;@Autowired 是说”这个对象我不用自己 new,框架会帮我塞进来”。

审计信号:在 IDEA 里把光标放在 userMapper 上按 Ctrl+B(或 Ctrl+鼠标左键),就能跳到它的定义或实现。

步骤 5:看方法 —— 一个方法就是一条数据流

1
2
3
4
5
@GetMapping("/vul/info")                       // IDOR1.java:32
public List<UserIDOR> vul(String name) { // IDOR1.java:33
log.info("[vul] 基于用户身份越权查询:{}", name); // IDOR1.java:34
return userMapper.queryByUser2(name); // IDOR1.java:35
} // IDOR1.java:36

(1) 方法上的注解 → 路径和请求方式
@GetMapping(“/vul/info”) 表示这是 GET 请求,路径是 /vulnapi/IDOR/vul/info。@GetMapping /@PostMapping 都是接口入口的标志,数一数有几个,就是这个文件有几个接口。
(2) 方法签名 → 输入和输出
public List vul(String name) 拆开看。此处略。

审计信号:参数 name 上没加任何注解 → Spring 会自动把 URL 里的查询参数绑给它。这就是 Source(用户输入进来的地方)。

(3) 方法体 → 数据流到哪去了

1
return userMapper.queryByUser2(name);

name被原封不动交给查询,没有验证这个用户的身份。Source 直接流向 Sink,中间没有任何校验 —— 这就是水平越权。

(4) return → 数据的出口
return 把结果还给调用者(这里是还给浏览器)。返回值也是要注意的:如果它把用户输入原样回显到页面上,那就是 XSS 的温床。

此处是靶场练习,可用多一个:

步骤 6:对照一下修复版

同一个文件里的 safe() 方法(第 46-59 行)是修复版,对比着看

1
2
3
4
5
6
String loginUser = (String) session.getAttribute("LoginUser");  // 第 49 行:取出当前登录的是谁
if (loginUser.equals(name)) { // 第 53 行:比对
m.put("user", loginUser);
} else {
m.put("info", "无权限查询其他用户!"); // 第 56 行:拒绝
}

查询前先拿 session 里的登录身份做了一次比对。vul() 里完全没有这一步,所以谁都能查别人。

任何跟”查看/修改/删除”有关的接口,都要问一句:它有没有先确认你是谁

完整数据流:/vulnapi/IDOR/vul/info?name=admin

resource到sink:

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
①  浏览器
GET /vulnapi/IDOR/vul/info?name=admin
↓
② Spring 路由匹配
类上 @RequestMapping("/vulnapi/IDOR") ← IDOR1.java:24
方法上 @GetMapping("/vul/info") ← IDOR1.java:32
两段拼起来命中 → IDOR1.vul()
↓
③ 参数绑定 ← ★ Source 在这里诞生
vul(String name) ← IDOR1.java:33
参数没写注解,Spring 自动去 URL 查询串里取值塞进来
此时 name = "admin"
↓
④ 拦截器
LoginHandlerInterceptor.preHandle()
只检查 session 里有没有 LoginUser
⚠️ 它只管"你登没登录",不管"你能查谁" ← 这是新手最容易误判的一步
↓
⑤ 进入方法体
IDOR1.java:34 log.info(...) ← 只是打日志,不是校验
IDOR1.java:35 userMapper.queryByUser2(name)
↓
⚠️ 数据流在这里跨文件断掉 → Ctrl+B 跳过去
↓
⑥ UserMapper.java:31 接口方法(只有声明,没有实现体)
头顶挂着 UserMapper.java:30 的 @Select("select * from users where user = #{user}")
↓
⑦ MyBatis 的动态代理拦下这次调用,读出 @Select 里的 SQL
SQL = select * from users where user = #{user}
↓
⑧ #{user} 走预编译(PreparedStatement)
参数值作为"数据"单独传给数据库,不拼进 SQL 文本
→ 这一步是干净的,没有 SQL 注入
↓
⑨ MySQL 实际执行
SELECT * FROM users WHERE user = 'admin'
↓
⑩ 结果集 → 映射成 List<UserIDOR>
(列名对应关系由 UserMapper.xml 里的 resultMap 管)
↓
⑪ 回到 IDOR1.vul() 的 return
@RestController 让返回值直接序列化成 JSON 响应
↓
⑫ 浏览器看到 admin 的信息

复盘:

1、只有标了 @GetMapping / @PostMapping 的才是接口。数注解,不数路径。

比如,这个文件只有这四个接口:

1
2
3
4
@GetMapping("/vul/info") 
@GetMapping(value = "/vul/userid")
@GetMapping("/safe/info")
@GetMapping("/safe/userid")

2、注释要验证,但不要盲信也不要无视。

注释当线索用,最后以代码和配置为准

3、${} 不等于一定有洞,还要看参数类型

看到 ${} 只是锁定嫌疑人,不等于定罪。比如顺着数据流向查的时候,发现有个地方,因为参数被声明成了 Integer,Spring 会强制做类型转换,乱传字符串会直接报类型错误。

# 条件 缺了会怎样
1 危险写法 —— ${} / 字符串拼接 没有它就没什么可聊的
2 参数能带脏数据 —— 类型是 String 这类 Integer 会被强制转换挡掉
3 路径可达 —— 从入口到这里没有被映射/白名单/正则拦住 拦住了就是安全的

比如:

1
2
3
@Select("select * from users where id = ${id}")     // 第 36 行
List<User> queryByIdAsString(@Param("id") String id); // ← String
List<User> queryByIdAsInterger(@Param("id") Integer id); // ← Integer

后面再详细说。

4、一条链最终算什么漏洞,由 Controller(入口)决定

真实项目里同一个 Mapper 方法会被调用几十次,所以永远不能只看 SQL 就下结论,必须回到调用点看上下文。

5、本项目中UserMapper.java里的几个真注入接口:

本来没想写这个,但是这里顺着链条查到UserMapper.java里面的几种不同写法,然后这里面有的是预编译的不能注入,有的是类型不能注入,剩下的是能真注入的,顺手就给记下来。

如image-20260921211448952

image-20260921211525764

其他图片略。

Mapper 方法 SQL 参数类型 被谁调用 结论
queryByIdAsInterger where id = ${id} Integer IDOR1.java:42、MyBatis.java:75 注入不了(类型挡了)
queryByIdAsString where id = ${id} String MyBatis.java:27 真注入
searchVul like '%${user}%' String MyBatis.java:34 真注入
orderBy order by ${field} ${sort}(在 XML 里) String MyBatis.java:46 真注入

有以下几个真注入接口:

1
2
3
/vulnapi/sqli/mybatis/vul/id/1 union select 1,2,3,4
/vulnapi/sqli/mybatis/vul/search?user=admin
/vulnapi/sqli/mybatis/vul/order?field=id&sort=desc
image-20260921211145255 image-20260921211220448