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