HelloCTF RCE-labs · 挑战关:仅剩一个字母的通配符 —— Writeup
| 项目 | 内容 |
|---|---|
| 靶场 | HelloCTF RCE 靶场(github.com/ProbiusOfficial/RCE-labs,作者 探姬) |
| 关卡 | RCE 挑战关(题面注释:刚才,学了什么来着!?——考的是前面所学通配符的极限运用) |
| 题型 | Web / PHP 命令执行(Command Injection / RCE) |
| 目标 | http://80-e36cdd6f-930e-44b3-8dae-08eb35c54cba.challenge.ctfplus.cn/ |
| 入口参数 | GET 参数 cmd |
| 过滤情况 | `preg_match("/[b-zA-Z_@#%^&*:{}-+<>" |
| 工具 | 新版 HackBar(DevTools 内置版)、Burp Suite Community v2026.8(新版 UI) |
| 结果 | 拿到 Geesec{97487dac-ea51-4e71-bcdf-5c6592ab4471} |
注:上方地址是平台下发的临时实例,题目做完后会失效。复现时请替换成自己那一份。
一、题目
打开靶机,页面照例用 highlight_file(__FILE__) 把自己打印了出来。去掉教学注释后,核心就是这几行:
function hello_shell($cmd){
if(preg_match("/[b-zA-Z_@#%^&*:{}\-\+<>\"|`;\[\]]/", $cmd)){
die("WAF!");
}
system($cmd);
}
isset($_GET['cmd']) ? hello_shell($_GET['cmd']) : null;
highlight_file(__FILE__);结构和上一关几乎一样,唯一的变化就是过滤从"拦一个词"升级成了"拦一大批字符"。
二、题目分析
2.1 把正则黑名单翻译成人话
[...] 是字符集合:只要命令串里出现集合内任意一个字符,就 die("WAF!")。逐段拆开:
| 正则片段 | 拦掉的字符 | 备注 |
|---|---|---|
b-z | b c d … z | 小写字母只剩 a 幸存 |
A-Z | 所有大写字母 | 大写绕过这条路直接封死 |
_ | 下划线 | ${IFS} 这种写法受影响 |
@ # % ^ & * | 六个符号 | * 通配符在列 |
: { } | 冒号、花括号 | {}, 展开被禁 |
\-\+ | 减号、加号 | 命令的 -l、-a 参数全废 |
< > | 重定向 | 不能 cat < /flag,不能写文件 |
" | 双引号 | 只剩单引号 ' 可用 |
| | 竖线 | 管道没了 |
` | 反引号 | 命令替换没了 |
; [ ] | 分号、方括号 | 命令分隔没了,[a-z] 通配也没了 |
反过来,幸存字符全集就是本题的"武器库":
字母: a 数字: 0-9
符号: $ ( ) = . , / ? ! ' ~ 以及: 空格、换行2.2 三个关键判断
判断一:空引号插入法(c''at)这一关失效了。
上一关的招牌手法 c''at,本质是"字母还在,只是被空引号断开"。这一关 c、t 本身就在黑名单里——引号插得再花哨,字母出现就是死。凡是 b-z 区间的字母,一个都不能明文写。
判断二:通配符只剩 ? 一个。
*、[]、{} 全被禁,能用的通配符只有 ?(匹配恰好 1 个字符)。这既是限制也是提示——出题人题面那句 刚才,学了什么来着!?,两个 !? 就是明示。
判断三(本题最核心):? 展开的前提是"真有文件能匹配上"。
shell 的通配符展开(globbing)不是字符串替换,而是查文件系统:
?在当前位置找"名字长度吻合的真实文件";- 找到了 → 替换成文件名;找不到 → 原样保留,继续往下传。
由此推出两个结论:
- 读文件:flag 文件在根目录,
f???这种写法不行(f是被禁字母),但????可以——它匹配根目录下所有 4 字符文件; - 命令本身:
?a?这种写法单独发是不行的。因为 shell 只在当前目录(webshell 场景 =/var/www/html)展开它,那里没有任何 3 字符文件叫?a?→ 展开失败 → 整串原样传给系统 →command not found,而且这条报错走 stderr,system()不回显,页面上什么都看不到(上一关 4.3 节"伪解"是同一个坑)。
命令本身也是文件——它躺在
/bin、/usr/bin这些目录里。想让通配符命中命令,就得把命令当文件、连同它的目录一起通配:/bin是 3 个字符 → 写成/???/?a?。 这就是"cat 不到就要想到 bin"的准确含义:不是 cat 失败了才想 bin,而是命令名用通配符时,目录前缀从一开始就必须带上。
2.3 利用思路
把 "cat" 和 "flag" 里的每一个字母都藏进 ? 通配符里,
让 WAF 看到的只有符号,shell 展开后却还原成完整命令cat→?a?(c、t 藏起来,a 是唯一幸存字母)/bin→/???(命令所在目录一起通配)/flag→/????(4 字符文件名)
三、解题过程
3.1 先探底:确认哪些字符真的能过
在 Burp 内置浏览器(Proxy → Intercept 页右上角 Open browser)逐条验证黑名单是否如源码所示:
| 请求 | 结果 | 说明 |
|---|---|---|
?cmd=1 | 正常回显源码,无 WAF | 数字能过 |
?cmd=a | 同上 | 字母 a 能过 |
?cmd=ls | WAF! | l、s 都在 b-z |
?cmd=cat%20/flag | WAF! | 字母 + 路径全灭 |
?cmd=tac%20fl* | WAF! | t/c/f/l 踩 b-z,* 踩符号 |
3.2 抓包送 Repeater
Proxy → HTTP history 找到靶机请求 → Ctrl+R 送 Repeater → 切 Raw 视图。
3.3 发最终 payload
请求行改成:
GET /?cmd=/???/?a?%20/???? HTTP/1.1
Host: 80-e36cdd6f-930e-44b3-8dae-08eb35c54cba.challenge.ctfplus.cn
(其余头保持不动)点 Send。响应约 43 万字节——cat 命中了一个二进制文件,拉到响应最底部(<code> 源码块之前),flag 就在二进制噪声的末尾:
Geesec{97487dac-ea51-4e71-bcdf-5c6592ab4471}Burp 里二进制大响应建议切 Hex 视图尾部查看,或直接
Ctrl+F搜{。
3.4 flag 文件是怎么定位的(没有 ls 的枚举法)
本关 ls 用不了(字母全灭),通配符枚举只能按文件名长度逐个猜。实测记录:
| payload(URL 解码后) | 响应体积 | 判读 |
|---|---|---|
/???/?a? /????(4 字符) | 429,956 字节 | 命中了二进制文件,flag 在末尾 ✓ |
/???/?a? /?????(5 字符) | 429,911 字节 | 命中另一个二进制,无 flag |
/???/?a? /??????~/????????? | 均为 429,911 字节 | 同上 |
/???/???/??(≈/usr/bin/ls) | 2,279 字节(无命令输出) | 见 5.2 排查表 |
侧信道原理:无回显命令名可用时,“响应体积突变"本身就是信息——比基线(2,279 字节)大两个数量级 = cat 到了真实存在的二进制文件。逐个长度试下去,flag 自然浮出。
四、payload 推导链
一条 payload 的完整旅程(四层,每层身份不同):
cmd=/???/?a?%20/???? ← 你在 Repeater 里敲的原文
↓ URL 解码:%20 → 空格
/???/?a? /???? ← PHP $_GET['cmd'] 拿到的
↓ preg_match 字符黑名单检查
无 b-z、无 A-Z、无 * 等 → 放行(这一串里只有 ? / a 和空格)
system("/???/?a? /????") ← 交给 shell
↓ shell 通配符展开(globbing,查真实文件系统)
/??? → /bin ;?a? → cat ;/???? → /flag
/bin/cat /flag ← 文件系统上真正执行的命令和上一关同源的信息差:WAF 检查发生在第 2 层(纯文本匹配),通配符展开发生在第 4 层(查文件系统)。中间隔了一整个 shell,? 就是钻进这条缝的工具——只不过上一关只需藏 flag 一个词,这一关要把所有字母都藏进去。
五、读不到 flag 时的排查表(本关增补版)
5.1 通用排查(沿用上一关结论)
| 回显现象 | 原因 | 处理 |
|---|---|---|
页面返回 WAF! | 命中了黑名单字符 | 对照 2.1 的幸存字符表,逐字符排雷 |
| 无 WAF 但无输出 | 命令跑了但失败,stderr 不回显 | 先确认文件/命令真的存在(见下) |
| Burp 返回 400 | 请求行有裸空格 | 写 %20;或用 $IFS(本关 $ 幸存,可用) |
| 改了没反应 | Repeater 不自动重发 | 重新点 Send |
5.2 本关新增的三条
| 回显现象 | 原因 | 处理 |
|---|---|---|
裸写 ?cmd=?a?%20/???? 无输出 | ?a? 只在当前目录展开,/var/www/html 里没有匹配 → 原样传给 shell → command not found(stderr 不可见) | 命令名必须带目录前缀:/???/?a? 或 /???/???/?a? |
/???/???/??(≈ls)无输出 | ?? 这类短模式同时匹配多个二进制(ar、as、sh…),它们被依次执行,输出混乱或为空 | 短通配符枚举不可靠;改用「按长度 cat + 响应体积」侧信道 |
| 响应 43 万字节全是乱码 | cat 到了二进制文件 | 正常!拉到最底部或搜 {,flag 常被拼在二进制之后 |
5.3 目录前缀的两个变体
| 写法 | 展开成 | 何时用 |
|---|---|---|
/???/?a? | /bin/cat | /bin 存在(本题实测可用) |
/???/???/?a? | /usr/bin/cat | 精简镜像里命令都在 /usr/bin(4 字符) |
?a? | ❌ 不展开 | 当前目录没有叫 cat 的文件 |
不确定时两个都发一遍,反正都过 WAF。
六、原理深化:通配符展开(globbing)的三条铁律
本关所有技巧都从这三条推出来,值得单独记:
铁律一:glob 查的是文件系统,不是字符串。
preg_match 看到的是 /???/?a? 这串符号;shell 执行前会拿着它去目录里"点名”,把能对上号的真实文件名换进去。WAF 在文本层,glob 在文件系统层——这就是永远打不完的信息差。
铁律二:匹配不上就原样保留。
?a? 在没有匹配文件的目录里不会报错、不会变空,而是原样传下去,最后由执行环节报 command not found——又因为 stderr 不回显,页面上表现为"安静的失败"。“没拦但没输出"的第一嫌疑永远是展开失败。
铁律三:一个模式匹配到几个文件,就展开成几个词。
/???? 匹配到根目录下所有 4 字符文件 → cat 的参数一下子变成好几个 → 依次全被读出来。这是双刃剑:
- 好处:不用知道确切文件名,长度对上就中;
- 坏处:噪声大(二进制乱码刷屏),且匹配过多时(如
/???/???/??匹配一堆命令)行为不可控。
七、与之前关卡的联动复习
这道"挑战关"没有引入任何新符号——它把前面学的东西全部拆掉,只留一件武器逼你用到底。对照着复习:
| 关卡 | 教的手法 | 本关还剩多少可用 | 复习要点 |
|---|---|---|---|
| Lv3 无过滤入门 | cat /flag 直读 | ❌ 字母全灭 | 认识 system() 回显位置:输出在源码块之前 |
| SHELL 运算符 | ; | && 拼接命令 | ❌ 分隔符全灭 | 本题 cmd 本身就是完整命令,本来就用不上拼接——读源码判断结构,别背手法 |
| 空字符与通配符 | c''at 空引号、/fla?、/???/?at /e't'c/pass?d | '' 可用但没用了;? 是唯一主武器 | 注意!那关示范里的 /???/?at 就是本关答案的雏形——目录通配 + 命令通配,本关只是把它逼成唯一解 |
| 终端特性速查表 | *、?、[]、{} | 只剩 ? | ? 匹配恰好 1 字符,最精确;* 贪婪但被禁;通配符优先级 /fla? > /fla[g] > /fl* > /fl''ag |
| 上一关 4.3 伪解 | cat /FLAG 静默失败 | ⚠️ 同款坑 | system() 只回显 stdout——“没 WAF 但没输出”= 展开失败或文件不存在 |
一条贯穿所有关卡的主线:从 cat /flag → c''at /fla? → /???/?at /e't'c/pass?d → /???/?a?%20/????,payload 一步步"变质”,但骨架从没变过——在 WAF 看得见的文本层和 shell 看得见的文件系统层之间制造差异。手法会随黑名单升级而失效,这条主线不会。
本关新增的两条独立知识点:
- 响应体积侧信道:字母被杀光、ls 不可用时,按文件名长度逐个 cat,用响应体积比基线暴涨多少来判断命中。这是"无回显枚举"的最朴素形态。
- 读字符集正则的方法:见到
[...]别慌,逐段翻译成"拦了什么、剩了什么",先列幸存字符全集,再从全集出发倒推 payload——而不是从记忆里的 bypass 清单一条条试。
八、要点清单(可当速查卡)
- 先列幸存字符集:拿到字符级黑名单,第一件事是把"能用什么"列全,再谈 payload。
- 只剩
?时:命令、路径的所有字母都用?顶替,唯一幸存字母(如a)保留真身。 - 命令名通配必须带目录前缀:
/???/?a?;精简镜像用/???/???/?a?。裸写?a?= 静默失败。 ?匹配恰好 1 字符:文件名长度决定?的个数;长度未知就按 4、5、6……逐个试。- 枚举靠响应体积:比基线大两个数量级 = 命中真实文件;flag 在输出的头或尾。
- 短通配符(
??)慎用:会同时展开成一堆命令,行为不可控。 $IFS本关可用:$幸存,?a?$IFS/????可以连空格一起省掉。system()只回显 stdout:没 WAF 但没输出 = 展开失败 / 文件不存在 / 命令失败,按这个顺序排。- WAF 在文本层、glob 在文件系统层:所有通配符绕过的总原理,背不住手法时回来想这条。
- 改完 payload 重新点 Send——Repeater 不会自动重发,永远的第一嫌疑。
九、汇总表
| 项目 | 内容 |
|---|---|
| 关卡 | HelloCTF RCE-labs 挑战关(ctfplus / Geesec 平台) |
| WAF 正则 | [b-zA-Z_@#%^&*:{}\-\+<>\"|;[]]` |
| 幸存字符 | a、0-9、$ ( ) = . , / ? ! ' 空格 |
| 我的第一发 payload | tac%20fl* → WAF!(t/c/f/l 踩 b-z,* 踩黑名单) |
| 失败原因 | 非a字母全被禁 + * 被禁,通配符只剩 ? |
| 核心思路 | 命令与路径的全部字母用 ? 藏进通配符,a 保留真身 |
| 中间踩的坑 | 裸 ?a? 无输出(展开前提=文件真实存在);/???/???/?? 枚举失效(多匹配不可控) |
| 最终 payload | GET /?cmd=/???/?a?%20/???? |
| 等价于 | cat /flag(flag 为根目录 4 字符文件) |
| flag | Geesec{97487dac-ea51-4e71-bcdf-5c6592ab4471} |
| 新学知识点 | ① glob 三铁律(查文件系统/不匹配原样保留/多匹配全展开)② 响应体积侧信道枚举 ③ 命令名通配必须带目录前缀 |
Writeup 完
- 本文链接: https://quinc.cn/posts/helloctf_rce_labs_lv6_writeup/
- 版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 许可协议。

