跳转到帖子

背景

很多代码审计文章会直接从某个漏洞点切入,比如 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、@RequestBody
  • HttpServletRequest#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 层绑定资源边界
反序列化自动类型、表达式、脚本引擎关闭危险能力,使用白名单
依赖组件漏洞版本是否可达结合调用路径判断,优先升级

总结

代码审计的稳定方法是先建立入口视图,再围绕外部输入做数据流跟踪,最后结合业务权限和危险汇点判断影响。单纯堆工具或只搜危险函数,很容易出现误报和漏报。

实际工作里,我更建议把审计结论写成“代码位置 + 数据流 + 风险原因 + 最小复现 + 修复建议 + 回归点”的形式。这样开发能快速确认,安全侧也能持续沉淀规则。长期看,这比一次性挖几个问题更有价值。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…