跳转到帖子

背景

最近在做一批内网 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 应用的安全问题,很多并不依赖复杂利用技巧,而是来自长期迭代中的边界缺失。代码审计的价值在于把这些边界重新画清楚:谁能访问什么资源,用户输入能影响哪些关键操作,系统对异常和敏感信息如何处理。

实践中建议按“入口梳理、数据流追踪、权限验证、最小化复现、公共层修复”的顺序推进。这样既能发现单点漏洞,也能沉淀出可复用的安全基线,对后续版本迭代更有帮助。

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

0篇意见

推荐意见

没有意见。

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