跳转到帖子

背景

最近在一次企业内网安全巡检中,遇到一个比较典型的 Web 异常访问事件:边界设备没有发现明显入侵告警,但业务侧反馈某个管理后台偶发 500,且访问日志中出现了大量非常规参数。整体事件没有造成业务中断,但排查过程比较完整,适合整理成一篇复盘,重点放在日志分析、漏洞定位和代码审计闭环上。

本文不会讨论攻击利用细节,也不会提供可直接用于入侵的操作步骤,主要记录防守视角下如何从日志入手,确认风险点并推动修复。

问题场景

受影响系统是一个 Java Web 管理后台,部署在内网网段,通过统一网关反向代理暴露给办公网访问。系统本身历史较久,部分模块仍使用传统 MVC 写法,参数校验依赖业务代码手工判断。

最早的异常来自 Nginx access.log,表现为:

  • 同一来源 IP 在短时间内访问多个历史接口。
  • 请求参数长度明显偏长,包含较多特殊字符。
  • 部分接口返回 500,部分返回 200,但响应体大小异常。
  • User-Agent 比较杂乱,疑似脚本化探测。

这类现象不一定意味着已经被攻陷,但通常说明系统正在被扫描或被验证历史漏洞,需要尽快确认是否存在可利用缺陷。

日志初筛思路

第一步不是直接看代码,而是先从日志中建立时间线。建议至少收集以下几类日志:

  • 反向代理访问日志和错误日志。
  • 应用访问日志、异常栈日志。
  • 认证日志,包括登录成功、失败、会话刷新。
  • 操作审计日志,如后台配置变更、文件上传、任务执行。
  • 主机侧日志,如进程启动、计划任务、临时目录文件变更。

实际排查时,我通常先按来源 IP、URI、状态码、响应大小几个维度聚合,找出异常请求集中在哪些接口。

awk '{print $1, $7, $9, $10}' access.log | sort | uniq -c | sort -nr | head -50

如果日志格式包含请求耗时,也可以把高耗时请求单独筛出来。很多漏洞探测会导致异常查询、异常反序列化或模板渲染失败,这些都可能在耗时上有所体现。

awk '$NF > 2 {print $1, $7, $9, $NF}' access.log | head -100
注意:不同公司的 Nginx 日志格式不一样,上面的命令只是示例。排查时要先确认字段含义,避免误判。

异常特征归类

经过初步聚合,可以把异常请求大致分成三类:

  • 目录和接口枚举:访问旧路径、测试接口、备份文件名。
  • 参数型探测:针对查询参数传入特殊字符或长字符串。
  • 认证绕过类尝试:直接访问后台接口、伪造常见 Header。

这里比较值得关注的是第二类。某个历史接口频繁返回 500,应用日志中出现了数据库异常和模板渲染异常。虽然仅凭异常不能直接判断漏洞类型,但说明该接口对输入处理不够稳健,至少存在拒绝服务或信息泄露风险。

应用日志中的一段典型异常类似下面这样:

java.lang.IllegalArgumentException: invalid sort field
    at com.example.admin.service.QueryService.buildOrderBy(QueryService.java:87)
    at com.example.admin.controller.ReportController.list(ReportController.java:42)

异常指向了报表查询模块,下一步就需要结合代码审计确认参数如何进入业务逻辑。

代码审计定位

相关接口代码结构比较常见:Controller 接收查询条件,Service 拼装查询对象,DAO 层执行分页查询。问题出现在排序字段 sort 和排序方向 order 的处理上。

// 示例代码,已做脱敏处理
@GetMapping("/report/list")
public PageResult list(String keyword, String sort, String order, Integer page) {
    return reportService.query(keyword, sort, order, page);
}

Service 中原始实现大致如下:

public PageResult query(String keyword, String sort, String order, Integer page) {
    String orderBy = sort + " " + order;
    return reportMapper.selectPage(keyword, orderBy, page);
}

这类写法的问题不在于某个框架本身,而是把外部输入直接拼进了排序表达式。即便 DAO 层使用了参数化查询,很多 ORM 或 SQL Mapper 对 order by 字段也不能直接参数化,只能通过白名单方式控制。

继续检查 Mapper,发现 orderBy 被作为文本片段拼接:

