背景
很多代码审计文章会直接从某个漏洞点切入,比如 SQL 注入、文件上传、反序列化。但实际项目审计时,更常见的问题是:代码量不小、框架不统一、业务逻辑杂,单点检索容易漏掉关键路径。本文整理一套偏实战的 Web 代码审计流程,重点放在如何建立入口视图、跟踪数据流、确认风险边界,以及如何把问题整理成可复现、可修复的结论。
这里不针对某个具体系统,也不提供攻击性利用链,示例代码和命令仅用于本地审计、测试环境验证和修复确认。
审计目标与常见场景
代码审计的目标不是“找几个洞”这么简单。比较可靠的审计结果至少要回答几个问题:
- 外部输入从哪里进入系统,例如路由参数、表单、JSON Body、Header、Cookie、文件上传、第三方回调。
- 输入经过哪些校验、转换、拼接、存储和输出。
- 敏感操作是否有统一鉴权和权限边界。
- 危险函数或组件是否被业务包装过,包装层是否可靠。
- 漏洞能否在测试环境稳定复现,修复是否能被回归验证。
实际审计中,比较容易产生高价值发现的场景包括:后台管理接口、导入导出功能、文件处理、模板渲染、报表查询、第三方登录回调、消息队列消费、定时任务、插件扩展机制。
第一步:建立项目入口视图
拿到一个项目后,不建议马上全文搜索危险函数。先确认技术栈和入口结构,这一步能减少很多误判。
常见检查点如下:
- Web 框架:Spring MVC、Spring Boot、Laravel、ThinkPHP、Django、Flask、Express、Gin 等。
- 路由定义:注解路由、配置路由、文件路由、集中式 Controller。
- 认证方式:Session、JWT、网关鉴权、统一登录中间件。
- 权限方式:角色权限、资源权限、数据权限、接口白名单。
- 数据访问层:ORM、MyBatis XML、原生 SQL、查询构造器。
- 模板和前端输出:服务端模板、前后端分离接口、富文本渲染。
在本地可以先用简单命令快速获得项目结构感:
find . -maxdepth 3 -type f | sed 's#^./##' | sort | head -200
# Java 项目查看 Controller、Mapper、Filter 等入口
grep -R "@RequestMapping\|@GetMapping\|@PostMapping\|@RestController\|Filter\|Interceptor" -n src/main 2>/dev/null
# PHP 项目查看路由和控制器
grep -R "Route::\|function .*Action\|controller" -n app routes 2>/dev/null
# Node.js 项目查看路由
grep -R "router\.\|app\.get\|app\.post\|app\.use" -n . 2>/dev/null这一步的产物最好是一张简单的文字版入口清单:哪些接口需要登录,哪些是公开接口,哪些接口操作敏感数据。没有这个视图,后面判断漏洞影响会比较飘。
第二步:从外部输入开始做数据流跟踪
审计的核心是数据流。外部输入进入系统后,最终到达了哪里。常见的危险汇点包括 SQL 执行、命令执行、文件读写、模板渲染、反序列化、重定向、HTTP 请求转发、日志输出等。
以 Java Web 为例,外部输入可能来自:
@RequestParam、@PathVariable、@RequestBodyHttpServletRequest#getParameter、getHeader、getCookies- 上传文件对象,例如
MultipartFile - 第三方回调参数,例如 OAuth、支付回调、Webhook
危险汇点可以先建立一个本地检索清单:
# SQL 相关
grep -R "createQuery\|createNativeQuery\|prepareStatement\|Statement\|\${" -n src/main 2>/dev/null
# 文件操作
grep -R "FileInputStream\|FileOutputStream\|Files\.read\|Files\.write\|transferTo\|new File" -n src/main 2>/dev/null
# 命令执行
grep -R "Runtime.getRuntime\|ProcessBuilder" -n src/main 2>/dev/null
# 反序列化
grep -R "ObjectInputStream\|readObject\|XMLDecoder\|Yaml" -n src/main 2>/dev/null
# SSRF 常见 HTTP 客户端
grep -R "RestTemplate\|WebClient\|HttpClient\|OkHttp\|URLConnection" -n src/main 2>/dev/null这里要注意,搜索结果只是线索,不是漏洞结论。真正有价值的是确认“可控输入是否到达危险汇点,以及中间是否有有效约束”。
SQL 注入审计:重点看动态拼接边界
现代项目中直接字符串拼接 SQL 的比例下降了,但动态查询仍然常见。尤其是报表、后台列表、导出功能,经常出现排序字段、筛选条件、表名字段动态传入的问题。
安全写法通常依赖参数绑定,但参数绑定不能保护 SQL 结构片段,例如列名、排序方向、表名。这类字段应使用白名单映射。
// 不建议:排序字段直接进入 SQL 结构
String sql = "select * from user where status = ? order by " + sort;
// 建议:只允许业务定义过的字段
Map<String, String> allowedSort = Map.of(
"name", "name",
"created", "created_time",
"id", "id"
);
String sortColumn = allowedSort.getOrDefault(sort, "id");
String sql = "select * from user where status = ? order by " + sortColumn;MyBatis 项目里尤其要区分 #{} 和 ${}。前者是参数绑定,后者是文本替换。并不是所有 ${} 都一定有问题,但它必须配合严格白名单,不能直接接收外部输入。
文件上传与文件读取:关注路径和内容双重约束
上传功能的审计不能只看扩展名。更稳妥的检查点包括:
- 上传目录是否在 Web 可直接访问目录内。
- 文件名是否使用服务端生成,是否避免路径穿越。
- 扩展名和 MIME 是否只是辅助校验,是否检查真实文件头。
- 是否对图片进行重新编码处理。
- 是否限制大小、数量、压缩包层级和解压后总大小。
- 是否存在上传后解析、预览、导入等二次处理链路。
路径拼接是另一个常见问题。判断是否存在路径穿越时,可以看用户输入是否参与了 new File、Paths.get、Files.read* 等调用,并确认是否做了规范化后的目录边界检查。
Path baseDir = Paths.get("/data/app/uploads").toRealPath();
Path target = baseDir.resolve(userFileName).normalize();
if (!target.startsWith(baseDir)) {
throw new IllegalArgumentException("invalid path");
}这里的关键不是简单过滤 ../,而是规范化后确认目标路径仍在允许目录下。对于符号链接场景,还要结合 toRealPath 做更严格处理。
SSRF 审计:不要只看 URL 黑名单
只要系统允许用户提供 URL,并由服务端发起请求,就要考虑 SSRF 风险。常见场景包括:图片抓取、网页截图、Webhook 测试、在线导入、远程文件解析、URL 健康检测。
审计时建议重点确认:
- 是否限制协议,只允许
http和https。 - 是否做域名白名单,而不是简单过滤 IP 字符串。
- 是否解析 DNS 后校验目标 IP,防止访问内网和本机地址。
- 是否处理重定向,重定向后的地址也要重新校验。
- 是否限制响应大小、超时时间和下载类型。
比较常见的误区是只检查 URL 字符串里是否包含 127.0.0.1、localhost。这种方式很脆弱。更可靠的做法是解析主机名,对得到的 IP 做网段判断,并在发起请求前后保持一致校验。
鉴权与越权:从业务动作而不是接口名判断
越权问题通常不是靠搜危险函数发现的。审计时要从业务动作出发,例如查看订单、修改用户资料、下载合同、审批流程、导出数据等。
建议将接口分成三类:
- 身份鉴别:是否登录,Token 是否有效。
- 功能权限:当前角色是否允许执行该动作。
- 数据权限:当前用户是否拥有目标资源。
很多系统只做了前两层,漏了数据权限。例如接口要求登录,也要求有“订单查看”权限,但没有校验订单是否属于当前租户、部门或用户。这类问题在多租户系统、SaaS 后台、代理商系统里非常常见。
代码层面可以关注类似模式:
// 风险点:只按 id 查询,没有绑定当前用户或租户
Order order = orderMapper.selectById(orderId);
// 更稳妥:查询时带上资源边界
Order order = orderMapper.selectByIdAndTenant(orderId, currentTenantId);修复越权时,不建议只在 Controller 临时补判断。更推荐把资源边界下沉到 Service 或数据访问层,避免同类接口重复遗漏。
反序列化与表达式执行:重点看“可控对象”和“可控表达式”
反序列化漏洞不一定只存在于 Java 原生序列化。JSON 多态反序列化、XML 解析、YAML 解析、模板表达式、规则引擎、脚本引擎,都可能引入类似风险。
审计时可以关注:
- 是否存在
ObjectInputStream#readObject处理外部输入。 - JSON 组件是否启用了危险的自动类型识别。
- XML 解析是否禁用了外部实体。
- 模板、表达式、脚本内容是否可由用户控制。
- 消息队列、缓存、RPC 中的数据是否默认可信。
这类问题的修复思路通常是减少能力:关闭自动类型、使用白名单类型、禁用外部实体、避免让用户提交表达式。如果业务确实需要规则配置,应提供受限 DSL,而不是直接暴露通用脚本引擎。
依赖组件审计:漏洞版本只是起点
依赖扫描工具能快速发现已知漏洞版本,但要避免两个极端:看到 CVE 就认为可利用,或者因为“不直接暴露接口”就忽略。比较可靠的判断方式是结合调用路径。
可以先导出依赖树:
# Maven
mvn dependency:tree -DskipTests
# Gradle
./gradlew dependencies
# Node.js
npm ls --all
# Python
pip freeze然后确认三个问题:
- 漏洞组件是否被实际打包到生产环境。
- 系统是否调用了受影响功能。
- 攻击者是否能控制触发该功能的输入。
如果短期无法升级,要给出临时缓解措施,例如关闭高危功能、限制入口、增加输入白名单、在网关层阻断特征请求。但临时缓解不能替代版本升级。
复现与验证:让结论可被开发接受
一份好的审计报告不需要夸大影响,但必须可复现。复现建议在本地或授权测试环境完成,保留最小化请求、日志和代码位置。
建议记录以下信息:
- 受影响接口、文件路径、函数名、代码行号。
- 输入参数如何进入危险汇点。
- 当前校验为什么不足。
- 最小化复现步骤和预期现象。
- 修复建议和回归测试点。
对于不能公开细节的高风险问题,可以在报告中保留数据流、截图和脱敏请求,不需要提供破坏性操作。
常见误区
- 只依赖关键词搜索。关键词能发现线索,但不能替代数据流分析。
- 只看 Controller。很多风险在 Service、Mapper、异步任务、消息消费者里。
- 只审计登录后的接口。公开回调、资源预览、健康检查、导入接口经常更危险。
- 只关注 RCE。越权、信息泄露、逻辑缺陷在真实业务里的影响往往更直接。
- 修复只做过滤。更可靠的方式通常是白名单、参数化、权限下沉、能力收敛。
一份可执行的审计清单
| 审计项 | 重点问题 | 建议动作 |
| 入口梳理 | 公开接口、后台接口、回调接口是否清楚 | 整理路由、鉴权、敏感动作清单 |
| SQL 查询 | 动态拼接、排序字段、表名字段 | 参数绑定,结构字段白名单 |
| 文件功能 | 上传、读取、解压、预览 | 目录边界检查,文件类型和大小限制 |
| SSRF | 服务端请求用户 URL | 协议限制,域名/IP 白名单,重定向复检 |
| 鉴权越权 | 功能权限和数据权限是否完整 | 在 Service 或 DAO 层绑定资源边界 |
| 反序列化 | 自动类型、表达式、脚本引擎 | 关闭危险能力,使用白名单 |
| 依赖组件 | 漏洞版本是否可达 | 结合调用路径判断,优先升级 |
总结
代码审计的稳定方法是先建立入口视图,再围绕外部输入做数据流跟踪,最后结合业务权限和危险汇点判断影响。单纯堆工具或只搜危险函数,很容易出现误报和漏报。
实际工作里,我更建议把审计结论写成“代码位置 + 数据流 + 风险原因 + 最小复现 + 修复建议 + 回归点”的形式。这样开发能快速确认,安全侧也能持续沉淀规则。长期看,这比一次性挖几个问题更有价值。
推荐意见