PHP代码审计入门

TL;DR

本文将讲述在学习了PHP的基本语法结构及开发后如何进行简单的代码审计, 涵盖常见的Web漏洞讲述如何挖掘并利用, 结合代码的实际操作让读者更加通俗易懂! 考虑文章篇幅及读者内容消化的原因, 故本文会分为基础与进阶两部分, 基础部分更多讲述漏洞原理, 通过靶场的漏洞环境让读者更加切实的感受漏洞挖掘和利用的过程, 在进阶的部分文章中会讲述在面对ThinkPHP等MVC框架中, 我们又该如何去审计这样的源码呢? 并通过漏洞复现的形式加深代码审计的能力。

个人认为代码审计主要可以分为两个方向去挖掘漏洞:
一是通过针对一些敏感函数的追踪分析其变量是否可控, 如命令执行的system, passthru, shell_exec, exec等函数, 代码执行的eval函数, 文件操作的file_get_contents, fopen, read等等。
二是通过通读整套源码熟悉完整的业务流程去分析挖掘, 当前这种方法会更加耗时费力, 个人而言这种方法只适用于一些小型的CMS建站系统或逻辑较为简单的程序。

前序知识

  • 基础语法结构
  • 面向对象
  • debug调试
  • SQL知识

SQL注入

SQL注入是一个较为常见的漏洞了, 其原理就在于开发者在编写SQL语句时将用户的输入与SQL语句进行拼接并没有任何保护而导致的. 本章节引用Dvwa靶场进行代码分析。

如上图注解所示, 在接收了参数值后程序并没有做任何处理就进行SQL语句的拼接导致了注入, SQL语句:

SELECT first_name, last_name FROM users WHERE user_id = ‘$id’;

注意在上面的SQL语句里, WHERE条件的user_id值我们可控, 那么我们就可以通过单引号闭合前面的WHERE条件并使其查询失败, 然后通过union联合查询数据库名, 在使用union联合查询时后者查询的列数必须与前者保持一致, 因为前面的单引号闭合了WHERE条件的值所以在SQL语句的结尾中还剩下一个单引号, 此时可以通过注释或闭合的方式解决.

在上图中我们可以看到注入了恶意的查询语句后, 能够成功获取当前的数据库名. 而原本的SQL语句在经过拼接注入的内容后就变成了这样:

SELECT first_name, last_name FROM users WHERE userid = ‘-1’ union select null, database()– ‘

以上的环境在现代的Web应用程序中可能较少存在, 但在部分小型的CMS(内容管理系统)中还是可以见到一些开发者在缺乏安全开发意识的情况下使用用户输入的内容直接拼接SQL语句, 而下面的实战分析就是这样的一个例子.

熊海CMS V1.0 SQL注入


在上图中可以看到程序在安装时会向数据库更新一条管理员信息, 而此时拼接的$user变量是从POST接收的参数值.

变量可控且无过滤或转义, 并且该SQL语句执行失败时会打印报错信息, 那么我们就可以使用报错注入来获取数据库信息了。
Payload:admin' and updatexml(1,concat(0x7e,database(),0x7e),1)#

任意文件删除

通常这类漏洞出现在unlink函数中, 所以在挖掘这种漏洞时我们可以通过全局搜索该函数并回溯其变量是否可控来判断是否存在漏洞. 当然, 通过其他的方式或间接调用其他的函数也能够达到删除文件的功能, 本章节只是针对此类漏洞的一个基础挖掘讲解.
WeLive V5.8.0在线客服系统后台任意文件删除

通过全局搜索的方法找到几处使用了unlink函数的地方, 逐一跟进代码逆推其变量是否可控.

在上图中我们可以看到, 当$action变量为delete时会进入elseif 分支, 首先进行了一个权限验证, 然后在第57的if中调用了unlink函数, 参数是backupDir加$filename变量, 此时继续跟进$filename变量调用的函数ForceStringFrom查看其是否可控.

通过分析上图代码可以得知, 在调用ForceStringFrom函数并传入参数file时, ForceStringFrom函数会做这几件事:

  • 判断GET数组里是否有file参数, 有则调用ForceString函数然后返回
  • 上面条件不成立时进入elseif, 判断POST数组里是否有file参数, 然后执行跟上面一样的操作
  • 当如上两个条件都不成立时则返回空

ForceString函数又是干嘛的呢?

  • 判断传入的参数是否字符串, 然后调用EscapeSql函数对其进行过滤

通过如上的代码分析不难得知其实$filename变量的内容我们是可控的, 只不过会经过一些函数对变量的内容进行一些过滤, 但此处并不影响删除文件漏洞的利用。回到我们的漏洞代码

