Menu

PHPMailer < 5.2.18 远程代码执行分析及复现

0 Comment

0x00 漏洞概述

1.漏洞简介

Dawid Golunski 在圣诞节当天发布了一个漏洞报告,报告中表明 PHPMailer 小于5.2.18的版本存在远程代码执行漏洞。成功利用该漏洞后,攻击者可以远程任意代码执行。许多知名的 CMS 例如 WordPress 等都是使用这个组件来发送邮件,影响不可忽视。

2.漏洞影响

漏洞触发条件:

  • PHP 没有开启 safe_mode(默认)
  • 攻击者需要知道 Web 服务部署的路径

成功利用该漏洞后,攻击者可以远程任意代码执行。

3.影响版本

PHPMailer < 5.2.18

4.代码分析

从这里就可以看出上述第一个要求的原因, 如果开启了safe_mode, 则不会向mail函数传递第5个参数

这里$param作为mail的第五个参数,该参数用于指定sendmail的额外参数,其中sendmail-X参数会将流量记录到文件中从而写文件实现 RCE

$param参数这里补丁使用了escapeshellarg()来处理$this->Sender,我们来看一看$this->Sender是什么传进来的。

public function setFrom($address, $name = '', $auto = true)
{
    $address = trim($address);
    $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim
    // Don't validate now addresses with IDN. Will be done in send().
    if (($pos = strrpos($address, '@')) === false or
        (!$this->has8bitChars(substr($address, ++$pos)) or !$this->idnSupported()) and
        !$this->validateAddress($address)) {
            ...
        }
        ...
        if ($auto) {
            if (empty($this->Sender)) {
                $this->Sender = $address;
            }
        }
        return true;
    }

可以看到$this->Sender是由$address传入的,所以变量$this->Sender是通过setFrom函数的第一个参数$address进行设置的

