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

0x01 起因

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

Day 2 - Twig

Can you spot the vulnerability?

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

1.png

2.png

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

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

filter_var(variable, filter, options)

3.png

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

图片 1.png

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/

图片 2.png

如上图,很容易看出来,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/

图片 3.png

0x03 有趣的事

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

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

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

图片 4.png

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

图片 5.png

发现是成功了的。那么原因出现在哪里呢? 第一个想法是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);
}

两者主要变化对比:

11.png

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

图片 6.png
图片 7.png
图片 8.png

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

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

我的结果是,没有影响。

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

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

8.0的版本。

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

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

图片 10.png

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

Error: Host not allowed

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

图片 11.png

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

0x04 参考

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

parse_url函数的解释和绕过

PHP-Audit-Labs

PHP扩展开发(二)

标签: none

添加新评论