由filter_var()函数引起的技术探讨

0x01 起因

最近在看PHP SECURITY CALENDAR 2017的题目,这是第二题

Day 2 - Twig

Can you spot the vulnerability?

<?php
// composer require "twig/twig"require 'vendor/autoload.php';
class Template
{
    private $twig;
    public function __construct()
    {
        $indexTemplate = '<img ' . 'src="https://loremflickr.com/320/240">' . '<a href="{{link|escape}}">Next slide »</a>';
        // Default twig setup, simulate loading
        // index.html file from disk
        $loader = new Twig\Loader\ArrayLoader(['index.html' => $indexTemplate]);
        $this->twig = new Twig\Environment($loader);
    }
    public function getNexSlideUrl()
    {
        $nextSlide = $_GET['nextSlide'];
        return filter_var($nextSlide, FILTER_VALIDATE_URL);
    }
    public function render()
    {
        echo $this->twig->render('index.html', ['link' => $this->getNexSlideUrl()]);
    }
}
(new Template())->render();

这里考察的是XSS漏洞。对于XSS漏洞,大部分出现的地方在输出环节,如 echo $var; $var可控且无过滤,或者过滤不严格,导致了XSS漏洞的产生。

而在这里,XSS的出现是因为标签内的code过滤不严格,导致可利用javascript伪协议绕过。

0x02 分析

代码不长,首先来通读下整段代码。 这是一个Template的类的定义,类的内部定义了三个函数函数,分别为construct()、getNexSlideUrl()以及render()。

construct()主要实现了模板载入,getNexSlideUrl()主要实现了URL过滤识别,render()则主要是实现了传入URL的功能。函数的功能并不复杂,关键点在于两个过滤函数:

  • twig的escape过滤器
  • filter_var()的URL判断

对于twig的escape过滤器,可以见官网的说明:

escape uses the PHP native htmlspecialchars function for the HTML escaping strategy. 

其实也就是将htmlspecialchars包装到了escape的过滤器中,换了个使用方式,真正起作用的,还是htmlspecialchars函数

htmlspecialchars(string,flags,character-set,double_encode)

2.png

我们都知道htmlspeciachars的主要作用就是将特殊字符转换为 HTML 实体,这一方法不但可以在一定程度上防止SQL,也可以在一定程度上防止XSS。

但是有些xss并不需要特殊字符。 再来看看filter_var():

filter_var(variable, filter, options)

filter_var($nextSlide, FILTER_VALIDATE_URL);

将获取的nextSlide值传入filter_var()函数中,然后判断其是否符合URL的相关规则。 这里的URL的判断就很有意思,有很多绕过判断的方式,有兴趣的朋友可以自行谷歌。 但是这里考虑到htmlspecicalchars,因此对于单双引号以及尖括号的payload都不考虑。

官方给的解答是:

?nextSlide=javascript://comment%250aalert(1)

NextSlide传入的值为

javascript://comment%250aalert(1)

如果将这个值echo出来,结合标签,就会产生xss,具体流程如下:

首先传入到<a>标签内:

<a href='javascript://comment%250aalert(1).'>Next slide »< /a> 

//为注释符,%25为百分号,%与0a组成为换行符
最终单独生成一行为alert(1),成功执行了alert函数

0x03 实例

// index.php<?php $url = $_GET['url'];if(isset($url) && filter_var($url, FILTER_VALIDATE_URL)){    $site_info = parse_url($url);    if(preg_match('/sec-redclub.com$/',$site_info['host'])){        exec('curl "'.$site_info['host'].'"', $result);        echo "<center><h1>You have curl {$site_info['host']} successfully!</h1></center>              <center><textarea rows='20' cols='90'>";        echo implode(' ', $result);    }    else{        die("<center><h1>Error: Host not allowed</h1></center>");    } }else{    echo "<center><h1>Just curl sec-redclub.com!</h1></center><br>          <center><h3>For example:?url=http://sec-redclub.com</h3></center>";}
?>// f1agi3hEre.php<?php  $flag = "HRCTF{f1lt3r_var_1s_s0_c00l}"?>

不看源代码可能很难了解这题的意思,但是看了源代码题目就很清楚明了了。

通过GET方式获取URL参数,参数需要满足filter_var中FILTER_VALIDATE_URL的URL规则

同时,还要含有Linux命令,能够让exec()函数执行得到f1agi3hEre.php的内容。

关于绕过filter_var的方法有很多,具体可以看下面的参考内容

