S1xHcL's Blog.

PHP-Audit Day04 strpos使用不当引发漏洞

Word count: 2kReading time: 9 min
2021/10/06 Share

函数学习

strpos — 查找字符串首次出现的位置

作用:主要是用来查找字符在字符串中首次出现的位置。

结构:int strpos ( string $haystack , mixed $needle [, int $offset = 0 ] )

Demo学习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
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 version="1.0"?>' .
'<user v="%s"/><pass v="%s"/>';
$xml = sprintf($format, $user, $pass);
$xmlElement = new SimpleXMLElement($xml);
// Perform the actual login.
$this->login($xmlElement);
}
}
}

new Login($_POST['username'], $_POST['password']);
?>

在第12 ~ 13中,程序通过格式化字符串用xml存储用户登录信息,在第9 ~ 10行对提交的参数usernamepassword进行简单的过滤,禁止输入< >符号防止注入。但过略并不严谨,导致可以绕过。

strpos()函数会返回查找字符串的下标,如果不存在则为false

1
2
3
4
5
<?php
var_dump(strpos('abcde', 'a'));
var_dump(strpos('abcde', 'b'));
var_dump(strpos('abcde', 'x'));
?>

由图中可以看到,查找的字符串如果位于第一位,则返回为0,但在PHP中,0false取反均为true

1
2
Payload:
user=<"><injected-tag%20property="&pass=<injected-tag>

案例分析

案例选取为DeDecms V5.7SP2正式版,该CMS存在任意用户密码重置漏洞。

先看什么是PHP弱类型比较,在PHP中有两种比较符号=====

  • ==

在进行比较的时候,会先将字符串类型转换成相同,再比较

  • ===

在进行比较的时候,会先判断两种字符串的类型是否相同,再比较

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

var_dump('abc'== 1); //boolean false
var_dump('1abc'== 1); //boolean true
var_dump('abc'== 0); //boolean true
var_dump('1abc'== 0); //boolean false
var_dump(null == 0); //boolean true
var_dump('abc'=== 1); //boolean false
var_dump('1abc'=== 1); //boolean false
var_dump('abc'=== 0); //boolean false
var_dump('1abc'=== 0); //boolean false

?>

漏洞的触发点在 member/resetpassword.php文件中,由于对接收的参数safeanswer 没有进行严格的类型判断,导致可以使用弱类型比较绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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值代入sql语句中查询对应安全问题、安全答案、用户id和邮箱。在第11行中,如果所传入的问题和答案非空,并且登录查询到的安全问题和答案相同则进行sn函数中。但这里进行比较的是==而不是===,所以是可以进行绕过。

假设用户没有设置安全问题和答案,默认情况下安全问题值为$row['safequestion']='0',安全答案值为$row['safeanswer']=null。如果提交空参数的话,$safequestion$safeanswer值均为空字符串。那么第11行中if表达式就变成了if('0'=='' && null == '') ,即if(false && true)。所以,只要让$row['safequestion'] == $safequestionTrue就可以正确进入sn函数中。

通过上面PHP弱类型的学习,这里可以简单绕过:

1
2
3
4
5
6
7
<?php

var_dump('0' == '0e1'); //boolean true
var_dump('0' == '0.0'); //boolean true
var_dump('0' == '0.'); //boolean true

?>

接下来进入sn函数,文件路径在member/inc/inc_pwd_functions.php中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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函数中,会通过$mind查询dede_pwd_tmp表中是否存在临时密码。假设当前是第一次找回密码,$row值未空,则进入newmail函数中INSERT操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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&amp;id=".$mid."&amp;key=".$randval);
}
}
else
{
return ShowMsg('对不起修改失败,请联系管理员', 'login.php');
}
}

newmail函数中,由于是第一次找回密码,所以$send='N',进入对应的if分支,漏洞触发点存在此处,通过ShowMsg打印出$key值。$key值在第4行中随机生成并进行MD5加密,然后插入到dede_pwd_tmp表中,但这里直接打印拿到$key值。回到resetpassword.php文件查看dopost=getpasswd处操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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();
}

首先会判断当前$mid用户是否执行过重置密码,如果$row不为空继续执行以下操作:

1
2
3
4
5
6
7
8
9
10
11
12
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");
}

首先判断当前时间是否超过dede_pwd_tmp表中有效时间,符合要求打开密码修改页面,进入重置密码最后一步。

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
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值和dede_pwd_tmp表中的$row['key']值是否相同,如果相同就完成密码重置。

案例复现

注册test账号,其对应密码hash为ceb6c970658f31504a901b89dcd3e461

访问第一步中的Payload:

1
http://127.0.0.1/dede/member/resetpassword.php?dopost=safequestion&safequestion=0.0&safeanswer=&id=2

此处id为test用户对应的mid

获取到key值,拿出来拼接访问第二步Payload:

1
http://127.0.0.1/dede/member/resetpassword.php?dopost=getpasswd&id=2&key=dlY0Zq3g

密码重置成功。

案例学习

漏洞点位于api.php文件中buy函数处。

1
2
3
4
5
6
7
8
9
10
11
12
13
function buy($req){
require_registered();
require_min_money(2);

$money = $_SESSION['money'];
$numbers = $req['numbers'];
$win_numbers = random_win_nums();
$same_count = 0;
for($i=0; $i<7; $i++){
if($numbers[$i] == $win_numbers[$i]){
$same_count++;
}
}

彩票号码$win_numbers为随机生成的7位数字。在第9~11行中,用户提交的$numbers$win_numbers每一位数进行对比,而在第10行中使用了==进行比较。

1
2
3
4
5
6
<?php

var_dump(true == 1); //boolean true
var_dump(true == 0); //boolean false

?>

除了0falsenull以外均为 true ,所以使用 true 和数字进行比较,返回的值肯定是 true 。用户提交数据是以json格式上传,所以提交7个true元素的数组即可。如果随机数生成了0,则多购买几轮彩票就可以购买flag

钱够了直接购买就可以了。

CATALOG
  1. 1. 函数学习
  2. 2. Demo学习
  3. 3. 案例分析
    1. 3.1. 案例复现
  4. 4. 案例学习