背景
很多内网 Web 系统上线时间早、业务依赖重,平时很少有人系统性审计。真正出问题时,常见情况不是单点漏洞特别复杂,而是几个普通风险叠加:老旧框架、调试接口遗留、上传目录可访问、权限校验分散、日志缺失。本文整理一次偏防守视角的代码审计与风险收敛方法,重点放在如何发现问题、验证风险边界、推动修复,不涉及未授权攻击或恶意利用。
场景假设是企业内部自研 Web 系统,技术栈为 Java Spring MVC / Spring Boot 混合,前端有少量老 JSP,部署在 Linux + Nginx + Tomcat 环境。目标不是“打穿系统”,而是通过代码审计和受控验证,把可被利用的缺陷收敛到可修复、可复测的清单。
问题与场景
这类系统常见的问题并不新,但因为业务迭代多年,风险点往往藏在边角位置:
- 接口权限校验不一致:部分 Controller 依赖前端菜单控制,没有后端鉴权。
- 文件上传处理粗糙:只校验后缀或 Content-Type,上传目录又能被直接访问。
- SQL 拼接遗留:新模块使用 ORM,老模块仍有字符串拼接 SQL。
- 配置暴露:测试配置、默认口令、调试开关混入生产分支。
- 日志与审计不足:关键操作没有记录操作者、对象、结果,事件复盘困难。
审计时建议先不要急着跑工具扫一遍就结束。工具能帮忙发现一部分明显问题,但对业务权限、对象越权、流程缺陷的覆盖有限。更稳妥的方式是先梳理入口,再按风险类型分层排查。
审计前的准备
代码审计前我通常会先整理三类信息:部署拓扑、路由入口、敏感功能。这样后续验证会更有边界,不容易误伤业务。
- 部署拓扑:Nginx 转发规则、Tomcat context、静态目录、上传目录、定时任务。
- 路由入口:Controller、Filter、Interceptor、Servlet、JSP 直连页面。
- 敏感功能:登录、用户管理、角色权限、文件上传下载、导入导出、报表查询、系统配置。
可以先用简单命令对代码结构做一个粗略索引:
find . -type f \( -name "*.java" -o -name "*.jsp" -o -name "*.xml" -o -name "*.properties" -o -name "*.yml" \) | wc -l
grep -RIn "@RequestMapping\|@GetMapping\|@PostMapping" src/ | head -n 50
grep -RIn "upload\|download\|export\|import\|FileInputStream\|FileOutputStream" src/如果是 Spring Boot 项目,可以重点关注以下位置:
- 安全配置:SecurityConfig、WebMvcConfigurer、Interceptor 注册逻辑。
- 统一异常:是否吞掉异常,是否把堆栈直接返回前端。
- 配置文件:application-prod.yml、bootstrap.yml、logback 配置。
- 依赖版本:pom.xml / build.gradle 中是否存在长期未升级组件。
技术分析:几个高频风险点
1. 权限校验分散导致的接口越权
很多老系统会在页面层控制菜单显示,但后端接口本身没有强制鉴权。审计时不能只看登录拦截器是否存在,还要看“登录态”和“操作权限”是否分开校验。
一个比较危险的模式是只判断 session 中是否存在用户:
if (session.getAttribute("user") == null) {
return "redirect:/login";
}这种做法只能说明用户已登录,不能说明用户有权限访问某个接口。更合理的方式是让权限校验集中在 Filter、Interceptor 或安全框架中,并且权限点绑定到后端路由,而不是只绑定前端菜单。
排查时可以搜索:
grep -RIn "getAttribute(\"user\"\)\|hasPermission\|checkPermission\|@PreAuthorize\|@RequiresPermissions" src/如果同一个项目里既有 @PreAuthorize,又有手写 session 判断,还存在完全没有注解的 Controller,就要重点抽样验证。验证时应在授权测试环境中使用不同角色账号对比访问结果,避免对生产环境做破坏性操作。
2. 文件上传与可访问目录叠加
上传漏洞的风险不一定来自“上传后执行”,也可能来自敏感文件落地、覆盖文件、路径穿越、恶意内容被二次解析等。审计重点包括:
- 是否只依赖文件名后缀判断。
- 是否使用用户传入的原始文件名直接落盘。
- 上传目录是否在 Web 根目录下。
- 下载接口是否存在任意文件读取风险。
- 是否对文件大小、类型、数量做限制。
常见的不安全写法如下:
String fileName = multipartFile.getOriginalFilename();
File dest = new File(uploadPath + "/" + fileName);
multipartFile.transferTo(dest);至少应做这些处理:生成服务端文件名、白名单扩展名、限制大小、校验真实文件类型、目录规范化、上传目录禁止执行、下载时按文件 ID 映射真实路径。
Path baseDir = Paths.get(uploadPath).toAbsolutePath().normalize();
String safeName = UUID.randomUUID().toString() + ".pdf";
Path target = baseDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new IllegalArgumentException("invalid path");
}Nginx 层也建议明确限制上传目录的执行能力,不要让上传目录被脚本引擎处理。示例仅表达思路,具体规则要结合实际路径调整:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
}3. SQL 拼接与动态查询
老系统里最常见的是“新代码用 MyBatis 参数绑定,老代码直接拼接条件”。尤其是报表、导出、综合查询页面,字段多、条件多,很容易出现拼接 SQL。
String sql = "select * from orders where 1=1";
if (StringUtils.isNotBlank(keyword)) {
sql += " and customer_name like '%" + keyword + "%'";
}审计时可以搜索以下关键词:
grep -RIn "Statement\|createQuery\|createNativeQuery\|\$\{\|order by \|like '%" src/需要注意,MyBatis 中 ${} 不等于 #{}。前者是字符串替换,后者是参数绑定。对于排序字段这类无法直接绑定的场景,应使用白名单映射,而不是把用户输入直接拼进 SQL。
Map<String, String> allowSort = new HashMap<>();
allowSort.put("time", "create_time");
allowSort.put("amount", "total_amount");
String sortColumn = allowSort.getOrDefault(req.getSort(), "create_time");4. 反序列化与表达式注入的边界排查
Java 项目如果引入过老版本组件,反序列化和表达式注入需要单独看。这里不建议在业务环境做危险 payload 验证,更适合通过依赖分析、调用链确认、受控单元测试来判断风险。
可以先看依赖树:
mvn dependency:tree | egrep "commons-collections|fastjson|jackson-databind|xstream|shiro|spring-expression"关注点包括:
- 是否存在历史高危版本组件。
- 是否有对外暴露的反序列化入口,例如直接接收 ObjectInputStream、XMLDecoder。
- 是否启用了危险的类型自动识别能力。
- 表达式是否包含用户可控输入。
判断这类风险时,不要只凭“存在某个依赖”就下结论。更准确的结论应包含:组件版本、是否可达、入口在哪里、用户输入能否控制关键参数、现有防护是否生效。
5. 配置与日志问题
配置泄露和日志缺失经常被低估。比如测试环境回调地址、默认管理口令、对象存储密钥、数据库连接串被提交到仓库。审计时可以对配置文件做一次敏感信息扫描:
grep -RIn "password\|passwd\|secret\|accessKey\|token\|jdbc:\|redis" . \
--exclude-dir=target --exclude-dir=.git日志方面,建议至少覆盖这些事件:
- 登录成功、失败、锁定、退出。
- 新增、删除、修改用户与角色。
- 导入、导出、下载敏感数据。
- 系统配置变更。
- 权限拒绝与异常访问。
日志要避免记录明文密码、完整 token、身份证号等敏感数据。更推荐记录脱敏后的对象标识、操作者 ID、来源 IP、请求 ID、结果状态,便于后续排查。
关键步骤:从发现到修复闭环
实际项目里,审计报告如果只列“存在漏洞”,研发往往难以处理。更有效的方式是把每个问题写成可复现、可定位、可修复、可复测的闭环。
| 阶段 | 重点 | 产出 |
|---|---|---|
| 资产梳理 | 确认代码分支、部署环境、入口接口 | 接口清单、敏感功能列表 |
| 静态审计 | 定位高风险函数、配置、依赖 | 疑似风险点与代码位置 |
| 受控验证 | 在授权测试环境验证影响 | 复现步骤、请求样例、影响范围 |
| 修复建议 | 给出最小改动与长期方案 | 修复 PR 或整改说明 |
| 复测确认 | 确认漏洞不可复现,避免引入回归 | 复测记录、回归用例 |
一个比较实用的问题记录模板如下:
问题名称:接口权限校验缺失
影响模块:/admin/user/export
代码位置:UserController.java:128
触发条件:普通登录用户可直接请求导出接口
影响说明:可能导致非授权用户导出用户列表
验证环境:测试环境,账号 role=test_user
修复建议:后端增加权限点校验,导出操作纳入审计日志
复测标准:无权限用户访问返回 403,并记录权限拒绝日志注意点
- 不要在生产环境做破坏性验证,尤其是文件写入、批量导出、压力型请求。
- 验证权限问题时,应使用授权测试账号,避免使用真实高权限账号做无关操作。
- 报告里尽量避免夸大影响,能确认到哪里就写到哪里。
- 修复建议要区分短期止血和长期治理,例如先加权限校验,再统一权限框架。
- 依赖漏洞不要只看 CVE 列表,要确认组件是否可达、功能是否启用。
- 涉及敏感数据的截图、日志、请求包应脱敏后再流转。
总结
内网 Web 资产的安全问题往往不是单个“高深漏洞”,而是长期迭代中留下的工程缺口。一次有效的代码审计,核心不是堆工具结果,而是围绕入口、权限、数据、文件、配置、日志建立清晰判断链。
我的经验是,最值得长期投入的是三件事:权限校验集中化、文件与数据访问最小化、关键操作可审计。把这些基础能力做好,很多常见风险会自然下降。审计报告也应尽量服务于修复闭环,让研发能按代码位置和复测标准逐项处理,这比单纯列风险更有价值。
推荐意见