@ 开头的标签,不影响程序逻辑,但决定框架怎么对待这段代码。

第 1 层:类上的注解 —— 判断这是什么类

1
2
3
4
5
@Api("水平越权")                   // IDOR1.java:21   Swagger 文档用,和安全无关,跳过
@Slf4j // IDOR1.java:22 Lombok 生成 log 对象(所以能写 log.info)
@RestController // IDOR1.java:23 ← 关键
@RequestMapping("/vulnapi/IDOR") // IDOR1.java:24 ← 关键
public class IDOR1 { // IDOR1.java:25

@RestController = 方法返回什么,浏览器就收到什么

审计动作:看到 @RestController / @Controller,把整个文件标记为入口文件,逐个方法看下去。

第 2 层:方法上的注解 —— 数接口

注解 含义
@GetMapping GET 请求
@PostMapping POST 请求
@PutMapping / @DeleteMapping / @PatchMapping 对应其他 HTTP 方法
@RequestMapping==== ==老写法,不限定方法时所有 HTTP 方法都会映射一遍==

审计动作:数 @*Mapping 的个数,就是接口个数。 完整 URL = 类上前缀 + 方法上路径。

小坑:如果写 @RequestMapping("/xxx") 而不限定 method,框架会给 GET、POST、HEAD、TRACE、OPTIONS 全都建一份映射。审计时都要检查一下,避免只检查了一部分,另外一部分漏掉的情况。

第 3 层:参数上的注解 ——source

【重点】

参数写法 数据从哪来 靶场例子 典型漏洞
无注解 String name URL 查询参数 ?name=x IDOR1.java:33 SQLi / 越权 / XSS
@RequestParam("uuid") URL 查询参数 ?uuid=x IDOR1.java:63 同上
@PathVariable String id URL 路径里的一段 /xxx/{id} MyBatis.java:26 SQLi / 越权
@RequestBody String content 请求体(JSON / XML) FastjsonVul.java:25 反序列化
@CookieValue("JWT_TOKEN") Cookie JWT.java:23 认证绕过 / JWT
HttpServletRequest request 原始请求对象 CSRF.java:21 万能入口
MultipartFile file 上传的文件 Upload.java:31 文件上传
@RequestHeader("X-Forwarded-For") 请求头 靶场用 request.getHeader() IP 伪造

① 无注解 = 自动绑定(IDOR1.java:33)

1
public List<UserIDOR> vul(String name) {

没写注解 → Spring 按参数名去 URL 查询参数里找 name。

② @RequestParam = 显式指定名字(IDOR1.java:63)

1
public List<User> safe2(@RequestParam("uuid") String uuid) {

来源和 ① 一样,只是把名字写死了。==括号里的 uuid 是 URL 上的名字,后面的 uuid 是代码里的变量名,两者可以不同。==

③ @PathVariable = 从 URL 路径里取(MyBatis.java:26)

1
2
3
4
@GetMapping("/vul/id/{id}")                    // ← 这个 {id} 是个"空位",等着被填
public List<User> queryById(@PathVariable String id) { // ← 把空位里的值接过来
return userMapper.queryByIdAsString(id);
}

区别在于:@RequestParam 取的是 ? 后面的,@PathVariable 取的是路径里的一段。

这里同样有类似的小坑:

{id} 里的 id 和 @PathVariable String id 里的 id 是配对的。如果两边名字不一样,就得写成 @PathVariable("id") String 别的名字

括号里 = 对外的名字(URL 上出现的)
后面 = 对内的名字(代码里用的)

@PathVariable 这个词拆开看:path(路径)+ variable(变量),意思就是”把路径里那个空位的值取出来,当成变量用”。

这个区别很重要,因为路径参数经常被开发者忽略校验。

审计:==看到 @GetMapping 的路径里有 { },比如@GetMapping(“/vul/id/{id}”)==

==就去下面找配对的 @PathVariable,把它当成一个 Source。==

④ @RequestBody = 从请求体取(FastjsonVul.java:25)

1
public String vul(@RequestBody String content) {

补充:

1
2
3
4
5
6
POST /vulnapi/Fastjson/vul HTTP/1.1        ← ① 请求行:方法 + 路径
Host: 127.0.0.1:8888 ┐
Content-Type: application/json ├ ② 请求头(headers)
Content-Length: 42 ┘
← ③ 空行(分界线)
{"name":"admin","age":18} ← ④ 请求体(body)

@RequestBody 就是:“把空行下面的整段内容,原样交给我。”

请求体就是 POST 的 body,通常是一段 JSON。==看到 @RequestBody,而且后面交给 fastjson / Jackson 去解析,立刻往反序列化方向想。== 这是 Java 高危漏洞的头号入口。

web请求传进来的url参数永远都是string,请求体可以是一整段结构化的内容(JSON、XML、二进制),所以它比 URL 参数能装的东西多得多。

注意:请求体只有 POST / PUT 这类请求才有。GET 请求没有 body,所以 @RequestBody 基本都出现在 @PostMapping 上。

示例:

1
2
3
4
5
24|  @RequestMapping(value = "/vul", method = {RequestMethod.POST})
25| public String vul(@RequestBody String content) {
26| try {
27| Object obj = JSON.parse(content); // ← 危险就在这一行
28| return obj.toString();
  • 攻击者往 /vulnapi/Fastjson/vul 发一个 POST
  • 请求体里塞一段 JSON
  • @RequestBody String content → Spring 把整段 body 原样塞进 content

【前面都还是安全的,但是在第四步】

  • JSON.parse(content) → 把这段字符串交给 fastjson 去解析

fastjson 有一个特殊功能:@type,fastjson 允许在 JSON 里指定”这段数据要变成哪个 Java 类

普通json:

1
{"name":"admin","age":18}

@type

1
{"@type":"某个类名", "字段":"值"}

fastjson 一看到 @type,它的行为就从”我解析数据”变成了 “我要去把这个类 new 出来,然后把字段填进去”。

在new一个类的时候,构造方法会执行,setter 会执行。如果攻击者挑的那个类,正好在它被创建或被填字段的时候会去做危险操作(比如发起一次 JNDI 远程请求),那代码就跑起来了

==这条链:==

1
@RequestBody(接收)→ JSON.parse(解析)→ @type(指定类)→ new 对象 → 危险方法执行 → RCE

审计:

看到 @RequestBody,先别急着往下看逻辑,往上翻一眼 import 和下面几行,找有没有 JSON.parse / JSONObject.parseObject / readValue 这类解析调用。

参数 + 解析库 = 反序列化入口。这是 Java 里最高危的组合之一。

看到 意味着
@RequestBody + 交给 fastjson 高危,fastjson 1.2.x 有历史 CVE,2026 年 7 月还出了无补丁的新 0day
@RequestBody + 交给 Jackson 要看有没有开 enableDefaultTyping(),开了才危险
@RequestBody User user(直接是对象类型) 由 Spring 内部用 Jackson 绑定,风险相对低,但仍要看后续处理
1
public String getNickname(@CookieValue("JWT_TOKEN") String jwt_cookie) {

直接从 Cookie 拿值。看到就联想认证类问题(JWT 伪造、Token 校验)。

部分 含义
@CookieValue 路标:去 Cookie 里取值
"JWT_TOKEN" Cookie 的名字(要找的是叫这个名字的那张纸条)
String jwt_cookie 取出来的值,装进这个变量里给方法体用

如果浏览器请求里没有叫 JWT_TOKEN 的 Cookie,Spring 会直接报错(默认这个 Cookie 是必须存在的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
① 你在页面登录 admin/admin
↓
② Login.java 调用 JwtUtils.generateToken("admin")
→ 生成一个 JWT token(里面写着 username=admin,还带一个签名)
↓
③ Login.java 把这个 token 塞进名为 JWT_TOKEN 的 Cookie,发给浏览器
↓
④ 浏览器存下这张"通行证"。之后每次请求都自动带上
↓
⑤ 你访问 /vulnapi/JWT/getName
→ @CookieValue("JWT_TOKEN") 从请求里把那张通行证取出来
→ jwt_cookie 这个变量里就装着 token 字符串
↓
⑥ JwtUtils.getUsernameByJwt(token)
→ 先验签名(verifyTokenByJavaJwt)
→ 验过了,从 token 里读出 username
↓
⑦ 页面显示:"当前JWT用户是:admin"

因为绝大部分 Web 应用的”身份凭证”就住在 Cookie 里:

  • 传统 Session 模式:Cookie 里放 JSESSIONID
  • JWT 模式:Cookie 里放 JWT_TOKEN
  • 有些框架:Cookie 里放权限等级、用户 ID

而 Cookie 有个致命特点:它在用户手里。用户(也就是攻击者)可以随便改自己浏览器里的 Cookie 值。所以:

  • 如果代码信任 Cookie 里的内容而不验证 → 攻击者改成 admin 就冒充了管理员
  • 如果验证用的密钥泄露或太弱 → 攻击者可以自己签一个合法的假 token
  • 如果 Cookie 里直接放了用户 ID 或权限标记 → 改一下就是越权

**审计:**看到 @CookieValue("JWT_TOKEN"),就把这个参数当成一个 Source —— 它的值完全在攻击者手里。往下追两件事:

  1. 值被直接信任了吗:取出来就当”已登录用户”用(拼 SQL、查库、决定权限),还是先验签?
  2. 验签是怎么验的:见下面「陷阱」一节,verify() 出现在代码里 ≠ 安全。

同类写法一起记:request.getCookies()、request.getHeader("token")、request.getParameter("token")。凡是”凭证从请求里来”,都归到认证类审计。

⑥ HttpServletRequest = 直接拿原始请求(CSRF.java:21)

1
2
public Map<String, Object> transferMoney(HttpServletRequest request, ...) {
String amount = request.getParameter("amount"); // 值是在方法体里取的

这是”万能入口”。一旦参数里有 HttpServletRequest,Source 就藏在方法体里的 request.getParameter() / request.getHeader()。这种情况下,必须进方法体找。

它为什么和别的写法不一样

HttpServletRequest 不是”某一个值”,而是整个 HTTP 请求的包装箱:URL 上的参数、请求头、Cookie、请求体,全都装在这一个对象里。Spring 把箱子整个递给你,掏哪一样它不管。

所以它在签名里的意思不是”我要一个参数”,而是 “我要整个请求,我自己去翻”。

1
2
// A 型:Source 就写在签名上,一眼看到
public Map<String, Object> vul(@RequestParam("uuid") String uuid)
1
2
// B 型:签名上只有一个"箱子",里面装了什么根本看不出来
public Map<String, Object> transferMoney(HttpServletRequest request, ...)

读签名 → 只知道”这个人要在请求里翻东西” → 翻什么?不知道。 可能是 amount,可能是 csrfToken,可能翻了 8 个参数你只看到 1 个。签名一个字都不告诉你。

这就是**”注解帮不了你”**的意思:注解只能标注”某个具名参数从哪来”,而 B 型压根没有具名参数可标,注解失去着力点。只能进方法体,一行一行看他伸手掏了哪个格子。

例:

1
2
3
4
public Map<String, Object> transferMoney(HttpServletRequest request, HttpServletResponse response, HttpSession session) {
String from = (String) session.getAttribute("LoginUser"); // 23
String amount = request.getParameter("amount"); // 24
String receiver = request.getParameter("receiver"); // 25

这里的source是24、25行

B 型的通病:每个方法的 Source 清单都是独立的,不能靠签名归纳。 同一个签名形状,37-43 行那个 referer 版本又冒出来一个 request.getHeader("referer")。

箱子上最常掏的六个抽屉

方法 从箱子哪一层掏 返回类型 审计含义
getParameter("amount") URL 问号后 / POST 表单体 String SQLi / 越权 / XSS,最常被直接拼进查询
getHeader("referer") 请求头 String 最阴:请求头 100% 客户端可控,却常被信任
getCookies() 头里的 Cookie Cookie[],要遍历 认证绕过 / JWT
getInputStream() 请求体(字节流) ServletInputStream 反序列化 / 文件上传,手工解析请求体的信号
getReader() 请求体(字符流) BufferedReader 同上,和 getInputStream 抢同一份内容
getQueryString() 问号后那一整段原始字符串(未解析) String 手工 split 解析 → 后面大概率跟着拼接

几个坑:

  • getParameter 一次覆盖 URL 查询参数 + POST 表单体两条路(?a=1 和表单里的 a=1 都拿得到)。
  • getParameter 读不了 JSON 请求体。前端发 {"amount":1},它返回 null —— 这就是 ④ 那行 @RequestBody 必须单独记的原因。
  • 同名参数出现两次(?a=1&a=2)只给第一个,要全拿得用 getParameterValues。
  • getInputStream 和 getReader 只能二选一,一个方法里两个都调会抛异常。
  • 请求体被 getReader / getInputStream 读掉之后,再调 getParameter 可能拿到 null。

这六个是高频必背,不是穷举清单。 同一个箱子里还有 getPathInfo、getRequestURI、getRemoteAddr、getServletPath,都能掏东西。先搜这六个,兜不住再补。

注意:getParameter 和 getAttribute 不是一回事

1
2
request.getParameter("amount")      // 用户说的 —— 正经 Source
session.getAttribute("LoginUser") // 服务器自己记的 —— 默认不算 Source

记法:getParameter 是用户说的,getAttribute 是服务器自己记得的。

但”默认不算”不等于”永远不算”,见后面「session 到底算不算 Source」一节。另外 getAttribute 取出来的东西要强转 (String),因为它的返回类型是 Object(谁都能往里存,Java 不确定里面装的是什么)。

审计:

看到签名里有 HttpServletRequest(还有 HttpServletResponse、HttpSession 这几个同族兄弟),脑子里应该自动触发两件事:

一,这是 HTTP 入口,值得审。 能拿到原始请求的方法,一定是直接被浏览器打到的。

二,Source 清单不在签名上,去方法体里找。 实操上就是搜上面那六个关键词。审真实项目时,这几乎是发现隐藏入口最快的一招——因为很多参数名根本不出现在任何注解里,只在方法体里出现。

1
2
// 真实项目里这样搜,一次把这六个口全捞出来
getParameter|getHeader|getCookies|getInputStream|getReader|getQueryString
  • 按 Ctrl + Shift + F(Find in Path,搜整个项目)

  • 右上角勾上 .\*(Regex,正则开关)—— 2024.1 的汉化版可能写作「正则表达式

  • 搜索框里粘:

  • 1
    getParameter|getHeader|getCookies|getInputStream|getReader|getQueryString
  • Scope 保持 In Project(别选 In All Places,会把 JDK 和 jar 包里的 getParameter 定义也搜出来,几万条)

  • 想再收窄就在 File mask 填 *.java

方法2,命令行:

在靶场根目录下跑 ripgrep(它默认就是正则):

1
rg -c "getParameter|getHeader|getCookies|getInputStream|getReader|getQueryString" -t java -g '!**/target/**' "D:\代码审计\Hello-Java-Sec-master\src"

-c 是按文件计数,-t java 只搜 Java,-g '!**/target/**' 把编译产物排除掉(这个很有必要,不然 target 里的 .class 和复制的资源会把结果淹掉)。

所以 HttpServletRequest 被叫”万能入口”:它给的范围最大,藏得也最深。

⑦ MultipartFile = 上传的文件(Upload.java:31)

1
public String uploadVul(@RequestParam("file") MultipartFile file, ...)

看到就联想文件上传漏洞(后缀校验、路径穿越、内容检测)。

但上传文件不一样。文件有二进制内容,塞不进这种文本格式,所以浏览器换了一种打包方式,叫 multipart/form-data。它像”一个快递箱里装了好几件东西”:每个输入框是一格,文件单独占一格,每格有自己的名字和内容类型。

Spring 收到这种请求后,会把”文件那一格”解析出来,装进一个对象给你。这个对象的类型就是 MultipartFile。

所以它的准确含义是:用户这次请求里上传的那个文件的本体(不只是路径,是内容一起兜住了)。它同时在内存/临时文件里放着文件字节、以及浏览器报上来的文件名和类型。

@RequestParam(“file”)里面的file是表单里文件控件的name。

审计:

MultipartFile 是个接口,能调的方法不多,但每一个都值得盯:

  • file.isEmpty() —— 用户压根没选文件。
  • file.getOriginalFilename() —— 原始文件名。完全由用户控制,../../../../x.jsp 也能原样进来。这是路径穿越和”后缀绕过”的总入口。
  • file.getContentType() —— 请求里声明的 MIME 类型。同样是用户控制的,Burp 里改一个字符的事。
  • file.getBytes() / file.transferTo(...) —— 文件真正的内容,用来往磁盘写。

文件名有没有被清洗?(防 ../)

后缀怎么判的?黑名单(.jsp 黑名单必漏)、白名单、还是只判 Content-Type 头?

写到的是什么目录?是不是 web 能直接访问的静态目录?

写完之后这个文件会被谁执行/解析?

第 4 层:字段上的注解 —— 依赖注入

1
2
3
4
5
@Autowired
//注解
private UserMapper userMapper;
//接口声明
//IDOR1.java:27-28

@Autowired意思是”我要的东西我自己不去找,请框架帮我放进来。”

Spring 启动时做三件事:

  1. 扫一遍所有类,看谁身上贴了 @Autowired
  2. 去它自己维护的一个”对象仓库”里,找一个类型对得上的对象
  3. 找到之后,把它塞进那个位置

步骤三即为反射。

反射是什么:

补充:session 到底算不算 Source

结论:默认不算,但”默认不算”不等于”永远不算”。判据不是 getAttribute 这个名字,而是这个值是谁写进去的。

靶场里的活例子

往上追一行,找 session.setAttribute(...),看塞进去的是什么:

1
2
3
4
5
// Login.java:30   用户的输入进来
public String login(@RequestParam("username") String username, ...)

// Login.java:50 用户的输入被存进 session
session.setAttribute("LoginUser", username);

所以 CSRF.java:23 那句:

1
String from = (String) session.getAttribute("LoginUser");   // CSRF.java:23

虽然它调的是 getAttribute,取回来的却是一个用户控制过的值。

这就是二阶来源:用户输入先被存起来,换个时间、换个方法再被取出来用。(靶场这里不构成实际漏洞,因为 Login.java:42 有密码校验拦着;但机制是完整的。)

再对照一个:

1
session.setAttribute("csrfToken", token);   // CSRF.java:59

这个 token 来自 UUID.randomUUID(),服务器自己造的,用户控制不了 —— 这才是真·不可控。

getAttribute 本身不产生污点,但它可能搬运污点。 看到它别直接放行,先花两秒找 setAttribute。

为什么 Login.java 里只有 setAttribute,没有 getAttribute

因为 Login.java 是写入端。setAttribute 和 getAttribute 是一对动作,但通常不坐在同一个文件里:一个负责”放进去”,一个负责”过一会儿掏出来用”。Login.java 干的只是前面那半件事——登录成功那一刻把”你是谁”写进 session,然后它的活儿就干完了,没有任何理由再读回来。

靶场里实际的分布是 写入 8 处、读取 11 处,散在 8 个不同文件里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
写入(谁往箱子里放东西)
Login.java:50 LoginUser ← 来自 @RequestParam("username")
CSRF.java:59 csrfToken ← 来自 UUID 生成
PasswordReset.java:35,36,37 mobile / authCode / authCodeTimestamp
PasswordReset.java:93,94,95 同上
LoginHandlerInterceptor.java:13 msg ← 注意这里是 request.setAttribute

读取(谁伸手进箱子掏东西)
LoginHandlerInterceptor.java:11 LoginUser
CSRF.java:23, 38, 68, 69 LoginUser / csrfToken
CSVInjection.java:34 LoginUser
IDOR/IDOR1.java:49 LoginUser
IDOR/IDOR2.java:30 LoginUser
PasswordReset.java:55,56,57 mobile / authCode / authCodeTimestamp
PasswordReset.java:110,111 同上
XSS/XSS.java:69, 94 LoginUser

写入只发生在 3 个文件里,读取却蔓延到 8 个文件。 只搜 Login.java 一个文件,当然只能看到”写”,看不到”读”。

反例:有时候它俩确实在同一个文件

PasswordReset.java 就是。两个接口共用一次会话,一个写、一个读:

1
2
3
4
5
6
7
8
9
10
11
// 第 31 行:发验证码 —— 写入
public Map<String, String> resetPassword(@RequestParam("mobile") String mobile, ...) {
session.setAttribute("mobile", mobile); // 35
session.setAttribute("authCode", authCode); // 36
}

// 第 53 行:校验验证码 —— 读取
public Map<String, String> confirmReset(@RequestParam("code") String code, ...) {
String storedMobile = (String) session.getAttribute("mobile"); // 55
String storedCode = (String) session.getAttribute("authCode"); // 56
}

所以不是”写和读一定在不同文件”,而是——写和读在不同的接口里。 文件碰巧相同,只是这两个接口被放在了一个类里。

拦截器:一个特殊位置

1
Object session = request.getSession().getAttribute("LoginUser");   // LoginHandlerInterceptor.java:11

这是拦截器,在每一个请求进来之前统一读一次 session,判断你登录了没有。正因为认证被抽到这里统一做了,各业务方法就不用再自己读一遍。

顺带看同文件第 13 行:

1
request.setAttribute("msg", "请先登录");   // LoginHandlerInterceptor.java:13

这里用的是 request 不是 session —— 是两个不同的箱子。session 跨请求活一整个会话;request 只活这一次请求,请求一结束里面的东西就没了(所以它用来传一条”请先登录”的提示给页面,用完即弃)。

审计:两个方向都要搜

搜 setAttribute 和搜 getAttribute 是两次搜索,不能只做一次:

  • 反向搜:看到一个 getAttribute,去搜 setAttribute 找它的写入点,判断有没有被用户污染过 → 找二阶漏洞。
  • 正向搜:看到一个 setAttribute 里塞的是用户输入,去搜它在哪儿被取出来用 → 找”存进去的毒在哪儿爆发”。

只搜一个方向,就会像在 Login.java 里那样——觉得干干净净,其实毒是自己没往下追。

用户可控的值,能不能进 if?

不能这么一刀切——不然登录就没法写了。 判据不是”谁控制的”,而是:

这个 if 是在”让用户证明”,还是在”让用户声明”。

靶场里的四种 if

1
2
3
4
5
6
7
8
9
10
11
// ① Login.java:42  —— 正确。用户输入 vs 服务器配置里的真值
if (user.equals(username) && pass.equals(password))

// ② IDOR1.java:49 —— 正确。用户输入 vs session 里服务器侧的身份
if (loginUser.equals(name))

// ③ IDOR2.java:30 —— 正确。session 身份 vs 配置文件里的 admin 名字
if (session.getAttribute("LoginUser").equals(user))

// ④ CSRF.java:43 —— 错误。用户自报的 referer 被当成事实
if (referer == null || !referer.startsWith("http://baidu.com"))

username、password 百分之百是用户控制的,而且必须是——不然登录还验什么?用户输入出现在 if 里,是安全校验的前提,不是罪证。

真正的差别在这个值是不是用户在替自己作证:

  • 密码有证明力,因为攻击者得猜中一个只有服务器知道的秘密。
  • session 里的 LoginUser 有证明力,因为它不是用户说的,是服务器在登录成功那一刻自己记下来的,用户改不了。
  • referer 没有证明力。它也在跟一个写死的字符串比对,看起来像校验,但 referer 是浏览器自称的——攻击者要填 http://baidu.com 就填,随手伪造,条件直接成立。这个 if 是个装饰品。

能被用户”自由声明”的值没有证明力;只能被用户”证明”的值才有。

同一个值,两种命

最能说明问题的是 IDOR1.java 里同一个 name 参数的两副面孔:

1
2
3
4
5
6
7
// 漏洞版(33 行):没有任何 if,直接拿去查库
public List<UserIDOR> vul(String name) {
return userMapper.queryByUser2(name); // name 想填谁填谁
}

// 安全版(49 行):同样的 name,加了个 if
if (loginUser.equals(name)) { ... } // 比对:你要查的 vs 服务器记的

name 依然是用户控制的,但它从”要查谁”变成了”你要查的是不是我”。它没被当成事实,而是被拿去跟服务器侧的锚点对了一遍。 值没变,角色变了,安全性就翻转了。

最快的实操检验:拿掉这个 if 会怎样

问自己一句:攻击者能不能随手让这个条件成立?

  • if (password.equals(输入)) → 得猜中密码。if 有效。
  • if (loginUser.equals(name)) → 得先登录成那个人,否则改不了 session。if 有效。
  • if (referer.startsWith("http://baidu.com")) → 加个 header 就满足。if 无效。
  • if (request.getParameter("role").equals("admin")) → 传 role=admin 就满足。if 无效。

能随手满足的条件,等于没写。

三条规则

一个 if 里出现用户可控的值,要同时满足这三条才算安全:

一、有服务器侧的锚点掺进来 —— 数据库里的值、配置文件里的值、session 里的值。

二、用户要跨过一道坎才能满足条件 —— 猜中密码、持有有效会话、提供正确验证码。不是”说一声”就行。

三、锚点不是公开可伪造的 —— 这条专治 referer、X-Forwarded-For、Cookie 里自报的身份。它们形式上像”比对”,但比的那一头是攻击者自己写的。

一句话:用户能控制的东西,不能被当成结论;只能被拿去核对。

陷阱:代码里有防护痕迹 ≠ 安全

1. 先看一个熟悉的同类陷阱

JDBC.java 的 vul2():

1
2
3
4
5
6
// JDBC.java:89-99
@GetMapping("/vul2")
public String vul2(String id) {
String sql = "select * from users where id = " + id; // ← 92 行:直接拼接
...
PreparedStatement st = conn.prepareStatement(sql); // ← 99 行:看起来"用了预编译"

作者确实调用了 PreparedStatement(这个类的名字就写着”预编译防护”),但漏洞照样存在 —— 因为它没用 ? 占位符。靶场自己的注解都写明了(JDBC.java:88):

notes = "采用预编译的方法,但没使用?占位,此时进行预编译也无法阻止SQL注入"

结论:prepareStatement 出现在代码里 ≠ 安全。 换成 JWT 就是:verify() 出现在代码里 ≠ 安全。

2. 靶场里 JWT 的”验签”

1
2
3
4
5
6
7
8
9
10
11
// JwtUtils.java:56-66
public static Boolean verifyTokenByJavaJwt(String token) {
try {
Algorithm algorithm = Algorithm.HMAC256(SECRET);
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(token); // ← 确实验签了
return true;
} catch (JWTVerificationException exception){
return false;
}
}

结构上写对了,但漏洞在 JwtUtils.java:20:

1
private static final String SECRET = "123456";

验签算法没问题,密钥公开了,等于没验。 攻击者用 123456 自己签一个 username=admin 的 token,服务器验签会通过。

先解释签名的作用:token 里的内容(username=admin)任何人都能读、能改。为了防止被改,服务器用自己的密钥算一个”签名”附在后面;别人想改内容就算不出对应签名 —— 前提是密钥没泄露。

3. 看到 verify,要往下查五件事

# 查什么 写错的样子
① 密钥从哪来 源码里硬编码 "123456",或弱口令、固定值
② 验完用的是哪份数据 验了 token,却用 JWT.decode(token) 的结果(decode 只拆包,不验签)
③ 失败分支往哪走 catch (Exception e) { return true; } —— 出错反而算通过
④ 验的范围够不够 只验签名,不验过期时间 exp、不验签发方 iss
⑤ 算法能不能被攻击者指定 信任 token 自带的 alg 字段 → alg:none 或算法混淆攻击

4. 审计动作

看到 verify,不是”放心”,而是往下追三步:

  1. 密钥哪来的 —— 配置文件里的强随机值,还是源码里的 123456?
  2. 验完用的是什么 —— 验的是 token,用的是不是同一个 token?
  3. 失败走哪条路 —— 返回 false 之后调用方怎么处理?会不会被 catch 吞掉?

一句话记法:

不要问”有没有校验”,要问”校验的对象、凭证、失败路径分别是什么“。

5. 同一个思路适用于所有”防护痕迹”

代码里出现的防护函数,都要验证它到底拦住了什么,而不是认出它就完事:

prepareStatement / verify / escape / replace / normalize / checkSql / 强制类型转换 …

延伸:JwtUtils.java 里 generateToken 用的是 jjwt 库、verifyTokenByJavaJwt 用的是 java-jwt 库 —— 同名功能由两个不同的库各写一半,本身就是嗅觉点,因为两个库的密钥处理方式、默认算法、校验严格程度可能都不一样。