1
2
if(@unlink($this->backupDir . $filename)){
//无动作

在调用unlink函数时, 其参数是backupDir属性 + $filename变量。

backupDir属性值为程序的根目录 + 'config/', 当我们需要删除根目录的一个文件只需要构造file参数为../filename即可。此类漏洞可以配合一些在安装程序后通过判断其安装目录是否存在一个锁文件来验证程序是否安装的情况组合成重装程序的漏洞。

任意文件下载

继续拿上面的那个客服系统做实战讲解, 在上一章节的漏洞分析中我们已经得知该系统通过ForceStringFrom函数获取的参数我们是可控的。因此本章节中不再重复赘述参数获取的过程。

从上图的代码中能够看到在获取了file参数并赋值给$filename变量后只判断了文件是否存在, 而没有其他的一些验证, 随之调用readfile函数读取文件并输出。
后话
其实在该程序中, 此方法只是用于管理员在备份了数据库后能够下载备份文件, 但是由于在此没有验证文件是否为备份文件或做一下其他的限制策略, 那么此时就能够通过跨目录的形式做到下载任意文件。

任意文件上传

任意文件上传漏洞通常出现在上传图片的地方, 开发者由于没有对上传格式执行严格的过滤或以白名单的形式验证文件后缀名的话, 攻击者就可以进行上传恶意的脚本文件(俗称Webshell)进而获取权限。 此处依旧引用DVWA漏洞靶场
案例一

上图代码中从$_FILES变量获取文件名称与路径进行拼接, 进而使用move_loadeded_file函数对文件进行移动。在这过程中并未对文件格式拓展名进行校验, 从而导致任意文件上传
案例二

上图案例中程序虽然判断了文件的Content-Type类型是否为图像类型和文件的大小限制,但是依旧没有对文件的格式拓展名做校验, 所以此处依旧存在任意文件上传。


phpweb任意文件上传
漏洞位于base/appborder.php

上图代码中可以看到在进行目标路径和文件的拼接时并没有对文件拓展名进行验证, 而后便使用copy函数将上传的文件从临时路径移动至目标路径导致其任意文件上传。


EyouCMS V1.0.0任意文件上传
漏洞位于api模块Uploadify控制器的preview方法, 对应文件:application/api/controller/Uploadify.php, 主要看201行开始的漏洞代码就可以了

data:image/php;base64,PD9waHAgcGhwaW5mbygpOw==


文件包含

文件包含是每个应用程序必不可少的一部分, 因为代码不可能全部写在一个文件中, 因此就必须通过包含其他文件来调用其中的函数/对象, 但如果开发者在包含文件的地方拼接了用户的输入, 那么便有可能造成任意文件包含漏洞。而文件包含漏洞又分为LFI(本地文件包含)和RFI(远程文件包含), 这取决于你的PHP配置项。

示例一

1
2
3
4
5
<?php

$page = $_GET['page'];
define("ROOT_PATH", "/var/www/html");
include ROOT_PATH . $page;

如上代码中$page变量是攻击者可控的, 而程序在使用include包含文件时直接将文件的根目录与$变量进行拼接, 因此攻击者可通过跨目录的形式去包含任意文件(前提是你得知道路径)。

示例二

1
2
3
4
5
6
<?php

$file = $_GET['page'];
$file = str_replace(array("http://", "https://"), "", $file);
$file = str_replace(array("../", "..\""), "", $file);
include $file;

上述代码中虽然对$file变量进行了过滤, 但只是把匹配的内容替换为空, 因此可通过双写的形式绕过此限制。构造Payload:…/./…/./…/./…/./…/./etc/passwd

phpmyadmin任意文件包含

漏洞代码:

1
2
3
4
5
6
7
8
9
if (! empty($_REQUEST['target'])
&& is_string($_REQUEST['target'])
&& ! preg_match('/^index/', $_REQUEST['target'])
&& ! in_array($_REQUEST['target'], $target_blacklist)
&& Core::checkPageValidity($_REQUEST['target'])
) {
include $_REQUEST['target'];
exit;
}

前四个条件很容易满足, 重点在最后一个方法, 跟进Core类checkPageValidity方法, 只要其为true那么就会包含$REQUEST[‘target’]。

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
public static function checkPageValidity(&$page, array $whitelist = [])
{
if (empty($whitelist)) {
$whitelist = self::$goto_whitelist;
}
if (! isset($page) || !is_string($page)) {
return false;
}

if (in_array($page, $whitelist)) {
return true;
}

$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}

$_page = urldecode($page);
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}

return false;
}

假设传入Payload:target=tbl_sql.php%253f../../test.php, 进入函数中就会执行下图流程

XSS

XSS基于后端服务分为反射与存储型, 反射XSS又称非持久型。是因为其后端通过将用户输入的内容没有经过过滤便输出造成的漏洞。而存储型XSS常见于应用程序将用户输入的内容保存至数据库, 并且在某个地方又从数据库拿出该内容进行输出, 在PHP中常见于字符串输出的有echo, print, printf等语句。

