'PHP-Challenge-2015-Solution'

本文翻译自:PHP Challenge 2015 Solution

1. 题目

本题目源于PHP5.6的一个更新,目标是以admin身份登录。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?php

/*******************************************************************
* PHP Challenge 2015
*******************************************************************
* Why leave all the fun to the XSS crowd?
*
* Do you know PHP?
* And are you up to date with all its latest peculiarities?
*
* Are you sure?
*
* If you believe you do then solve this challenge and create an
* input that will make the following code believe you are the ADMIN.
* Becoming any other user is not good enough, but a first step.
*
* Attention this code is installed on a Mac OS X 10.9 system
* that is running PHP 5.4.30 !!!
*
* TIPS: OS X is mentioned because OS X never runs latest PHP
* Challenge will not work with latest PHP
* Also challenge will only work on 64bit systems
* To solve challenge you need to combine what a normal
* attacker would do when he sees this code with knowledge
* about latest known PHP quirks
* And you cannot bruteforce the admin password directly.
* To give you an idea - first half is:
* orewgfpeowöfgphewoöfeiuwgöpuerhjwfiuvuger
*
* If you know the answer please submit it to info@sektioneins.de
********************************************************************/

$users = array(
"0:9b5c3d2b64b8f74e56edec71462bd97a" ,
"1:4eb5fb1501102508a86971773849d266",
"2:facabd94d57fc9f1e655ef9ce891e86e",
"3:ce3924f011fe323df3a6a95222b0c909",
"4:7f6618422e6a7ca2e939bd83abde402c",
"5:06e2b745f3124f7d670f78eabaa94809",
"6:8e39a6e40900bb0824a8e150c0d0d59f",
"7:d035e1a80bbb377ce1edce42728849f2",
"8:0927d64a71a9d0078c274fc5f4f10821",
"9:e2e23d64a642ee82c7a270c6c76df142",
"10:70298593dd7ada576aff61b6750b9118"
);

$valid_user = false;

$input = $_COOKIE['user'];
$input[1] = md5($input[1]);

foreach ($users as $user)
{
$user = explode(":", $user);
if ($input === $user) {
$uid = $input[0] + 0;
$valid_user = true;
}
}

if (!$valid_user) {
die("not a valid user\n");
}

if ($uid == 0) {

echo "Hello Admin How can I serve you today?\n";
echo "SECRETS ....\n";

} else {
echo "Welcome back user\n";
}

2. MD5 密码Hash

当浏览代码时,第一眼看到应该是下面的用户表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

$users = array(
"0:9b5c3d2b64b8f74e56edec71462bd97a" ,
"1:4eb5fb1501102508a86971773849d266",
"2:facabd94d57fc9f1e655ef9ce891e86e",
"3:ce3924f011fe323df3a6a95222b0c909",
"4:7f6618422e6a7ca2e939bd83abde402c",
"5:06e2b745f3124f7d670f78eabaa94809",
"6:8e39a6e40900bb0824a8e150c0d0d59f",
"7:d035e1a80bbb377ce1edce42728849f2",
"8:0927d64a71a9d0078c274fc5f4f10821",
"9:e2e23d64a642ee82c7a270c6c76df142",
"10:70298593dd7ada576aff61b6750b9118"
);

$valid_user = false;

$input = $_COOKIE['user'];
$input[1] = md5($input[1]);

这段代码显示了11名用户,从0到10。还可以看到,在用户后面有32字符的字符串,通过下面的md5()可以猜测这是密码的MD5值。使用MD5加密密码是一个很糟糕的主意,因为像彩虹表这种先进的破解技术可以破解一些简单的密码(较短的或简单的密码)。然而,我们并不希望大家暴力破解MD5来解出此题。

我们选择了一些无法Google出结果的密码,除了用户5的密码Hash06e2b745f3124f7d670f78eabaa94809,密码明文是hund,在德语中是狗的意思。但是,本题的目标是以admin的身份登录,以用户5的身份登录只是一个解出题目的第一步。

我们为其他用户选择了相当长的强密码,我们并不想让任何人破解出密码明文。甚至一些密码是随机产生的,就连我们自己都不知道明文是什么。

但是,admin的密码明文是orewgfpeowöfgphewoöfeiuwgöpuerhjwfiuvugeröwhdböcp9ueiwrbcvzeiwuböochineworöcern,想在密码字典中找到这样的密码基本不可能。

所以,需要寻找其他的解法。

3. 理解代码

1. 定义用户

1
2
3
4
5
6
7
8
<?php

$users = array(
"0:9b5c3d2b64b8f74e56edec71462bd97a" ,
...
);

$valid_user = false;

2. 读取用户Cookie

这段代码从COOKIE中读取user信息,并在为检查其到底是不是数组的情况下,就将其看成数组。数组中下标为1的元素是密码的明文,然后对其进行MD5计算。

1
2
3
4
<?php

$input = $_COOKIE['user'];
$input[1] = md5($input[1]);

3. 在用户表中对照密码

这段代码遍历用户表,检查登录密码和用户表中密码是否一致。将用户ID和密码Hash分离放在数组中,然后使用三等于===运算符和登录数组进行比较。使用三等于运算符来进行判断是正确的做法,因为在PHP中,所有和安全相关的比较都应该使用三等于运算符。为了后面比较$uid,这里将cookie中的$uid加上0来强制转换为数字类型。此外,还将$valid_user设为true

1
2
3
4
5
6
7
8
9
10
<?php