在之前, 函数对$address是进行有效性判断的, 接下来进入这个地址有效性判断的函数中去看看validateAddress()

 public static function validateAddress($address, $patternselect = null)
{
        ...
        if (!$patternselect or $patternselect == 'auto') {
            //Check this constant first so it works when extension_loaded() is disabled by safe mode
            //Constant was added in PHP 5.2.4
            if (defined('PCRE_VERSION')) {
                //This pattern can get stuck in a recursive loop in PCRE <= 8.0.2
                if (version_compare(PCRE_VERSION, '8.0.3') >= 0) {
                    $patternselect = 'pcre8';
                } else {
                    $patternselect = 'pcre';
                }
            } elseif (function_exists('extension_loaded') and extension_loaded('pcre')) {
                //Fall back to older PCRE
                $patternselect = 'pcre';
            } else {
                //Filter_var appeared in PHP 5.2.0 and does not require the PCRE extension
                if (version_compare(PHP_VERSION, '5.2.0') >= 0) {
                    $patternselect = 'php';
                } else {
                    $patternselect = 'noregex';
                }
            }
        }
        switch ($patternselect) {
            case 'pcre8':
                /**
                 * Uses the same RFC5322 regex on which FILTER_VALIDATE_EMAIL is based, but allows dotless domains.
                 * @link http://squiloople.com/2009/12/20/email-address-validation/
                 * @copyright 2009-2010 Michael Rushton
                 * Feel free to use and redistribute this code. But please keep this copyright notice.
                 */
                return (boolean)preg_match(
                    '/^(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){65,}@)' .
                    '((?>(?>(?>((?>(?>(?>\x0D\x0A)?[\t ])+|(?>[\t ]*\x0D\x0A)?[\t ]+)?)(\((?>(?2)' .
                    '(?>[\x01-\x08\x0B\x0C\x0E-\'*-\[\]-\x7F]|\\\[\x00-\x7F]|(?3)))*(?2)\)))+(?2))|(?2))?)' .
                    '([!#-\'*+\/-9=?^-~-]+|"(?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\x7F]))*' .
                    '(?2)")(?>(?1)\.(?1)(?4))*(?1)@(?!(?1)[a-z0-9-]{64,})(?1)(?>([a-z0-9](?>[a-z0-9-]*[a-z0-9])?)' .
                    '(?>(?1)\.(?!(?1)[a-z0-9-]{64,})(?1)(?5)){0,126}|\[(?:(?>IPv6:(?>([a-f0-9]{1,4})(?>:(?6)){7}' .
                    '|(?!(?:.*[a-f0-9][:\]]){8,})((?6)(?>:(?6)){0,6})?::(?7)?))|(?>(?>IPv6:(?>(?6)(?>:(?6)){5}:' .
                    '|(?!(?:.*[a-f0-9]:){6,})(?8)?::(?>((?6)(?>:(?6)){0,4}):)?))?(25[0-5]|2[0-4][0-9]|1[0-9]{2}' .
                    '|[1-9]?[0-9])(?>\.(?9)){3}))\])(?1)$/isD',
                    $address
                );
            case 'pcre':
                //An older regex that doesn't need a recent PCRE
                return (boolean)preg_match(
                    '/^(?!(?>"?(?>\\\[ -~]|[^"])"?){255,})(?!(?>"?(?>\\\[ -~]|[^"])"?){65,}@)(?>' .
                    '[!#-\'*+\/-9=?^-~-]+|"(?>(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\xFF]))*")' .
                    '(?>\.(?>[!#-\'*+\/-9=?^-~-]+|"(?>(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\xFF]))*"))*' .
                    '@(?>(?![a-z0-9-]{64,})(?>[a-z0-9](?>[a-z0-9-]*[a-z0-9])?)(?>\.(?![a-z0-9-]{64,})' .
                    '(?>[a-z0-9](?>[a-z0-9-]*[a-z0-9])?)){0,126}|\[(?:(?>IPv6:(?>(?>[a-f0-9]{1,4})(?>:' .
                    '[a-f0-9]{1,4}){7}|(?!(?:.*[a-f0-9][:\]]){8,})(?>[a-f0-9]{1,4}(?>:[a-f0-9]{1,4}){0,6})?' .
                    '::(?>[a-f0-9]{1,4}(?>:[a-f0-9]{1,4}){0,6})?))|(?>(?>IPv6:(?>[a-f0-9]{1,4}(?>:' .
                    '[a-f0-9]{1,4}){5}:|(?!(?:.*[a-f0-9]:){6,})(?>[a-f0-9]{1,4}(?>:[a-f0-9]{1,4}){0,4})?' .
                    '::(?>(?:[a-f0-9]{1,4}(?>:[a-f0-9]{1,4}){0,4}):)?))?(?>25[0-5]|2[0-4][0-9]|1[0-9]{2}' .
                    '|[1-9]?[0-9])(?>\.(?>25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}))\])$/isD',
                    $address
                );
            case 'html5':
                /**
                 * This is the pattern used in the HTML5 spec for validation of 'email' type form input elements.
                 * @link http://www.whatwg.org/specs/web-apps/current-work/#e-mail-state-(type=email)
                 */
                return (boolean)preg_match(
                    '/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}' .
                    '[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/sD',
                    $address
                );
            case 'noregex':
                //No PCRE! Do something _very_ approximate!
                //Check the address is 3 chars or longer and contains an @ that's not the first or last char
                return (strlen($address) >= 3
                    and strpos($address, '@') >= 1
                    and strpos($address, '@') != strlen($address) - 1);
            case 'php':
            default:
                return (boolean)filter_var($address, FILTER_VALIDATE_EMAIL);
        }
    }

这里根据PCRE_VERSIONPHP_VERSION来选择过滤方式

有一种最简单的情况

  • PHP 不支持 PCRE,即不支持正则表达式(默认下php是会编译安装pcre扩展的)
  • PHP 版本小于 5.2.0(php版本小于5.2已经很少见了)

这个时候该函数会使用noregex的方式,即只需满足三个条件即可通过过滤:

  • 输入长度大于 3
  • 含有@
  • @不是最后一个字符

