跳转到帖子

背景

不少代码审计文章会直接从某个漏洞点开始讲,但在真实项目里,问题往往不是单点出现的。尤其是中后台、开放 API、文件处理、任务调度这类业务,漏洞通常藏在权限边界、数据流转和框架默认行为之间。

这篇文章整理一套偏实战但相对克制的审计方法,重点放在如何建立审计路径、如何验证风险、如何避免误报。内容适合用于内部安全评审、上线前代码检查,以及对历史系统做一次系统性体检。

本文只讨论授权环境下的代码审计和风险验证,不涉及未授权攻击、数据窃取或恶意利用。

审计前的准备

拿到一个项目后,不建议立刻全文搜索危险函数。更稳妥的做法是先理解项目结构和业务边界。至少需要确认以下信息:

  • 项目语言与框架,例如 Spring Boot、Laravel、Django、Express、Go Gin 等。
  • 鉴权方式,例如 Session、JWT、OAuth2、网关注入用户态。
  • 权限模型,例如 RBAC、数据权限、租户隔离、接口白名单。
  • 外部输入入口,例如 HTTP API、消息队列、定时任务、文件上传、Webhook。
  • 敏感能力,例如文件读写、命令执行、模板渲染、SQL 拼接、反序列化、第三方回调。

如果是 Java 或 Go 这类大型后端项目,我一般会先整理一张入口清单,再从入口向服务层和数据层追踪。这样比单纯查找关键字更接近真实风险。

第一步:梳理攻击面而不是直接找漏洞

审计入口可以先从路由、控制器、过滤器和中间件开始。以 Spring Boot 项目为例,可以先定位所有控制器和公开接口:

grep -R "@RestController\|@Controller\|@RequestMapping\|@GetMapping\|@PostMapping" -n src/main/java

如果项目中存在统一网关或鉴权拦截器,还需要确认哪些路径被排除在认证之外:

grep -R "excludePathPatterns\|permitAll\|antMatchers\|requestMatchers" -n src/main/java

这里的目标不是马上证明某个接口有问题,而是把接口分成几类:

  • 无需登录即可访问的接口。
  • 登录后所有用户可访问的接口。
  • 需要管理员或特定角色的接口。
  • 涉及文件、导入导出、回调、任务触发的接口。

经验上看,很多高危问题不是出现在复杂算法里,而是出现在“本来以为只有内部会调用”的接口上。尤其是管理端接口、调试接口、数据导入接口,经常存在权限假设过强的问题。

第二步:确认鉴权和授权边界

认证解决“你是谁”,授权解决“你能做什么”。代码审计时要特别区分这两件事。很多项目只在网关层校验登录态,但业务层没有做资源归属校验,容易出现越权问题。

一个典型场景是根据 ID 查询或修改资源:

@GetMapping("/order/detail")
public OrderDTO detail(Long orderId) {
    return orderService.getDetail(orderId);
}

上面这段代码本身看不出漏洞,关键要继续跟进 service 层是否校验了当前用户与订单的关系。安全一点的写法应该把用户上下文带入查询条件:

public OrderDTO getDetail(Long orderId, Long currentUserId) {
    Order order = orderMapper.selectByIdAndUserId(orderId, currentUserId);
    if (order == null) {
        throw new BizException("resource not found");
    }
    return convert(order);
}

审计越权问题时,可以重点关注以下模式:

  • 接口参数中出现 userId、tenantId、orgId、roleId,但没有从登录态或服务端上下文获取。
  • 查询条件只使用主键 ID,没有附加用户、租户、组织或权限范围。
  • 批量操作只校验了部分对象,后续直接按 ID 列表执行更新或删除。
  • 前端隐藏按钮作为权限控制依据,后端缺少二次校验。

第三步:跟踪危险能力的数据流

代码审计中更有效的方式是从“危险能力”反向查找调用链,再判断外部输入是否能影响关键参数。常见危险能力包括 SQL 执行、文件操作、模板渲染、表达式解析、命令执行、反序列化、HTTP 请求转发等。

以 SQL 注入风险为例,不要只搜索 select 或 query,而是重点查找字符串拼接、动态排序、动态表名和原生 SQL:

grep -R "createNativeQuery\|executeQuery\|Statement\|\${\|order by\|ORDER BY" -n src/main/java src/main/resources

MyBatis 项目里尤其要区分 #{} 和 ${}。前者通常是预编译参数,后者是字符串替换,后者如果接收外部输入,需要非常谨慎:

<select id="list" resultType="User">
    SELECT * FROM user
    WHERE status = #{status}
    ORDER BY ${sortField}
</select>

这里 sortField 如果来自请求参数,就需要白名单限制,而不是直接拼接:

private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("id", "created_at", "username");

public String safeSortField(String input) {
    if (!ALLOWED_SORT_FIELDS.contains(input)) {
        return "id";
    }
    return input;
}

类似地,文件读取和下载接口也要关注路径拼接。风险点通常不是单独的 File 对象,而是用户输入参与了路径构造:

File file = new File(basePath + "/" + request.getParameter("name"));