案例一

图中的代码直接使用echo拼接变量输出, 该变量为GET请求的name参数。

Payload:<script>alert(1)</script>

案例二
下图的代码中使用str_replace函数将<script>标签替换为空, 这种过滤方式是非常不严谨的。只要使用双写或使用其他标签都可以绕过此方法。

案例三

在下图的代码中, $message变量经过htmlspecialchars函数转换为HTML实体。但对于$name变量仅做了script标签的正则替换。因此我们可以通过其他标签来执行JavaScript代码。

Payload:<img/src/onerror=alert(1)>

某文章付费阅读系统XSS

已知该系统定义了局部函数来过滤用户的输入, 所以直接跟进该函数寻找突破口即可。

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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
function removexss($val) {
$val = preg_replace ( '/([\x00-\x08\x0b-\x0c\x0e-\x19])/', '', $val );

$search = 'abcdefghijklmnopqrstuvwxyz';
$search .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$search .= '1234567890!@#$%^&*()';
$search .= '~`";:?+/={}[]-_|\'\\';
for($i = 0; $i < strlen ( $search ); $i ++) {

$val = preg_replace ( '/(&#[xX]0{0,8}' . dechex ( ord ( $search [$i] ) ) . ';?)/i', $search [$i], $val );

$val = preg_replace ( '/(&#0{0,8}' . ord ( $search [$i] ) . ';?)/', $search [$i], $val );
}

$ra1 = array (
'javascript',
'vbscript',
'expression',
'applet',
'meta',
'xml',
'blink',
'script',
'object',
'iframe',
'frame',
'frameset',
'ilayer',
'bgsound'
);
$ra2 = array (
'onabort',
'onactivate',
'onafterprint',
'onafterupdate',
'onbeforeactivate',
'onbeforecopy',
'onbeforecut',
'onbeforedeactivate',
'onbeforeeditfocus',
'onbeforepaste',
'onbeforeprint',
'onbeforeunload',
'onbeforeupdate',
'onbegin',
'onblur',
'onbounce',
'oncellchange',
'onchange',
'onclick',
'oncontextmenu',
'oncontrolselect',
'oncopy',
'oncut',
'ondataavailable',
'ondatasetchanged',
'ondatasetcomplete',
'ondblclick',
'ondeactivate',
'ondrag',
'ondragend',
'ondragenter',
'ondragleave',
'ondragover',
'ondragstart',
'ondrop',
'onerror',
'onerrorupdate',
'onfilterchange',
'onfinish',
'onfocus',
'onfocusin',
'onfocusout',
'onhelp',
'onkeydown',
'onkeypress',
'onkeyup',
'onlayoutcomplete',
'onload',
'onlosecapture',
'onmousedown',
'onmouseenter',
'onmouseleave',
'onmousemove',
'onmouseout',
'onmouseover',
'onmouseup',
'onmousewheel',
'onmove',
'onmoveend',
'onmovestart',
'onpaste',
'onpropertychange',
'onreadystatechange',
'onreset',
'onresize',
'onresizeend',
'onresizestart',
'onrowenter',
'onrowexit',
'onrowsdelete',
'onrowsinserted',
'onscroll',
'onselect',
'onselectionchange',
'onselectstart',
'onstart',
'onstop',
'onsubmit',
'ontoggle',
'onunload'
);
$ra = array_merge ( $ra1, $ra2 );

$found = true;
while ( $found == true ) {
$val_before = $val;
for($i = 0; $i < sizeof ( $ra ); $i ++) {
$pattern = '/';
for($j = 0; $j < strlen ( $ra [$i] ); $j ++) {
if ($j > 0) {
$pattern .= '(';
$pattern .= '(&#[xX]0{0,8}([9ab]);)';
$pattern .= '|';
$pattern .= '|(&#0{0,8}([9|10|13]);)';
$pattern .= ')*';
}
$pattern .= $ra [$i] [$j];
}
$pattern .= '/i';
$replacement = substr ( $ra [$i], 0, 2 ) . ' ' . substr ( $ra [$i], 2 );
$val = preg_replace ( $pattern, $replacement, $val );
if ($val_before == $val) {

$found = false;
}
}
}
return $val;
}

定义了黑名单标签和属性$ra, 然后循环将黑名单的子字符从0的索引截取两个长度并添加空格再拼接剩余字符, 然后使用preg_replace正则匹配如果传入的参数匹配成功则替换为如上添加空格后的内容。简单理解:你传入一个<script>标签, 经过该函数过滤后会变为<sc ript>。因此如果需要绕过该黑名单就需要找一个不再此黑名单中的标签和属性, 最终我找到了svg标签和onrepeat属性。

<svg><animate onrepeat=alert(1) attributeName=x dur=1s repeatCount=2 />