<select id="selectPage" resultType="Report">
  select id, name, status, created_at
  from report
  where deleted = 0
  <if test="keyword != null and keyword != ''">
    and name like concat('%', #{keyword}, '%')
  </if>
  order by ${orderBy}
</select>

这里的风险点很明确:keyword 使用了绑定变量,而 orderBy 使用了文本替换。对排序字段来说,正确做法不是简单过滤特殊字符,而是把允许排序的字段固定成枚举或映射表。

修复方案

修复时建议遵循两个原则:一是默认拒绝未知字段,二是业务字段名和数据库字段名分离,不把数据库列名直接暴露给前端。

private static final Map<String, String> SORT_FIELD_MAP = Map.of(
    "name", "name",
    "status", "status",
    "createdAt", "created_at"
);

public PageResult query(String keyword, String sort, String order, Integer page) {
    String column = SORT_FIELD_MAP.getOrDefault(sort, "created_at");
    String direction = "asc".equalsIgnoreCase(order) ? "asc" : "desc";
    String orderBy = column + " " + direction;
    return reportMapper.selectPage(keyword, orderBy, normalizePage(page));
}

如果团队对 Mapper 层规范比较严格,也可以进一步避免传入完整 orderBy,只传入枚举后的字段和方向,并在 SQL 层使用 choose 结构进行控制。这样可读性略差,但更容易被代码扫描规则识别。

<choose>
  <when test="sort == 'name'">order by name</when>
  <when test="sort == 'status'">order by status</when>
  <otherwise>order by created_at</otherwise>
</choose>
<choose>
  <when test="order == 'asc'">asc</when>
  <otherwise>desc</otherwise>
</choose>

同时补上分页参数边界,避免被大页码或超大 pageSize 拖慢查询:

private int normalizePageSize(Integer pageSize) {
    if (pageSize == null) {
        return 20;
    }
    return Math.min(Math.max(pageSize, 1), 100);
}

验证与回归

修复后不要只验证正常查询能用,还要针对异常输入做回归测试。这里建议准备一组安全测试用例,覆盖以下情况:

  • sort 为空、未知字段、大小写混合字段。
  • order 为空、非 asc/desc 字符串。
  • keyword 包含特殊字符时仍走绑定变量。
  • page 和 pageSize 为负数、超大值、非数字。
  • 接口返回错误时不泄露 SQL、栈信息、绝对路径。

如果系统有单元测试,可以把排序字段白名单逻辑独立出来测试,成本很低,但收益很明显。

@Test
public void shouldFallbackWhenSortFieldInvalid() {
    String orderBy = service.buildOrderBy("unknown", "asc");
    assertEquals("created_at asc", orderBy);
}

主机侧排查

虽然这次没有发现成功利用迹象,但只修代码还不够。对已经暴露异常的系统,建议做一次轻量主机侧检查,确认没有异常落地文件或异常进程。

  • 检查 Web 目录、上传目录、临时目录最近变更文件。
  • 检查应用用户权限,确认不能写入代码目录。
  • 检查 crontab、systemd service、启动脚本是否有异常项。
  • 检查应用日志中是否出现非预期管理操作。
  • 检查数据库账号权限,避免应用账号拥有高危权限。

一些常用的只读排查命令如下:

find /opt/app -type f -mtime -7 -ls
find /tmp -type f -mtime -3 -ls
ps aux --sort=-%cpu | head
crontab -l
systemctl list-timers --all

这些命令本身不会改变系统状态,适合在事件早期做快速确认。生产环境执行前仍建议记录时间点和操作者,方便后续复盘。

日志与监控改进

这次事件暴露出一个常见问题:应用日志有异常栈,但缺少请求上下文,导致关联成本较高。后续改进时,可以考虑在网关和应用之间统一透传 requestId,并在日志中打印关键字段。

log.info("requestId={}, userId={}, uri={}, clientIp={}", requestId, userId, uri, clientIp);

同时建议增加几类告警规则:

  • 单 IP 短时间访问大量不存在路径。
  • 同一接口 500 数量突增。
  • 请求参数长度异常增长。
  • 后台接口被未登录用户频繁访问。
  • 响应体大小与历史基线明显偏离。

告警不需要一开始就很复杂,先把高置信度规则跑起来,再根据误报慢慢调整。很多时候,简单规则只要覆盖关键接口,就能提前发现问题。

代码审计中的几个经验点

从这次排查看,历史系统里最容易被忽略的不是明显的字符串拼接查询,而是一些“看起来只能传字段名”的参数,比如排序字段、分组字段、导出列、动态筛选条件等。

风险位置常见问题建议做法
排序字段外部参数直接进入 order by白名单映射
导出字段前端控制导出列名后端枚举可导出字段
动态筛选字段、操作符均由用户传入字段和操作符分别白名单
模板渲染用户输入进入模板表达式上下文转义和表达式限制
文件下载路径由参数拼接固定根目录和文件 ID 映射

审计时可以按“输入入口、数据流转、危险汇聚点”三步走。入口不只包括 Controller,也包括消息队列、定时任务、导入文件、第三方回调。危险汇聚点则包括 SQL 片段、文件路径、命令执行、模板表达式、反序列化入口等。

注意点

  • 不要只依赖黑名单过滤。安全字符集在不同数据库、编码和框架下并不完全一致。
  • 不要把异常栈直接返回给前端。内部日志可以详细,外部响应应保持稳定和克制。
  • 不要忽略低权限后台。很多企业系统的后台权限边界比较粗,低权限账号也可能访问敏感查询接口。
  • 不要在事件处理中覆盖原始日志。至少先备份涉及时间段的访问日志和应用日志。
  • 不要只修单点。发现一个 order by 拼接点后,应全局搜索类似写法。

总结

这次事件本身并不复杂,但比较典型:从异常日志出发,定位到历史接口,再通过代码审计确认风险点,最后完成修复、回归和监控补强。对企业内网系统来说,很多问题不是因为缺少高级防护,而是基础输入约束、日志字段和代码规范没有持续维护。

长期看,建议把这类问题沉淀成三件事:一是建立高风险写法清单,纳入代码评审;二是为关键接口补充异常输入测试;三是让日志具备可关联性。这样下次遇到类似扫描或异常访问时,才能更快判断影响范围,也更容易把处置工作闭环。

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

0篇意见

推荐意见

没有意见。

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