foreach ($users as $user)
{
$user = explode(":", $user);
if ($input === $user) {
$uid = $input[0] + 0;
$valid_user = true;
}
}

如果你仔细阅读上面的代码,你会发现在匹配到已存在的用户后,循环依然继续,没有直接退出循环。这确实是个bug,但在这种特定情况下,并不能成为一个可利用的缺陷。

4. 处理用户登录

如果没有检测到有效用户,在比较循环结束后,脚本立即退出,并报错。如果是有效用户,这里有两种情况:$uid为0的admin用户,和其他普通用户。使用双等于而不是三等于来和0进行比较,由此判断是否为admin用户。使用双等于来进行安全相关的比较是一种糟糕的方式。但是,这里的错误使用和解题并没有关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
if (!$valid_user) {
die("not a valid user\n");
}

if ($uid == 0) {

echo "Hello Admin How can I serve you today?\n";
echo "SECRETS ....\n";

} else {
echo "Welcome back user\n";
}

4. 我们现在知道什么?

我们现在知道从COOKIE中获取数据,作为数组进行储存。数组包含两个元素:用户ID和密码明文。还知道用户5的密码明文是hund。这意味着,我们可以通过设置cookie以用户5的身份进行登录。

1
Cookie:user[0]=5;user[1]=hund;

同时,我们知道前面使用三等于比较用户,后面用双等于比较用户ID。

到这里时,很多人会卡住,因为他们不知道PHP5.6修复了什么bug。但是有20-30人解出了这道题,因为他们从PHP的Changelog里看到了

Changelog

PHP每发行一个新版本,都会发布Changelog和新闻文件,有的时候,Changelog里会有写有趣的信息,里面会列出修复的问题,并且后面会有提交bug的链接,里面会有测试实例。

下面是Changelog的一部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Version 5.6.11

10 Jul 2015

- Core:

- Fixed bug #69768 (escapeshell*() doesn't cater to !).
- Fixed bug #69703 (Use __builtin_clzl on PowerPC).
- Fixed bug #69732 (can induce segmentation fault with basic php code).
- Fixed bug #69642 (Windows 10 reported as Windows 8).
- Fixed bug #69551 (parse_ini_file() and parse_ini_string() segmentation fault).
- Fixed bug #69781 (phpinfo() reports Professional Editions of Windows 7/8/8.1/10 as "Business").
- Fixed bug #69740 (finally in generator (yield) swallows exception in iteration).
- Fixed bug #69835 (phpinfo() does not report many Windows SKUs).
- Fixed bug #69892 (Different arrays compare indentical due to integer key truncation). <------ THIS SOUNDS INTERESTING
- Fixed bug #69874 (Can't set empty additional_headers for mail()), regression from fix to bug #68776.

上面的Changelog里,有一行很有意思。Bug #69892看起来很吓人,由于整数key截断导致不同数组之间比较时,结果相同。

Bug #69892 - 不同数组之间比较由于整数键截断导致结果相同

Bug的提交报告里的测试示例。

1
2
3
4
5
6
[2015-06-20 14:29 UTC] nikic@php.net
Description:
------------
var_dump([0 => 0] === [0x100000000 => 0]); // bool(true)

on all versions: http://3v4l.org/Sjdf8

zend_hash_compare()

整数key截断问题发生在zend_hash_compare()函数这里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ZEND_API int zend_hash_compare(HashTable *ht1, HashTable *ht2, compare_func_t compar, zend_bool ordered TSRMLS_DC)
{
Bucket *p1, *p2 = NULL;
int result;
void *pData2;

...

while (p1) {
if (ordered && !p2) {
...
}
if (ordered) {
if (p1->nKeyLength==0 && p2->nKeyLength==0) { /* numeric indices */
result = p1->h - p2->h; <------------ POSSIBLE TRUNCATION
if (result!=0) {
HASH_UNPROTECT_RECURSION(ht1);
HASH_UNPROTECT_RECURSION(ht2);
return result;
}

问题在于数字型下标在比较时,是通过各自的值相减结果来判断的,值是存放在bucket类型的h。通过判断结果是否为0决定是否相等。然而,h的数据类型bucket被定义为unsigned long,在64位系统中,通常是64位,但是result变量却只是32位int类型。因此,当比较结果的低32位全是0的话,比较结果会是相等。因此,key=0key=4294967296(0x10000000)以及其他的key都被认为相等。

5. 怎样解题?

我们现在已经能以用户5的身份登录,下一步只要将用户5变成用户0就可以了。

修改Cookie

1
Cookie:user[4294967296]=5;user[1]=hund;

在进行三等于判断时,由于bug的存在,会将user[4294967296]=5当作user[0]=5,成功验证

但是,在后面获取$uid时,并没有进行比较,只是读取$user[0]的值,因为$user[0]并没有被初始化,所以当其加上0时,结果为0。这时,用户身份就从用户5变成了用户0,admin身份。

6. 补充

测试环境可以自己搭建,也可以使用在线的PHP沙箱:

3v4l

Online PHP functions

这里附上在3v4l上测试的结果,由于无法设置Cookie,所以直接给$input赋值

1
$input = [4294967296 => "5","1" => 'hund'];

为了方便看结果,还输出了$input,可以看到只有在PHP5.4.0-5.4.43,5.5.0-5.5.26,5.6.0-5.6.1版本中才能以admin身份登录

漏洞的影响范围也是上面的版本