PHP and cURL and NULL bytes, oh my!

While doing some digging about for various trivial vulnerabilities in WordPress plugins, I came across a file which was included in a number of plugins. This file appears to be part of a WordPress themes and plugin framework called ‘redux-framework’, and can still be found in the ‘redux-framework’ repository to this day: https://github.com/ReduxFramework/redux-framework/blob/master/ReduxCore/inc/p.php (https://github.com/ReduxFramework/redux-framework/blob/0ef23fc077a4105c8f93ddc5a1d09f4530cead6b/ReduxCore/inc/p.php for the version at the time this article was written).

What interested me about this script was the fact that it was taking in the value of the ‘url’ QSA, and directly initialising an instance of cURL with it. My first thought was local file disclosure – yummy. Upon further investigation, it appeared that local file disclosure was likely not possible, due to the fact that the script is checking to see whether or not the provided URL contains the string ‘http’. Now, I’ve been aware of null-byte escaping in PHP for other functions, such as file_get_contents (and their subsequent patching..), but have never tried it with cURL. On a whim, I placed a URL Encoded null byte into my provided URL. While I was unable to get the body of the curl_exec due to the ‘p.php’ script attempting to separate out the headers and the body (I’m sure some files can be retrieved using this vulnerability), after checking the body of curl_exec in a test script, it turns out that null-byte escaping is effective when setting cURL options. The below POC shows this perfectly.

<?php
$url = "file:///etc/passwd%00http";
$ch = curl_init($url);
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true);
echo curl_exec($ch);

This results in the contents of /etc/passwd being output.

The root cause of this is to be determined, but it appears that the responsibility is shared between PHP and cURL. In ‘lib/file.c’ around line 228, there is a for loop that breaks when it finds a zero byte in the string. Secondly, in cURL, the ‘%00’ string should not be decoded as a null byte.

After reporting this bug to PHP, it was subsequently discovered that the ‘php_curl_option_str’ function is not binary safe, and as such if you were to provide a true null byte anywhere in a string option(i.e. providing “file:///etc/passwd\0testing123” to the CURLOPT_URL option, or the init parameter of acurl object) then the resulting value of the option would be terminated at the null byte. This was resolved in the PHP bug #68089. This bug was subsequently fixed in this commit, which was included in version 5.6.2 of PHP.

Leaving PHP aside, I wanted to find out why cURL was decoding ‘%00’ into a null byte in the first place,so I installed the pre-requisites to build cURL, and went to digging through the source code.

After compiling cURL, I did a quick test on the binary using the ‘file’ schema. Yep, null byte escaping exists as a problem in cURL as a stand alone..

curl file:///etc/passwd%00testing

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin

So, here we can see the above request results in the contents of the /etc/passwd file being output

Just to see if this was an issue specific to the ‘file’ schema or not, I tried this with a ‘http’ schema.

curl --trace-ascii /dev/stdout http://localhost/index.php%00testing
== Info: About to connect() to localhost port 80 (#0)
== Info:   Trying 127.0.0.1... == Info: connected
=> Send header, 192 bytes (0xc0)
0000: GET /index.php%00testing HTTP/1.1
0023: User-Agent: curl/7.22.0 (x86_64-pc-linux-gnu) libcurl/7.22.0 Ope
0063: nSSL/1.0.1 zlib/1.2.3.4 libidn/1.23 librtmp/2.3
0094: Host: localhost
00b1: Accept: */*
00be:
<= Recv header, 24 bytes (0x18)
0000: HTTP/1.1 404 Not Found
<= Recv header, 37 bytes (0x25)
0000: Date: Thu, 09 Oct 2014 11:03:54 GMT
<= Recv header, 16 bytes (0x10)
0000: Server: Apache
<= Recv header, 21 bytes (0x15)
0000: Content-Length: 207
<= Recv header, 45 bytes (0x2d)
0000: Content-Type: text/html; charset=iso-8859-1
<= Recv header, 2 bytes (0x2)
0000:
<= Recv data, 207 bytes (0xcf)
0000: <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">.<html><head>.
0040: <title>404 Not Found</title>.</head><body>.<h1>Not Found</h1>.<p
0080: >The requested URL /index.php was not found on this server.</p>.
00c0: </body></html>.
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL /index.php was not found on this server.</p>
</body></html>
== Info: Connection #0 to host localhost left intact
== Info: Closing connection #0

As the above output from cURL shows, this resulted in a 404 response from the server, and the %00 string is being sent through as the request URI, so this is definitely an issue with the ‘file’ schema decoding the %00 string and other URL encoded characters, I can assume, into their respective characters. We can test this, by replacing the ‘p’ character with its URL encoded counterpart of %71.

curl file:///etc/%71asswd

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin

Great success..it looks like URL encoded characters are being decoded prior to reading from the file path. Now to find out where the input is getting parsed.

After downloading the cURL source code, I opened up the file called ‘lib/file.c‘, which contains the code to actually retrieve the content of the file at the specified path. The path, prior to parsing, is held in the ‘data->state.path’ variable. This is passed through the function ‘curl_easy_unescape’, which unescapes URL encoded strings into their binary counterparts. This function is defined in ‘lib/escape.c‘. It takes in a few arguments, the first being the cURL handle, the second being a pointer to a char array, the third being the original length of the string and the fourth being a pointer to the resulting length of the string.

In ‘lib/file.c‘, the last two arguments are basically pointless. They are passed in as the values ‘0’ and ‘NULL’, respectively. As we do not need to worry about URL encoding when working with file paths, really the solution here would be to not call curl_easy_unescape at all, but I bow to bagder and the solution he has implemented, as my C is rusty, and my level of knowledge regarding the cURL code base not worth mentioning.

With regards to passing a true binary byte into the path, the patch put forward by bagder at https://github.com/bagder/curl/commit/53cbea22310f1509e98f5537ef3a83c6e600629f will prevent NULL byte escaping for both cases, when an actual NULL byte is passed through, as well as when an encoded NULL byte is passed through. This is due to the returned length from the ‘curl_easy_unescape’ function not matching the resulting calculated length, as the length returned includes the decoded NULL byte (and the text after), and since the change to the loop in this patch includes checking for a NULL byte using the length returned from ‘curl_easy_unescape’, cURL craps out stating a malformed URL.

Anyway, this really was just a bit of a learning experience for me. I’ve familiarized myself with a small portion of the cURL source code, and found out how my ‘%00’ strings were being decoded into NULL bytes. The result of this is a patched cURL, and a patch for the issue discovered in PHPs ‘php_curl_option_str’ function.