背景
最近在帮团队梳理一批历史 Web 系统,特点比较典型:业务代码维护多年,框架版本不统一,接口文档缺失,很多权限和数据校验逻辑散落在控制器、服务层甚至前端页面里。单纯跑扫描器会有不少噪声,真正需要的是把入口、数据流、权限边界和危险函数串起来看。
这篇文章整理一套偏实战的代码审计流程,适合用于企业内部资产、安全评估和上线前复核。重点放在如何定位高风险问题、如何验证风险、如何给出可落地的修复建议,不涉及未授权入侵或破坏性利用。
审计目标与场景
我一般会先明确审计目标,否则很容易陷入“看了很多代码,但没有结论”的状态。常见目标可以拆成几类:
- 确认认证与授权是否存在绕过或越权风险。
- 检查用户输入到敏感操作之间是否缺少校验。
- 发现文件上传、文件读取、命令执行、SQL 拼接等高风险点。
- 梳理历史接口、测试接口、管理接口是否暴露在生产环境。
- 为后续加固提供优先级,而不是只列问题清单。
如果是第一次接触一个系统,我更建议从“资产入口”和“权限模型”开始,而不是直接全局搜索危险函数。前者能帮助判断风险面,后者能决定漏洞影响范围。
第一步:建立项目视图
拿到代码后,先不要急着看业务细节,可以先建立一张项目地图。重点关注目录结构、路由定义、鉴权中间件、公共工具类、配置文件和第三方依赖。
常见优先查看位置:
Java/Spring:
- src/main/resources/application*.yml
- Controller / RestController
- Filter / Interceptor / SecurityConfig
- Mapper XML / Repository
- Upload、File、Export、Import 相关模块
PHP:
- route 配置
- controller 目录
- middleware 目录
- common/helper 工具类
- config/database.php
Node.js:
- app.js / server.js
- routes 目录
- middleware 目录
- controller/service 目录
- package.json
这个阶段主要回答几个问题:哪些接口对外开放?登录态怎么维护?权限在哪里判断?异常处理是否会泄露敏感信息?数据库访问是 ORM 还是手写 SQL?文件存储位置是否可被外部访问?
第二步:梳理入口与权限边界
越权问题在内部系统里非常常见,尤其是多角色、多租户、多部门数据隔离场景。审计时我会把接口分成三类:
- 无需登录即可访问的接口,例如登录、验证码、回调、公开查询。
- 登录后即可访问的接口,例如个人信息、列表查询、导入导出。
- 需要特定角色或数据权限的接口,例如用户管理、审批、配置变更。
需要重点检查的是:控制器是否只判断了“是否登录”,但没有判断“是否有权操作该资源”。例如下面这种伪代码看起来有登录校验,但没有校验订单是否属于当前用户。
public OrderDetail getOrderDetail(Long orderId) {
User user = SessionUtil.getLoginUser();
if (user == null) {
throw new BizException("not login");
}
return orderService.getById(orderId);
}更稳妥的做法是把资源归属和角色权限放到服务层统一处理,避免每个控制器各写一套判断。
public OrderDetail getOrderDetail(Long orderId) {
User user = SessionUtil.getLoginUserRequired();
Order order = orderService.getById(orderId);
if (order == null || !order.getUserId().equals(user.getId())) {
throw new BizException("resource not found");
}
return OrderMapper.toDetail(order);
}这里有个经验点:对外响应建议使用“资源不存在”而不是明确提示“无权限访问该资源”,可以减少资源枚举风险。
第三步:追踪用户输入的数据流
代码审计的核心不是看到一个参数就下结论,而是确认它最终流向哪里。可以按“输入点 - 处理过程 - 危险点”来追踪。
| 输入点 | 可能风险 | 重点关注 |
| HTTP 参数、JSON Body | SQL 注入、越权、逻辑漏洞 | 是否参数化、是否校验归属 |
| 文件上传 | 任意文件写入、解析风险 | 扩展名、MIME、存储路径、访问权限 |
| URL、回调地址 | SSRF、开放重定向 | 协议、内网地址、重定向白名单 |
| 模板内容、表达式 | 模板注入、XSS | 输出编码、模板沙箱 |
| 系统命令参数 | 命令注入 | 是否拼接 shell、是否使用参数数组 |
例如 SQL 注入排查,不能只看是否用了 MyBatis 或 ORM。很多项目虽然用了框架,但仍然存在字符串拼接。
// 风险写法:直接拼接查询条件
String sql = "select * from user where name = '" + name + "'";
return jdbcTemplate.queryForList(sql);
// 相对安全:参数化查询
String sql = "select * from user where name = ?";
return jdbcTemplate.queryForList(sql, name);MyBatis 中也要注意 ${} 和 #{} 的区别。一般参数值应使用 #{},只有白名单控制后的字段名、排序方向等少数场景才考虑动态拼接。
<!-- 风险:用户可控内容进入 ${} -->
ORDER BY ${sortField} ${sortOrder}
<!-- 建议:字段与排序方向做白名单映射 -->
if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
sortField = "created_time";
}
if (!"asc".equalsIgnoreCase(sortOrder)) {
sortOrder = "desc";
}第四步:重点模块审计方法
1. 文件上传与导入导出
文件上传是历史系统里的高频风险点。除了检查扩展名,还需要关注存储位置、文件名生成、是否可直接访问、是否会被服务端解析。
- 不要使用用户提交的原始文件名作为最终文件名。
- 上传目录尽量放在 Web 根目录之外。
- 只允许业务需要的文件类型,例如 xlsx、csv、pdf、jpg。
- 服务端重新生成文件名,并固定后缀。
- 对图片类文件可做重新编码,降低伪造文件风险。
- 导入功能要限制文件大小、行数和解析耗时。
// 简化示例:扩展名白名单 + 随机文件名
Set<String> allowExt = Set.of("xlsx", "csv", "pdf");
String ext = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase();
if (!allowExt.contains(ext)) {
throw new BizException("invalid file type");
}
String filename = UUID.randomUUID() + "." + ext;
Path target = uploadRoot.resolve(filename).normalize();
if (!target.startsWith(uploadRoot)) {
throw new BizException("invalid path");
}
Files.copy(file.getInputStream(), target);文件下载也不能忽略。很多任意文件读取问题来自 download?file=xxx 这类接口。修复思路是使用文件 ID 映射真实路径,而不是直接接收路径参数。
2. SSRF 与回调地址
SSRF 常见于图片抓取、Webhook、URL 预览、第三方回调测试等功能。判断风险时要看服务端是否会根据用户传入 URL 发起请求。
较稳妥的处理方式是:只允许 http/https,解析域名后判断 IP 是否属于内网、回环、链路本地地址,并限制跳转次数。注意 DNS 重绑定问题,发起请求前后最好都基于解析结果做控制。
需要拦截的典型地址范围:
- 127.0.0.0/8
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16
- ::1/128
- fc00::/7
- fe80::/103. 命令执行与外部工具调用
业务系统偶尔会调用 ffmpeg、imagemagick、pandoc 等外部工具。审计时重点看是否把用户输入拼接进 shell 字符串。即使做了简单过滤,也容易遗漏边界情况。
// 不建议:拼接 shell
Runtime.getRuntime().exec("convert " + input + " " + output);
// 建议:参数数组 + 固定工具路径 + 输入输出路径受控
ProcessBuilder pb = new ProcessBuilder(
"/usr/bin/convert",
safeInputPath.toString(),
safeOutputPath.toString()
);
pb.start();如果业务允许,最好把这类转换任务放到隔离容器或独立服务中,限制文件系统权限、CPU、内存和执行时间。
4. 反序列化与表达式执行
Java 项目里如果存在 Hessian、Dubbo、RMI、老版本 Shiro、Fastjson、XStream 等依赖,需要重点看版本和入口是否暴露。这里不建议只依赖版本号判断,还要确认业务是否真的调用了相关解析逻辑。
表达式执行类问题常见于规则引擎、动态模板、报表系统、消息通知模板。审计时可以搜索:
关键词参考:
- eval
- ScriptEngine
- GroovyShell
- SpEL
- FreeMarker
- Velocity
- OGNL
- JSON.parseObject
- readObject
- unserialize
如果确实需要动态表达式,建议使用白名单函数、沙箱执行、限制上下文对象,避免把 request、session、classLoader、runtime 等敏感对象暴露给模板。
第五步:依赖与配置风险
依赖风险不要只看 CVE 数量,关键是结合可达性。一个高危组件如果没有被引入运行路径,优先级可能低于一个业务接口中的越权漏洞。
可以用工具做初筛,再人工确认可达性和影响面。例如 Java 项目可以结合 Maven 依赖树:
mvn dependency:tree -DskipTests
mvn -q org.owasp:dependency-check-maven:check配置方面重点看以下内容:
- 生产环境是否开启 debug、swagger、actuator、druid 监控页。
- 配置文件中是否硬编码数据库、Redis、AK/SK 等敏感信息。
- 错误页面是否返回堆栈、SQL、绝对路径。
- CORS 是否过宽,例如 Access-Control-Allow-Origin: * 且允许凭据。
- Cookie 是否设置 HttpOnly、Secure、SameSite。
对于 Spring Boot Actuator,建议只暴露必要端点,并放在内网或加认证。
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=never第六步:风险验证与证据记录
验证风险时要克制,尤其是生产环境。目标是证明问题存在,不是扩大影响。一般建议遵循以下原则:
- 优先在测试环境或本地复现。
- 只使用自有测试账号和最小化测试数据。
- 避免执行写入、删除、批量导出等破坏性操作。
- 请求和响应要脱敏保存,便于开发复核。
- 复现步骤要能稳定重复,但不要包含攻击性 payload 扩展。
一份有效的审计记录,至少包含:接口位置、影响角色、触发条件、风险说明、复现证据、修复建议、修复优先级。
问题记录模板:
标题:普通用户可访问其他用户订单详情
位置:/api/order/detail?orderId=
影响:横向越权,可能泄露订单收货信息
条件:登录态有效,知道或猜到 orderId
原因:服务端只校验登录状态,未校验订单归属
建议:查询时增加 userId 条件,或在服务层统一做资源归属校验
优先级:高常见误区
- 只跑扫描器,不看权限和业务逻辑。扫描器很难发现复杂越权。
- 只搜索危险函数,不追踪数据流。很多危险函数参数并不可控。
- 只给漏洞结论,不给修复路径。开发难以判断如何改。
- 把测试环境结论直接套到生产环境。配置、网络、权限可能不同。
- 忽略历史接口。很多真实风险来自废弃但仍可访问的接口。
修复建议的优先级
实际推动修复时,可以按“影响面 + 可利用性 + 数据敏感度”排序。通常建议优先处理:
- 未授权访问、越权访问、敏感信息泄露。
- 文件上传、任意文件读取、命令执行等高危入口。
- 可直接触达内网资源的 SSRF。
- 生产环境暴露的调试接口、管理接口。
- 存在公开利用链且业务可达的组件漏洞。
修复后需要做回归测试,尤其是权限类问题。只在前端隐藏按钮没有意义,必须在服务端做校验。
总结
代码审计不是单纯找几个危险函数,而是围绕业务入口、权限边界和数据流做系统性确认。对内部 Web 系统来说,越权、文件处理、SSRF、命令调用、依赖配置往往是最值得投入精力的方向。
一套可复用的审计流程应该做到:先建立项目视图,再梳理入口和权限,随后追踪用户输入到敏感操作的路径,最后结合可达性判断风险优先级。这样产出的结论更容易被开发接受,也更利于长期修复和安全基线建设。
经验上,好的审计报告不在于问题数量多,而在于每个问题都能说明原因、影响和修复方式。能被修掉的漏洞,才真正有价值。
推荐意见