背景
最近在做一批内网 Web 应用的安全复核,类型比较典型:Java/Spring MVC 为主,少量 PHP 管理后台,部署环境封闭但业务权限复杂。这里整理一套比较通用的代码审计与漏洞验证流程,重点放在“如何定位问题、如何验证风险、如何给出修复建议”,不展开任何未授权攻击或破坏性操作。
这类系统的共性是:上线时间长、历史接口多、权限逻辑散落在不同层、文件上传和报表导出功能较多。真正有价值的审计,不只是扫出几个高危告警,而是把入口、数据流、权限边界和修复成本都梳理清楚。
典型场景
本次讨论的目标可以抽象为一个内部管理系统:
- 后端:Spring MVC / Spring Boot,部分老模块使用 JSP。
- 数据库:MySQL,存在历史拼接 SQL。
- 认证:统一登录,业务系统自行维护角色权限。
- 常见功能:用户管理、文件上传、报表导出、审批流、富文本公告。
- 部署:Nginx 反向代理,应用运行在 Tomcat 或内置容器中。
这类系统最容易出问题的地方通常不是单个框架漏洞,而是业务代码里的边界缺失。例如:接口只在前端隐藏按钮,后端未校验权限;下载接口只校验文件名,不校验归属;导出功能把用户输入直接拼入 SQL 条件。
审计前的准备
拿到代码后,不建议一开始就全局搜索“危险函数”。更稳妥的方式是先建立项目结构和数据流认知:
- 确认入口层:Controller、Filter、Interceptor、Servlet、路由配置。
- 确认认证逻辑:登录态如何保存,Session、JWT、网关 Header 还是单点登录票据。
- 确认权限模型:角色、菜单、接口、数据权限是否分离。
- 确认公共工具类:文件处理、SQL 拼装、HTTP 请求、序列化、模板渲染。
- 确认第三方组件版本:框架、中间件、上传组件、富文本组件、Excel/PDF 处理库。
如果是 Java 项目,可以先用 Maven 或 Gradle 输出依赖树,快速判断是否存在明显过期组件:
mvn dependency:tree -DoutputFile=dependency-tree.txt
gradle dependencies --configuration runtimeClasspath > dependency-tree.txt依赖风险不能只看版本号,还要结合实际调用面。例如某个反序列化相关库存在历史漏洞,但项目没有暴露对应入口,风险等级就需要谨慎评估。
技术分析一:权限校验是否在服务端闭环
内网系统里最常见的问题是越权,包括水平越权和垂直越权。审计时我通常从 Controller 入手,找出所有涉及资源 ID 的接口,例如 userId、deptId、orderId、fileId、taskId 等字段。
比较危险的代码形态如下:
@GetMapping("/detail")
public Result detail(Long id) {
return Result.ok(orderService.getById(id));
}这段代码本身不一定有漏洞,关键要看 orderService.getById(id) 内部是否校验当前登录用户对该订单的访问权限。如果只是按主键查询,就需要继续追踪调用链。
更稳妥的实现方式是把当前用户身份带入查询条件,避免先查出数据再做弱校验:
public OrderVO detail(Long id, LoginUser user) {
Order order = orderMapper.selectByIdAndTenant(id, user.getTenantId());
if (order == null) {
throw new BizException("资源不存在或无权限访问");
}
return convert(order);
}注意这里的返回信息也要克制,不建议区分“资源不存在”和“无权限”,否则容易形成资源枚举侧信道。
技术分析二:SQL 拼接与动态查询
管理后台普遍有复杂筛选条件,历史代码里经常看到字符串拼接 SQL。审计时可以重点搜索以下模式:
" order by "
" like '%" +
" in (" +
" limit " +
@Select("${")
statement.executeQuery(sql)一个典型风险点是排序字段:
String sql = "select * from user where status = ? order by " + sortField + " " + sortType;即便查询条件使用了预编译,order by 字段也经常被忽略。修复方式不是简单过滤关键字,而是使用白名单映射:
private static final Map<String, String> SORT_FIELD_MAP = Map.of(
"createTime", "create_time",
"username", "username",
"status", "status"
);
String column = SORT_FIELD_MAP.getOrDefault(req.getSortField(), "create_time");
String direction = "asc".equalsIgnoreCase(req.getSortType()) ? "asc" : "desc";
String sql = "select * from user where status = ? order by " + column + " " + direction;这里仍然存在字符串拼接,但拼接内容完全来自服务端白名单,风险可控。对于 MyBatis,也要区分 #{ } 和 ${ }:前者是参数绑定,后者是文本替换。排序字段、表名这类不能直接参数化的场景,必须配合白名单。
技术分析三:文件上传与文件下载
文件功能是长期高发区域。审计上传时,不要只看扩展名校验,还要看存储路径、访问方式、文件名生成、内容类型识别以及后续解析链路。
建议检查以下点:
- 上传目录是否位于 Web 可执行目录下。
- 文件名是否使用用户原始文件名直接落盘。
- 是否允许双扩展名、特殊字符、路径分隔符。
- 是否只依赖 Content-Type 判断类型。
- 图片、Office、压缩包等文件是否存在后续解析处理。
- 下载接口是否校验文件归属和访问权限。
一个相对稳妥的上传处理思路:
String originalName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
Set<String> allowExt = Set.of("jpg", "jpeg", "png", "pdf", "xlsx");
if (!allowExt.contains(ext)) {
throw new BizException("不支持的文件类型");
}
String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path baseDir = Paths.get(uploadRoot).toAbsolutePath().normalize();
Path target = baseDir.resolve(newName).normalize();
if (!target.startsWith(baseDir)) {
throw new BizException("非法路径");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);下载接口则建议不要让用户直接传路径,应该传文件 ID,服务端根据 ID 查询元数据,并校验当前用户是否有权限访问该文件。
技术分析四:SSRF 与内部资源访问
很多系统有“URL 导入图片”“远程抓取文件”“Webhook 回调测试”等功能,这些都可能引入 SSRF 风险。审计时重点找 HTTP 客户端调用:
RestTemplate
HttpClient
OkHttpClient
URLConnection
WebClient风险不在于使用 HTTP 客户端,而在于目标地址是否由用户输入控制,以及是否允许访问内网、云元数据地址、回环地址等敏感资源。
较合理的防护包括:
- 只允许访问业务白名单域名,而不是靠黑名单拦截。
- 解析域名后校验 IP 是否为公网地址,并防止重绑定。
- 限制协议为 http/https,禁止 file、gopher 等非预期协议。
- 设置合理的连接和读取超时。
- 禁止自动跟随跳转,或对跳转后的地址重复校验。
示例配置重点是超时和跳转控制,真正的安全边界仍应放在白名单和地址解析校验上:
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(3000)
.setResponseTimeout(5000)
.setRedirectsEnabled(false)
.build();技术分析五:日志与敏感信息泄露
内网项目里日志泄露常被低估。审计时可以搜索这些字段是否进入日志、异常堆栈或接口响应:
- password、passwd、pwd
- token、accessToken、refreshToken
- secret、appKey、appSecret
- idCard、mobile、email
- Authorization、Cookie
常见问题是登录失败时打印完整请求对象,或者全局异常处理直接返回 exception.getMessage()。建议做两层处理:日志脱敏和响应统一错误码。
@ExceptionHandler(Exception.class)
public Result handleException(Exception e) {
log.error("system error, traceId={}", TraceContext.getTraceId(), e);
return Result.fail("系统繁忙,请稍后重试");
}对于审计报告,敏感信息泄露类问题应明确可见范围:是普通用户可见、登录用户可见,还是仅服务器本地日志可见。不同范围的风险等级差异很大。
漏洞验证的边界
漏洞验证的目标是证明风险存在,而不是扩大影响。建议遵守几个原则:
- 优先在测试环境验证;生产环境只做最小化、只读验证。
- 不要批量枚举真实用户、订单、文件等业务数据。
- 不要执行破坏性写入、删除、重置密码等操作。
- 涉及文件上传时,使用无害内容和不可执行扩展。
- 涉及 SQL 注入时,优先使用布尔差异、错误信息或延时以外的安全证据,不对数据做导出。
- 保留请求、响应、代码位置和调用链,方便开发复现修复。
一份好的验证记录通常包括:接口路径、角色身份、请求参数、预期权限、实际结果、代码位置、影响范围和修复建议。只给“存在越权”四个字,开发很难有效修。
关键步骤清单
| 阶段 | 重点 | 产出 |
|---|---|---|
| 资产梳理 | 接口、角色、依赖、部署结构 | 入口列表、依赖清单 |
| 数据流分析 | 用户输入到数据库、文件、HTTP 请求的流向 | 高风险调用链 |
| 权限审计 | 资源 ID、租户 ID、部门 ID、审批流状态 | 越权风险点 |
| 漏洞验证 | 最小化、可复现、低影响 | 请求响应证据 |
| 修复复核 | 补丁是否覆盖同类入口 | 复测结论 |
常见修复误区
- 只在前端隐藏按钮,不在后端校验权限。
- 只过滤 SQL 关键字,不使用参数绑定和白名单。
- 只检查上传扩展名,不限制访问路径和解析行为。
- 只修复单个接口,没有抽象公共鉴权逻辑。
- 异常信息从详细堆栈改成“无权限”,但日志仍打印敏感参数。
- 修复后没有补充单元测试或接口级回归用例。
安全修复要尽量沉到公共层。比如越权问题,如果每个 Controller 手写判断,后续新增接口很容易再次遗漏;如果能在数据权限拦截器、服务层查询条件或统一注解里解决,长期维护成本会低很多。
总结
内网 Web 应用的安全问题,很多并不依赖复杂利用技巧,而是来自长期迭代中的边界缺失。代码审计的价值在于把这些边界重新画清楚:谁能访问什么资源,用户输入能影响哪些关键操作,系统对异常和敏感信息如何处理。
实践中建议按“入口梳理、数据流追踪、权限验证、最小化复现、公共层修复”的顺序推进。这样既能发现单点漏洞,也能沉淀出可复用的安全基线,对后续版本迭代更有帮助。
推荐意见