但是满足这个情况的主机现在已经很少了,正常情况下都是使用pcre8的正则来进行过滤,所以如果要扩大攻击面需要对正则进行绕过

5.正则饶过

<?php
preg_match(
'/^(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){65,}@)' .
'((?>(?>(?>((?>(?>(?>\x0D\x0A)?[\t ])+|(?>[\t ]*\x0D\x0A)?[\t ]+)?)(\((?>(?2)' .
'(?>[\x01-\x08\x0B\x0C\x0E-\'*-\[\]-\x7F]|\\\[\x00-\x7F]|(?3)))*(?2)\)))+(?2))|(?2))?)' .
'([!#-\'*+\/-9=?^-~-]+|"(?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\x7F]))*' .
'(?2)")(?>(?1)\.(?1)(?4))*(?1)@(?!(?1)[a-z0-9-]{64,})(?1)(?>([a-z0-9](?>[a-z0-9-]*[a-z0-9])?)' .
'(?>(?1)\.(?!(?1)[a-z0-9-]{64,})(?1)(?5)){0,126}|\[(?:(?>IPv6:(?>([a-f0-9]{1,4})(?>:(?6)){7}' .
'|(?!(?:.*[a-f0-9][:\]]){8,})((?6)(?>:(?6)){0,6})?::(?7)?))|(?>(?>IPv6:(?>(?6)(?>:(?6)){5}:' .
'|(?!(?:.*[a-f0-9]:){6,})(?8)?::(?>((?6)(?>:(?6)){0,4}):)?))?(25[0-5]|2[0-4][0-9]|1[0-9]{2}' .
'|[1-9]?[0-9])(?>\.(?9)){3}))\])(?1)$/isD',
$address
);

多研究一下会发现,在@前面,如果加上括号,将可以引入空格,P神的payload如下:aaa( -X/home/www/success.php )@qq.com

-X 参数, 把日志写到/www/success.php文件中

如果部分环境下遇到sendmail写权限不够的问题,就可以加-O参数

aaa( -X/home/www/success.php -OQueueDirectory=/tmp )@qq.com

-O option=value 是临时设置一个邮件存储的临时目录的配置。

注:-OQueueDirectory=/tmp/ 可以简写成-oQ/tmp/ -X后也可用相对路径

日志文件写到一个php文件中, 而mail发送的信息中含有php代码, 那么这不就可以形成webshell了

好,我们就成功得写了一条命令到$param参数里

前面得分析是怎样饶过限制穿一条命令到$param参数,现在我们看$param参数作为mail()函数的第5个参数是怎样运行的

$params = sprintf(‘-f%s’, escapeshellarg($this->Sender));

  if (ini_get('safe_mode') or !$this->UseSendmailOptions or is_null($params)) {
            $result = @mail($to, $subject, $body, $header);
        } else {
            $result = @mail($to, $subject, $body, $header, $params);
        }
        return $result;

mail()函数介绍

bool mail ( string $to  电子邮件收件人,或收件人列表
, string $subject       电子邮件的主题
, string $message       邮件内容
[, string $additional_headers 
[, string $additional_parameters ]] ) 许多web应用使用它设置发送者的地址和返回路径

使用mail()函数发送邮件

<?php
    $to="john@localhost";
    $subject="simple email ";
    $headers = "from: mike@localhost";
    $body="body of the message";
    $sender="admin@localhost";
    mail($to,$subject,$body,$headers,"-f $sender");
?>

在Linux系统上,mail函数在底层实现中,默认调用Linux的sendmail程序发送邮件

php将调用execve()执行sendmail程序

execve("/bin/sh","sh","-c","/usr/sbin/sendmail -t -i -f admin@localhost"],[/* 24 environment var */])

-t和-i参数由PHP自动添加。参数-t使sendmail从标准输入中提取头,-i阻止sendmail将’.’作为输入的结尾。

-f来自于mail()函数调用的第5个参数。$params = sprintf(‘-f%s’, escapeshellarg($this->Sender));

