跳转到帖子

背景

最近在帮团队梳理一批历史 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 BodySQL 注入、越权、逻辑漏洞是否参数化、是否校验归属
文件上传任意文件写入、解析风险扩展名、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::/10

3. 命令执行与外部工具调用

业务系统偶尔会调用 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、命令调用、依赖配置往往是最值得投入精力的方向。

一套可复用的审计流程应该做到:先建立项目视图,再梳理入口和权限,随后追踪用户输入到敏感操作的路径,最后结合可达性判断风险优先级。这样产出的结论更容易被开发接受,也更利于长期修复和安全基线建设。

经验上,好的审计报告不在于问题数量多,而在于每个问题都能说明原因、影响和修复方式。能被修掉的漏洞,才真正有价值。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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