注解-把source全部找出来
@ 开头的标签,不影响程序逻辑,但决定框架怎么对待这段代码。
第 1 层:类上的注解 —— 判断这是什么类
1 | // IDOR1.java:21 Swagger 文档用,和安全无关,跳过 |
@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 | // ← 这个 {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 | POST /vulnapi/Fastjson/vul HTTP/1.1 ← ① 请求行:方法 + 路径 |
@RequestBody 就是:“把空行下面的整段内容,原样交给我。”
请求体就是 POST 的 body,通常是一段 JSON。==看到 @RequestBody,而且后面交给 fastjson / Jackson 去解析,立刻往反序列化方向想。== 这是 Java 高危漏洞的头号入口。
web请求传进来的url参数永远都是string,请求体可以是一整段结构化的内容(JSON、XML、二进制),所以它比 URL 参数能装的东西多得多。
注意:请求体只有 POST / PUT 这类请求才有。GET 请求没有 body,所以 @RequestBody 基本都出现在 @PostMapping 上。
示例:
1 | 24| |
- 攻击者往
/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 绑定,风险相对低,但仍要看后续处理 |
⑤ @CookieValue = 从 Cookie 取(JWT.java:23)
1 | public String getNickname( String jwt_cookie) { |
直接从 Cookie 拿值。看到就联想认证类问题(JWT 伪造、Token 校验)。
| 部分 | 含义 |
|---|---|
@CookieValue |
路标:去 Cookie 里取值 |
"JWT_TOKEN" |
Cookie 的名字(要找的是叫这个名字的那张纸条) |
String jwt_cookie |
取出来的值,装进这个变量里给方法体用 |
如果浏览器请求里没有叫 JWT_TOKEN 的 Cookie,Spring 会直接报错(默认这个 Cookie 是必须存在的)。
1 | ① 你在页面登录 admin/admin |
因为绝大部分 Web 应用的”身份凭证”就住在 Cookie 里:
- 传统 Session 模式:Cookie 里放
JSESSIONID - JWT 模式:Cookie 里放
JWT_TOKEN - 有些框架:Cookie 里放权限等级、用户 ID
而 Cookie 有个致命特点:它在用户手里。用户(也就是攻击者)可以随便改自己浏览器里的 Cookie 值。所以:
- 如果代码信任 Cookie 里的内容而不验证 → 攻击者改成
admin就冒充了管理员 - 如果验证用的密钥泄露或太弱 → 攻击者可以自己签一个合法的假 token
- 如果 Cookie 里直接放了用户 ID 或权限标记 → 改一下就是越权
**审计:**看到 @CookieValue("JWT_TOKEN"),就把这个参数当成一个 Source —— 它的值完全在攻击者手里。往下追两件事:
- 值被直接信任了吗:取出来就当”已登录用户”用(拼 SQL、查库、决定权限),还是先验签?
- 验签是怎么验的:见下面「陷阱」一节,
verify()出现在代码里 ≠ 安全。
同类写法一起记:request.getCookies()、request.getHeader("token")、request.getParameter("token")。凡是”凭证从请求里来”,都归到认证类审计。
⑥ HttpServletRequest = 直接拿原始请求(CSRF.java:21)
1 | public Map<String, Object> transferMoney(HttpServletRequest request, ...) { |
这是”万能入口”。一旦参数里有 HttpServletRequest,Source 就藏在方法体里的 request.getParameter() / request.getHeader()。这种情况下,必须进方法体找。
它为什么和别的写法不一样
HttpServletRequest 不是”某一个值”,而是整个 HTTP 请求的包装箱:URL 上的参数、请求头、Cookie、请求体,全都装在这一个对象里。Spring 把箱子整个递给你,掏哪一样它不管。
所以它在签名里的意思不是”我要一个参数”,而是 “我要整个请求,我自己去翻”。
1 | // A 型:Source 就写在签名上,一眼看到 |
1 | // B 型:签名上只有一个"箱子",里面装了什么根本看不出来 |
读签名 → 只知道”这个人要在请求里翻东西” → 翻什么?不知道。 可能是 amount,可能是 csrfToken,可能翻了 8 个参数你只看到 1 个。签名一个字都不告诉你。
这就是**”注解帮不了你”**的意思:注解只能标注”某个具名参数从哪来”,而 B 型压根没有具名参数可标,注解失去着力点。只能进方法体,一行一行看他伸手掏了哪个格子。
例:
1 | public Map<String, Object> transferMoney(HttpServletRequest request, HttpServletResponse response, HttpSession session) { |
这里的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 | request.getParameter("amount") // 用户说的 —— 正经 Source |
记法:getParameter 是用户说的,getAttribute 是服务器自己记得的。
但”默认不算”不等于”永远不算”,见后面「session 到底算不算 Source」一节。另外 getAttribute 取出来的东西要强转 (String),因为它的返回类型是 Object(谁都能往里存,Java 不确定里面装的是什么)。
审计:
看到签名里有 HttpServletRequest(还有 HttpServletResponse、HttpSession 这几个同族兄弟),脑子里应该自动触发两件事:
一,这是 HTTP 入口,值得审。 能拿到原始请求的方法,一定是直接被浏览器打到的。
二,Source 清单不在签名上,去方法体里找。 实操上就是搜上面那六个关键词。审真实项目时,这几乎是发现隐藏入口最快的一招——因为很多参数名根本不出现在任何注解里,只在方法体里出现。
1 | // 真实项目里这样搜,一次把这六个口全捞出来 |
按
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 |
|
@Autowired意思是”我要的东西我自己不去找,请框架帮我放进来。”
Spring 启动时做三件事:
- 扫一遍所有类,看谁身上贴了
@Autowired - 去它自己维护的一个”对象仓库”里,找一个类型对得上的对象
- 找到之后,把它塞进那个位置
步骤三即为反射。
反射是什么:
补充:session 到底算不算 Source
结论:默认不算,但”默认不算”不等于”永远不算”。判据不是 getAttribute 这个名字,而是这个值是谁写进去的。
靶场里的活例子
往上追一行,找 session.setAttribute(...),看塞进去的是什么:
1 | // Login.java:30 用户的输入进来 |
所以 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 | 写入(谁往箱子里放东西) |
写入只发生在 3 个文件里,读取却蔓延到 8 个文件。 只搜 Login.java 一个文件,当然只能看到”写”,看不到”读”。
反例:有时候它俩确实在同一个文件
PasswordReset.java 就是。两个接口共用一次会话,一个写、一个读:
1 | // 第 31 行:发验证码 —— 写入 |
所以不是”写和读一定在不同文件”,而是——写和读在不同的接口里。 文件碰巧相同,只是这两个接口被放在了一个类里。
拦截器:一个特殊位置
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 | // ① Login.java:42 —— 正确。用户输入 vs 服务器配置里的真值 |
username、password 百分之百是用户控制的,而且必须是——不然登录还验什么?用户输入出现在 if 里,是安全校验的前提,不是罪证。
真正的差别在这个值是不是用户在替自己作证:
- 密码有证明力,因为攻击者得猜中一个只有服务器知道的秘密。
- session 里的
LoginUser有证明力,因为它不是用户说的,是服务器在登录成功那一刻自己记下来的,用户改不了。 referer没有证明力。它也在跟一个写死的字符串比对,看起来像校验,但 referer 是浏览器自称的——攻击者要填http://baidu.com就填,随手伪造,条件直接成立。这个 if 是个装饰品。
能被用户”自由声明”的值没有证明力;只能被用户”证明”的值才有。
同一个值,两种命
最能说明问题的是 IDOR1.java 里同一个 name 参数的两副面孔:
1 | // 漏洞版(33 行):没有任何 if,直接拿去查库 |
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 | // JDBC.java:89-99 |
作者确实调用了 PreparedStatement(这个类的名字就写着”预编译防护”),但漏洞照样存在 —— 因为它没用 ? 占位符。靶场自己的注解都写明了(JDBC.java:88):
notes = "采用预编译的方法,但没使用?占位,此时进行预编译也无法阻止SQL注入"
结论:prepareStatement 出现在代码里 ≠ 安全。 换成 JWT 就是:verify() 出现在代码里 ≠ 安全。
2. 靶场里 JWT 的”验签”
1 | // JwtUtils.java:56-66 |
结构上写对了,但漏洞在 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,不是”放心”,而是往下追三步:
- 密钥哪来的 —— 配置文件里的强随机值,还是源码里的
123456? - 验完用的是什么 —— 验的是
token,用的是不是同一个token? - 失败走哪条路 —— 返回
false之后调用方怎么处理?会不会被 catch 吞掉?
一句话记法:
不要问”有没有校验”,要问”校验的对象、凭证、失败路径分别是什么“。
5. 同一个思路适用于所有”防护痕迹”
代码里出现的防护函数,都要验证它到底拦住了什么,而不是认出它就完事:
prepareStatement / verify / escape / replace / normalize / checkSql / 强制类型转换 …
延伸:JwtUtils.java 里 generateToken 用的是 jjwt 库、verifyTokenByJavaJwt 用的是 java-jwt 库 —— 同名功能由两个不同的库各写一半,本身就是嗅觉点,因为两个库的密钥处理方式、默认算法、校验严格程度可能都不一样。