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)
我们都知道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的版本问题导致出现结果不同,还是因为其他原因。我这里由于时间问题就不继续研究了。 如果有其他结论,欢迎交流讨论