上一篇把依赖和配置过完了,产出是一张"重点跟进清单"。这一篇进代码。
进代码第一件事不是看业务,是把接口全找出来。
原因很直接:审计本质是逐条检查"外部输入能不能变成危险操作"。如果连外部输入有多少个入口都不清楚,就只能靠运气翻代码。一套若依二开系统的业务接口在 200 到 400 个之间,翻是翻不完的,只能按清单走。
接口梳理的目标是拿到一张表,每条记录四个字段:
HTTP 方法 | 路径 | 处理方法 | 权限注解
第四个字段决定了这条接口是"要登录才能打"还是"匿名就能打"。做越权审计的时候,这一列就是全部。
0x01 有源码能编译:搜注解
最直接的办法,也是最多人用的办法。但有一个坑。
只搜 @RequestMapping 会漏一半
@RestController
@RequestMapping("/system/user")
public class SysUserController extends BaseController {
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('system:user:export')")
@PostMapping("/export")
public void export(HttpServletResponse response, SysUser user) {
List<SysUser> list = userService.selectUserList(user);
ExcelUtil<SysUser> util = new ExcelUtil<SysUser>(SysUser.class);
util.exportExcel(response, list, "用户数据");
}
@PreAuthorize("@ss.hasPermi('system:user:remove')")
@DeleteMapping("/{userIds}")
public AjaxResult remove(@PathVariable Long[] userIds) {
return toAjax(userService.deleteUserByIds(userIds));
}
}
这里有三个问题。
第一,路径是拼出来的。 类上是 /system/user,方法上是 /list,实际路由是 /system/user/list。只搜方法级注解的话,你拿到的是 /list,然后要在脑子里把前缀补回去——两百个接口靠脑子补,一定会错。搜的时候必须把类级 @RequestMapping 一起抓出来。
第二,注解有五个,不是一个。
@RequestMapping、@GetMapping、@PostMapping、@PutMapping、@DeleteMapping、@PatchMapping,再加上 JAX-RS 的 @Path/@GET、WebFlux 的 @RequestMapping。少搜一个就是漏。
第三,@PreAuthorize 决定了这条接口谁能访问。
上面三条接口的注解值分别是 system:user:list、system:user:export、system:user:remove。没有 @PreAuthorize 的接口,只要登录就能调用,不管对方是什么角色。 这是越权审计里最直接的一类问题:翻一遍接口清单,看看有没有本该限管理员的功能忘了加权限注解。
RuoYi-Vue 里还有一个 @Anonymous:
@Anonymous
@GetMapping("/captchaImage")
public AjaxResult getCode(HttpServletResponse response) throws IOException { ... }
这个注解是显式声明"不需要登录"的标记。搜出来,一条条看。 这是免认证接口的完整清单,也是越权审计的第一优先级。
搜的时候用组合条件
grep -rnE "@(Get|Post|Put|Delete|Patch|Request)Mapping" --include=*.java . \
| grep -v "^\s*//" > routes_raw.txt
# 单独把类级前缀捞出来
grep -rnE "^@RequestMapping" --include=*.java .
搜完记得排掉被注释掉的行。老项目里注释掉的接口不少,注释掉的代码不会注册路由,但会污染你的清单。
0x02 断点法:一次拿全量路由
搜注解是文本层面的猜,框架自己有准确的注册表。用框架的。
Spring MVC 启动时,RequestMappingHandlerMapping 会扫描所有带 @Controller / @RestController 的 bean,把每个方法的路由信息注册进自己的 MappingRegistry。这个注册表就是权威答案,包含所有注解形式声明的路由。
拿它有两种方式。
方式一:Actuator
management:
endpoints:
web:
exposure:
include: "*"
启动后访问 /actuator/mappings,返回的 JSON 里 contexts.*.mappings.dispatcherServlets.dispatcherServlet 下面就是全量路由。
这个办法不用调试,但有两个前提:Actuator 得引了,端点得暴露。实际项目里 Actuator 常常没引,所以更通用的是方式二。
方式二:在调试器里读
应用跑起来,在任意一个 Controller 方法上打断点,触发一次请求让断点命中,然后在 IDE 的 Evaluate Expression 里执行:
org.springframework.web.context.support.WebApplicationContextUtils
.getWebApplicationContext(request.getServletContext())
.getBean("requestMappingHandlerMapping",
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.class)
.getHandlerMethods()
.keySet()
request 是断点所在方法的参数,作用域里有。IDEA 的 Evaluate Expression 支持全限定名,不用额外 import。
返回值打印出来长这样:
[{GET /system/user/list}, {POST /system/user/export}, {DELETE /system/user/{userIds}},
{GET /common/download/resource}, {POST /monitor/job/run}, ...]
HttpMethod、路径、路径变量全都对,不需要自己拼接。
如果项目启动不起来,或者只有编译好的包,可以在启动阶段下断点:AbstractHandlerMethodMapping.initHandlerMethods()。这个方法在容器启动时逐条注册路由,命中断点后同样 evaluate getHandlerMethods(),只是这时候拿到的可能不是最终结果,需要放行到注册结束。
还有一种情况:只要路径不要方法名
用 RuoYi 这类项目,接口数量多,可以用 IDEA Ultimate 的 Endpoints 工具窗口(View → Tool Windows → Endpoints)。它直接列出 Spring MVC 的映射,支持按路径搜索和跳转源码,比在调试器里翻方便。Community 版没有这个窗口。
0x03 只有 jar 或 war,没有源码
企业交付里很常见:给你一个 war,或者一个跑着的服务。
先反编译,再搜
# 解压
unzip -o app.war -d app_extracted
# CFR 批量反编译,直接吃 jar/war
java -jar cfr.jar app.war --outputdir src_decompiled
CFR、Procyon、JD-Core 都能用。IDEA 直接把 jar 拖进去也会自动反编译,而且能直接搜注解,适合小规模。
反编译之后,按 0x01 的办法搜注解就行。注解信息在 class 文件里是保留的(@Retention(RUNTIME)),反编译出来不会丢。
不想反编译:从常量池里捞
注解的字符串值编译后存在 class 文件的常量池里,是明文的。可以用 strings 直接捞:
find app_extracted/WEB-INF/classes -name '*.class' -exec strings {} \; \
| grep -E '^/[A-Za-z0-9_/{}._-]{2,60}$' \
| sort -u
在 Windows 上把 strings 换成 grep -a 也一样。
这个办法几秒钟能出结果,适合先快速判断"这套系统大概有哪些接口",再决定要不要花时间完整反编译。缺点是会有噪音——常量池里所有以 / 开头的字符串都会被捞出来,包括日志格式串和文件路径。当作线索用,不要当权威清单用。
0x04 XML 里的路由
有一类路由,注解和反编译都覆盖不到,因为它们声明在 XML 里。
Servlet 3.0 注解形式:
@WebServlet("/legacy/export")
public class LegacyExportServlet extends HttpServlet { ... }
老 Spring 的 URL 映射:
<bean id="urlMapping" class="org.springframework.web.servlet.handler.SimpleUrlHandlerMapping">
<property name="mappings">
<props>
<prop key="/old/export.do">exportController</prop>
<prop key="/old/import.do">importController</prop>
</props>
</property>
</bean>
Struts2:
<package name="admin" namespace="/admin" extends="struts-default">
<action name="user_*" class="com.xxx.action.UserAction" method="{1}">
<result name="success">/WEB-INF/jsp/user.jsp</result>
</action>
</package>
最后这个是重点。name="user_*" 加 method="{1}" 是一个通配映射:/admin/user_login.action 会调到 UserAction.login(),/admin/user_delete.action 会调到 UserAction.delete()。一行 XML 声明对应多个 URL,靠数 XML 行数是数不出接口数量的,必须读通配规则。
MCMS 这类 war 部署的老项目,要同时检查三处:
grep -n "servlet-class\|filter-class\|url-pattern" web.xml
grep -rn "SimpleUrlHandlerMapping\|<prop key=" --include=*.xml .
grep -rn "<action\|<package" --include=struts*.xml .
这类接口的特点是通常不走框架的过滤器和拦截器链。它们可能不受 Shiro / Spring Security 保护,可能是当年为了临时需求加的,而且路径在 Java 代码里搜不到。梳理接口清单时,这一批要单独列出来,逐个确认鉴权状态。
0x05 一次翻车:我只搜了 @RequestMapping
第一次做接口梳理的时候,我 grep 了 @RequestMapping,得到 180 条,觉得差不多了,就直接进业务。
后来为了确认另一个问题,在 requestMappingHandlerMapping 上 evaluate 了一次 getHandlerMethods(),拿到 247 条。
差的 67 条,绝大部分是 @PostMapping 和 @DeleteMapping。
更难受的是漏掉的那批里有什么:
@PostMapping("/run")
public AjaxResult run(@PathVariable Long jobId) { ... } // /monitor/job/run
@PostMapping("/createTable")
public AjaxResult createTable(String sql) { ... } // /tool/gen/createTable
一条是手动触发定时任务,一条是拿前端传的 SQL 直接建表。这两条恰恰是最该先看的一批接口——能执行代码、能执行 SQL 的接口。 而它们全在我漏掉的那 67 条里。
原因很简单:/run 是动作型的,开发写的时候顺手用 @PostMapping;createTable 也是。而我当时只搜了字面量 @RequestMapping。
两个教训:
搜注解要搜全五类,或者干脆别用文本搜,直接用框架的注册表。 前者容易漏,后者不会。
用断点法拿到的清单里会混进框架自带的端点。 error、/actuator/*、SpringBoot 的 /favicon.ico 都会在里面。过滤掉这批,剩下的才是业务接口。这次 247 条里,业务接口实际是 229 条。
0x06 从接口清单到重点清单
229 条业务接口,还是要排优先级。我的分法不是按漏洞类型,是按这个接口能干什么。
按风险从高到低:
能执行代码的。 定时任务、代码生成、脚本引擎、表达式解析、动态数据源管理、Groovy/JS 引擎相关。这类接口一旦参数可控就是 RCE,审计成本低、收益高,永远第一批看。
能操作文件的。 上传、下载、导入、导出、解压、模板在线编辑。这条线上挂着上传、任意文件读取、路径穿越、XXE、模板注入。
能拼 SQL 的。 列表查询、搜索、统计、报表。重点看有没有排序参数,因为 ORDER BY 后面没法用预编译。
能发外部请求的。 回调、代理、图片抓取、远程调用、Webhook 测试。SSRF 的落点。
认证相关本身。 登录、注册、找回密码、验证码、token 刷新。逻辑漏洞和认证绕过都在这。
权限管理。 用户、角色、菜单、部门、参数、公告。越权的主要目标。
接口名说明功能,参数名说明风险
只看接口名还不够。同一个 /list,参数里有没有 orderByColumn,风险差一个数量级。
所以第二步是把每个接口的参数名过一遍:
| 参数名 / 关键字 | 指向的问题 |
|---|---|
orderByColumn isAsc orderBy sortName sort | ORDER BY 拼接,SQL 注入 |
url uri target callback redirect picUrl | SSRF、开放重定向 |
fileName filePath path name download | 路径穿越、任意文件读写 |
xml data content template tpl | XXE、模板注入 |
className beanName methodName invokeTarget | 反射调用,RCE |
sql tableName columns ddl | 直接拼接 SQL |
type action cmd op status | 逻辑分支绕过 |
id ids userId deptId | 水平越权 |
这张表配合接口清单用:先按功能把接口分六档,再在每个接口的参数里搜这几个关键字。命中的直接进重点清单。
0x07 清单
[ ] 类级 @RequestMapping 前缀全部摘出,与方法级路径拼接
[ ] 五类 Mapping 注解 + @Path 全部搜到,排除注释行
[ ] 搜索 @Anonymous / permitAll / 匿名放行标记,列出免认证接口
[ ] 逐条记录 @PreAuthorize 值,标出无权限注解的接口
[ ] 用 RequestMappingHandlerMapping.getHandlerMethods() 或 /actuator/mappings 交叉验证
[ ] 框架自带端点(error、actuator、favicon)从清单中剔除
[ ] web.xml / SimpleUrlHandlerMapping / struts.xml 中的路由单独列出
[ ] Struts2 通配映射展开成实际 URL
[ ] 上述 XML 路由逐条确认鉴权状态
[ ] 按功能把接口分六档,标出"能执行代码"和"能操作文件"两类
[ ] 按参数名关键字扫一遍,命中的进重点清单
产出:一张接口表,每条带 HTTP 方法、路径、处理方法、权限注解;外加一张按风险排序的重点清单。
下一篇讲数据流:从入口参数到危险函数怎么追,以及怎么判断一个过滤器到底能不能绕。