这里就直接给出payload了:

?url=hello://";ls;";sec-redclub.com/

如上图,很容易看出来,host的内容是

";ls;";sec-redclub.com

结合exec执行函数,最终的效果相当于以下代码:

exec(ls,$result);exec(sec-redclub.com,$result);echo implode(' ', $result);

所以,最终读取flag的payload为:

? url=hello://";cat<f1agi3hEre.php;";sec-redclub.com/

0x03 有趣的事

在测试的过程中,也看到了其他的解法,如:

?url=demo://%22;ls;%23;sec-redclub.com:80/

但是我本地测试发现失效:

开始怀疑是PHP版本的问题,我本地PHP版本为7.1,博客的PHP版本为5.x 遂去我的博客也搭建了一下,测试效果如下:

发现是成功了的。那么原因出现在哪里呢? 第一个想法是PHP内置函数的问题,于是看了看php 5.x版本的filter_var内置函数:

/* {{{ proto mixed parse_url(string url, [int url_component])   Parse a URL and return its components */ PHP_FUNCTION(parse_url){        char *str;        int str_len;        php_url *resource;        long key = -1;         if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s|l", &str, &str_len, &key) == FAILURE) {                return;        }         resource = php_url_parse_ex(str, str_len);        if (resource == NULL) {                /* @todo Find a method to determine why php_url_parse_ex() failed */                RETURN_FALSE;        }         if (key > -1) {                switch (key) {                        case PHP_URL_SCHEME:                                if (resource->scheme != NULL) RETVAL_STRING(resource->scheme, 1);                                break;                        case PHP_URL_HOST:                                if (resource->host != NULL) RETVAL_STRING(resource->host, 1);                                break;                        case PHP_URL_PORT:                                if (resource->port != 0) RETVAL_LONG(resource->port);                                break;                        case PHP_URL_USER:                                if (resource->user != NULL) RETVAL_STRING(resource->user, 1);                                break;                        case PHP_URL_PASS:                                if (resource->pass != NULL) RETVAL_STRING(resource->pass, 1);                                break;                        case PHP_URL_PATH:                                if (resource->path != NULL) RETVAL_STRING(resource->path, 1);                                break;                        case PHP_URL_QUERY:                                if (resource->query != NULL) RETVAL_STRING(resource->query, 1);                                break;                        case PHP_URL_FRAGMENT:                                if (resource->fragment != NULL) RETVAL_STRING(resource->fragment, 1);                                break;                        default:                                php_error_docref(NULL TSRMLS_CC, E_WARNING, "Invalid URL component identifier %ld", key);                                RETVAL_FALSE;                }                goto done;        }         /* allocate an array for return */        array_init(return_value);     /* add the various elements to the array */        if (resource->scheme != NULL)                add_assoc_string(return_value, "scheme", resource->scheme, 1);        if (resource->host != NULL)                add_assoc_string(return_value, "host", resource->host, 1);        if (resource->port != 0)                add_assoc_long(return_value, "port", resource->port);        if (resource->user != NULL)                add_assoc_string(return_value, "user", resource->user, 1);        if (resource->pass != NULL)                add_assoc_string(return_value, "pass", resource->pass, 1);        if (resource->path != NULL)                add_assoc_string(return_value, "path", resource->path, 1);        if (resource->query != NULL)                add_assoc_string(return_value, "query", resource->query, 1);        if (resource->fragment != NULL)                add_assoc_string(return_value, "fragment", resource->fragment, 1);done:                php_url_free(resource);}/* }}} */

PHP 7.1版本的filter_var内置函数如下:

* {{{ proto mixed parse_url(string url, [int url_component])   Parse a URL and return its components */ PHP_FUNCTION(parse_url){        char *str;        size_t str_len;        php_url *resource;        zend_long key = -1;         if (zend_parse_parameters(ZEND_NUM_ARGS(), "s|l", &str, &str_len, &key) == FAILURE) {                return;        }         resource = php_url_parse_ex(str, str_len);        if (resource == NULL) {                /* @todo Find a method to determine why php_url_parse_ex() failed */                RETURN_FALSE;        }         if (key > -1) {                switch (key) {                        case PHP_URL_SCHEME:                                if (resource->scheme != NULL) RETVAL_STRING(resource->scheme);                                break;                        case PHP_URL_HOST:                                if (resource->host != NULL) RETVAL_STRING(resource->host);                                break;                        case PHP_URL_PORT:                                if (resource->port != 0) RETVAL_LONG(resource->port);                                break;                        case PHP_URL_USER:                                if (resource->user != NULL) RETVAL_STRING(resource->user);                                break;                        case PHP_URL_PASS:                                if (resource->pass != NULL) RETVAL_STRING(resource->pass);                                break;                        case PHP_URL_PATH:                                if (resource->path != NULL) RETVAL_STRING(resource->path);                                break;                        case PHP_URL_QUERY:                                if (resource->query != NULL) RETVAL_STRING(resource->query);                                break;                        case PHP_URL_FRAGMENT:                                if (resource->fragment != NULL) RETVAL_STRING(resource->fragment);                                break;                        default:                                php_error_docref(NULL, E_WARNING, "Invalid URL component identifier " ZEND_LONG_FMT, key);                                RETVAL_FALSE;                }                goto done;        }         /* allocate an array for return */        array_init(return_value);     /* add the various elements to the array */        if (resource->scheme != NULL)                add_assoc_string(return_value, "scheme", resource->scheme);        if (resource->host != NULL)                add_assoc_string(return_value, "host", resource->host);        if (resource->port != 0)                add_assoc_long(return_value, "port", resource->port);        if (resource->user != NULL)                add_assoc_string(return_value, "user", resource->user);        if (resource->pass != NULL)                add_assoc_string(return_value, "pass", resource->pass);        if (resource->path != NULL)                add_assoc_string(return_value, "path", resource->path);        if (resource->query != NULL)                add_assoc_string(return_value, "query", resource->query);        if (resource->fragment != NULL)                add_assoc_string(return_value, "fragment", resource->fragment);done:        php_url_free(resource);}

两者主要变化对比:

主要是RETVAL_STRING(…,1)中后面的参数被删除了,那么这有什么影响呢? 查看官方的介绍:



strdup()函数是c语言中常用的一种字符串拷贝库函数,主要是将串拷贝到新建的位置处。

那么回到最初的问题——多了这个1,对filter_var函数有没有影响?

我的结果是,没有影响。

因为RETVAL_STRING(..., 1) 可以被转换为 RETVAL_STRING(...),此外 RTVAL_STRING(..., 0) 也可以被转换为RETVAL_STRING(...);efree(...); 两者的区别就在于这里的string是否被重新分配。

那么是什么导致了同样的payload结果不同呢? 查看了下本地MySQL的版本:

8.0的版本。

初步结论是MySQL版本导致的。

在虚拟机里也搭建了,不过MySQL版本为5.5,结果如下:

由于是Windows环境,所以ls没效果。但是显然绕过了filter_var,不然会和我本机一样,出现

Error: Host not allowed

在本地修改注释符#为--,如下:

发现也是成功绕过,但至于为何没有列出文件列表。就不是很清楚了( 此处求解? ) 有兴趣的朋友可以自己去试一试看,到底是否是因为MySQL的版本问题导致出现结果不同,还是因为其他原因。我这里由于时间问题就不继续研究了。 如果有其他结论,欢迎交流讨论

0x04 参考

php执行多条shell命令
Zend API:深入 PHP 内核
php-src: RETVAL_STRINGL

parse_url函数的解释和绕过

PHP-Audit-Labs

PHP扩展开发(二)

当时复现这题的时候ls好像是失败了。 我改用echo写文件的方式拿shell

php7.x

    char *s = "\";ls;;90sec.com:80/#444"  // char *s = "\";ls;#;90sec.com:80/";
    int  len;
    len  = strlen(s);
    char *e , *p;
    e = len + s;
parse_host:
    /* Binary-safe strcspn(s, "/?#") */
    if ((p = memchr(s, '/', e - s))) {
        e = p;
    }
    if ((p = memchr(s, '?', e - s))) {
        e = p;
    }
    if ((p = memchr(s, '#', e - s))) {
        e = p;
    }

php5.x

char *s = "\";ls;;90sec.com:80/#444"  // char *s = "\";ls;#;90sec.com:80/";
    int  len;
    len  = strlen(s);
    char *e , *p;
    e = len + s
if (!(p = memchr(s, '/', (e- s)))) {
        char *query, *fragment;

        query = memchr(s, '?', (e - s));
        fragment = memchr(s, '#', (e - s));

        if (query && fragment) {
            if (query > fragment) {
                e = fragment;
            } else {
                e = query;
            }
        } else if (query) {
            e = query;
        } else if (fragment) {
            e = fragment;
        }
    } else {
        e = p;
    }