PHP会使用escapeshellcmd函数来过滤参数的内容,对特殊字符的转义来防止恶意命令执行(&#;`|*?~<>^()[]{}$\, \x0A and \xFF.’ “这些字符都不能使用),但是我们可以添加命令执行的其他参数,就如我们刚才所说的-X参数指定文件接收日志。

下面我们举一个具体的例子,在wordpress上进行漏洞利用

我们来看看这个漏洞在wordpress中的情况。漏洞文件是class.phpmailer.php,我们在wordpress中搜索查看这个文件,该文件在在wp-includes目录下。我们可以发现几行关键代码:

/**
     * Which method to use to send mail.
     * Options: "mail", "sendmail", or "smtp".
     * @var string
     */
    public $Mailer = 'mail';

    /**
     * The path to the sendmail program.
     * @var string
     */
    public $Sendmail = '/usr/sbin/sendmail';

我们刚才就发现,实际上phpmailer组件是调用linux系统命令sendmail进行邮件发送,命令格式为:sendmail -t -i -f username@hostname。并且我们继续审计代码发现:

/**
     * Get the server hostname.
     * Returns 'localhost.localdomain' if unknown.
     * @access protected
     * @return string
     */
    protected function serverHostname()
    {
        $result = 'localhost.localdomain';
        if (!empty($this->Hostname)) {
            $result = $this->Hostname;
        } elseif (isset($_SERVER) and array_key_exists('SERVER_NAME', $_SERVER) and !empty($_SERVER['SERVER_NAME'])) {
            $result = $_SERVER['SERVER_NAME'];
        } elseif (function_exists('gethostname') && gethostname() !== false) {
            $result = gethostname();
        } elseif (php_uname('n') !== false) {
            $result = php_uname('n');
        }
        return $result;
    }

serverHostname函数通过传入的SERVER_NAME参数来获取主机名,该主机名即HTTP请求报文中的host值,但是SERVER_NAME参数并没有经过任何过滤,因此我们可以进行任意构造拼接,从而产生了系统命令注入漏洞。

更棒的是,sendmail 提供了-O-X参数,-X参数用于写入日志文件, 我们可以使用-OQueueDirectory=/tmp/ -X/tmp/smtp.php命令组合,它会将发送的邮件保存到/tmp/smtp.php中, 那么在请求的时候payload应该类似于这样:

POST /wordpress/wp-login.php?action=lostpassword HTTP/1.1
Host: aaa( -X/tmp/smtp.php )@qq.com

那么如果我们写入的是Webshell后门文件呢?

思路很好,然而现实很无奈。

  • wordpress方面以及PHPMailer库方面都会防止攻击者注入空字符(空格或TAB)到sendmail命令中。并且,添加括号引入向sendmail中注入参数的方法已经行不通了
  • 比如我们想要调用/bin/touch的时候也会出问题,因为host字段中如果出现/,服务器会拒绝我们的请求。

我们去了解一下sendmail。然后就会发现柳暗花明又一村了。我们可以知道ubuntu/debain系统中,已经使用exim4替代了sendmail的功能,我们查看sendmail文件可以发现它是一个链向exim4的软链接文件。

那么我们可以利用exim4的语法参数进行命令执行参数的拼接啊!我们查看exim4的帮助手册,可以发现-be参数

简单来说,-be参数是一个字符串拓展测试命令,它可以读取一些变量的数据。比如,$tod_log,它可以显示系统时间。

~$ sendmail -be '$tod_log'
2018-04-20 16:26:47

并且,exim4提供了一些函数用来执行一些命令,如字符串截取函数substr$run系统调用函数。

我们可以截取空格字符。如图所示,substr函数从第十个字符开始截取,共截取一个字符,也就是时间字符串的第11个字符,是空格字符。

那么同理,我们也可以截取/字符串:

我们测试使用$run函数调用系统命令

到这里,遇到的问题都解决了,我们于是可以构造payload如下,该payload在/tmp/目录下创建test.txt文件:

11

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注