更稳妥的处理方式是进行路径规范化,并确认最终路径仍在允许目录内:

Path base = Paths.get(basePath).toRealPath();
Path target = base.resolve(fileName).normalize();

if (!target.startsWith(base)) {
    throw new SecurityException("invalid file path");
}

第四步:关注框架默认行为和历史兼容代码

真实项目里的问题,经常不是开发人员主动写了危险代码,而是框架特性、历史兼容逻辑和临时开关造成了风险。审计时可以重点检查以下位置:

  • 开发环境配置是否被带到生产,例如 debug、actuator 暴露、Swagger 未限制访问。
  • 跨域配置是否过宽,例如允许任意 Origin 且携带凭证。
  • 反序列化、表达式语言、模板引擎是否处理了用户可控内容。
  • 上传文件是否只校验后缀,没有校验 MIME、内容特征和存储位置。
  • 日志、导出、异常返回中是否包含敏感信息。

例如 Spring Boot Actuator 如果暴露过多端点,可能导致内部状态泄露。配置上通常应只开放必要端点,并放在内网或加访问控制:

management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=never

CORS 配置也要避免简单粗暴地允许全部来源:

// 不推荐
registry.addMapping("/**")
        .allowedOrigins("*")
        .allowCredentials(true);

更合理的做法是维护可信域名列表,并根据实际业务限制方法和请求头。

第五步:风险验证要可复现、可回滚、低影响

审计中发现可疑点后,需要验证风险是否真实存在。但验证不应以破坏数据或扩大影响为代价。比较稳妥的原则是:

  • 优先在测试环境或本地环境复现。
  • 使用专门的测试账号和测试数据。
  • 只验证到风险成立,不继续扩大影响面。
  • 所有请求、参数、响应和日志都要留存,方便开发复盘。
  • 涉及写操作时准备回滚脚本,或使用无副作用的对象。

例如验证水平越权时,不需要读取真实用户敏感数据,可以准备两个测试账号 A 和 B,分别创建测试资源,然后用 A 请求 B 的资源 ID。如果接口返回了 B 的资源详情,就足以证明授权校验存在问题。

验证 SQL 拼接风险时,也不建议使用破坏性 payload。可以通过异常差异、排序行为变化、参数边界变化来判断是否存在动态拼接问题。目标是定位根因,而不是追求攻击效果。

第六步:写审计结论时要便于修复

好的审计报告不是只写“存在漏洞”,而是能让开发快速理解风险和修改位置。建议每个问题至少包含以下信息:

字段说明
问题位置接口路径、类名、方法名、配置文件路径
风险描述说明外部输入如何影响敏感操作
影响范围涉及哪些角色、数据、环境或租户
复现方式使用测试账号和低影响参数说明验证过程
修复建议给出可落地的代码或配置修改方向
验证建议修复后如何回归测试,避免再次引入

修复建议要尽量具体。例如“加强权限校验”过于笼统,不如写成“在 orderService.getDetail 中增加 currentUserId 查询条件,并为批量查询补充 tenantId 过滤”。

常见问题与审计检查清单

下面是一份我在项目中常用的简化检查清单,可以作为初步审计参考。

  • 认证:是否存在匿名可访问的敏感接口,登录态是否可被错误复用。
  • 授权:是否校验资源归属、角色权限、租户边界和组织范围。
  • 输入校验:是否存在未限制的排序字段、文件名、URL、模板内容、表达式内容。
  • 输出控制:错误信息、日志、导出文件是否泄露密钥、手机号、身份证、Token 等敏感信息。
  • 文件处理:上传文件是否限制类型、大小、存储路径,下载是否防止路径穿越。
  • SQL 与 ORM:是否存在字符串拼接、动态表名、动态列名、原生 SQL。
  • 外部请求:是否存在用户可控 URL 导致服务端请求内部资源的风险。
  • 配置安全:生产环境是否关闭 debug,管理端点是否有访问控制。
  • 依赖组件:是否存在已知高危漏洞版本,是否有不可控反序列化入口。
  • 任务与消息:消费端是否信任消息内容,是否缺少幂等、签名或来源校验。

注意点

代码审计不是简单的关键字匹配。危险函数不一定等于漏洞,没有危险函数也不代表安全。真正需要判断的是:外部输入是否可达、是否经过有效约束、最终是否影响了敏感操作。

另外,不同团队的业务约束差异很大。比如同样是导出接口,在内部系统和公网开放平台的风险等级完全不同。审计结论最好结合调用来源、权限模型、数据敏感性和部署环境综合判断。

总结

一次有效的代码审计,应该从业务入口和权限边界开始,再围绕危险能力做数据流追踪,最后通过低影响方式验证风险。相比单点寻找漏洞,这种方式更容易发现真实系统中的高价值问题,也更便于开发团队修复。

如果只记住几个关键点,我建议是:先梳理入口,再确认授权;先看数据流,再看危险函数;验证要克制,报告要可修。这样做虽然慢一些,但在真实项目里更稳定,也更能沉淀长期价值。

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

0篇意见

推荐意见

没有意见。

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