PHP I/O functions support a handful of weird non-standard protocols and wrappers, with the most fun one probably being php://filter. I can recall at least several occasions where e.g. php://filter/resource=/some/file helped bypass a "remote URL only" restriction or php://filter/convert.base64-encode/resource=/some/file helped exfiltrate a binary file in an text-only or otherwise filtered (think: keyword blacklisting) output.
When playing Insomni'hack teaser 2018 I discovered yet another trick, which surprised me to the extent that I couldn't believe my eyes that it actually worked. But to explain the trick, I'm actually going to have the explain the task.

Cool Storage Service

The task itself had two stages, first of which was an HTML/CSS-injection and exfiltration of an anti-XSRF token on a Content Security Policy protected website. At core this wasn't that different from the 34C3 CTF 2017 urlstorage task (see e.g. this write-up by l4w) - it's a pretty amazing attack, but out of scope for today's post. Sufficient to say that at the end of this stage you have modified admin's e-mail address and reset his password to the one of your liking. Therefore you begin the next stage with an admin account.

While the first stage is a client-side web challenge, the second one moved to server-side. It was known from the task description that the flag is in the /flag file on the file system, but you had to find a way to leak it.


At your disposal were the following features/bugs (starting from the bottom):
  • You could upload an image file (it was verified whether it's an actual image in some way), which would then be stored with a random file name (but same extension) in /uploads/random directory name/file name.ext. PHP-related extensions were blacklisted, and you couldn't access these files via HTTP, but...
  • (Admin-only option) You could provide a URL with the image and it would be downloaded, verified and stored by the service. Both the directory and name were random (as in previous case) and the extension was hardcoded to .png. While some URL schemas were blacklisted, as was 127.0.0.1 as the host, it quickly turned out that the php:// family still works.
So, to get code execution (in order to read the flag file) one just needs to use the first option to somehow inject a file that is both an image and a PHP script (simple - just append the PHP script at the end of a legit image), and has such an extension that the PHP engine would be called to execute it on access. And then just execute it using either http://my.domain.that.points.to.localhost/uploads/proper_dir/file or php://filter/resource=http://127.0.0.1/uploads/proper_dir/file.  Right. Easy.

I failed at the second part. I tried .php, .php{3-7} and .phtml, and nothing. I tried .php.xyz, but only the last extension prevailed. So I started looking for other ideas.
In case you're wondering - yes, I didn't think about testing .pht, even though I'm sure I saw it before. And that was the correct, planned solution.

After reading some PHP source-code around the php://filter functionality, I've stumbled on the iconv conversion filter (under the name of convert.iconv.*), which wasn't really mentioned in the PHP documentation (well, in all fairness, it's mentioned in a heavily downvoted comment in one place, some bugs, and the example output of phpinfo() function in yet another comment). This filter basically allows you to use iconv to convert all processed data from charset A to charset B, where both character sets can be chosen from a surprisingly long list of supported encodings - there are 1173 (sic) entries on that list, some being aliases for other encodings though:

$ iconv -l
The following list contains all the coded character sets known.  This does
not necessarily mean that all combinations of these names can be used for
the FROM and TO command line parameters.  One coded character set can be
listed with several different names (aliases).
 437, 500, 500V1, 850, 851, 852, 855, 856, 857, 860, 861, 862, 863, 864, 865,
 866, 866NAV, 869, 874, 904, 1026, 1046, 1047, 8859_1, 8859_2, 8859_3, 8859_4,
 8859_5, 8859_6, 8859_7, 8859_8, 8859_9, 10646-1:1993, 10646-1:1993/UCS4,
...
 WINDOWS-31J, WINDOWS-874, WINDOWS-936, WINDOWS-1250, WINDOWS-1251,
 WINDOWS-1252, WINDOWS-1253, WINDOWS-1254, WINDOWS-1255, WINDOWS-1256,
 WINDOWS-1257, WINDOWS-1258, WINSAMI2, WS2, YU

To give you an example, the following code changes a UTF-8 encoded string into hackers' favorite UTF-7:

<?php
$url = "php://filter/convert.iconv.UTF-8%2fUTF-7/resource=data:,some<>text";
echo file_get_contents($url);
// Output:
// some+ADwAPg-text

At that point a seemingly crazy idea popped into my head: perhaps there is such a charset pair, that converting the /flag content between these encodings would actually result in a valid image file?

Unlikely.

But the basic PHP's image verification routines are, well, pretty basic (see these three posts: 1, 2, 3). On top of that it was almost 3am, I was almost out of ideas and it would take at most 15 minutes to code it. So whatever, I just went with it.

The script that I've written would select two charsets at random, form a php://filter/convert.iconv.CHARSET1%2fCHARSET2/resource=/flag URL and submit it to the service. If the service would answer with "Not an image", it would start over. And in the unlikely event of the service replying with "View here...", signaling successful processing, it would exit printing out both the URL of the result file and the charset pair.

I've run the script in the background and went to discuss other potential ideas with my teammates. We were thinking if we could use mcrypt/mdecrypt filter (I don't think you can invoke it through this interface though) with a selected IV, which would perhaps allow us to trick the image verification into accepting the flag as an image (in a somewhat similar manner to what I described in this post - look for "Bug 3 (unexploitable)" located at the very end).
To my great surprise, the script exited 5 minutes later outputting the following two words:

IBM1154 UTF-32BE
Wait. What?


I've quickly downloaded the "image" file and wrote a reverse-conversion script:

<?php
$d = file_get_contents(
 "php://filter/convert.iconv.UTF-32BE%2fIBM1154/resource=".
 "data:image/png;base64,AAAEWAAAACsAAARbAAAAIwAABFsAAAA/AA".
 "AAKAAAAC8AAAA+AAAAYAAABCoAAAQFAAAEAwAABAYAAAAlAAAALwAABD".
 "AAAACtAAAEUwAAAC8AAAA+AAAEDgAABAMAAAQFAAAEBwAAAD8AAAA/AA".
 "AEBAAABAYAAAA/AAAEDAAAAGAAAAA/AAAEDwAAACcAAACO");
echo $d;

And then executed it, both expecting it to fail for some reason and hoping it wouldn't:

$ php wtf.php
INS{SoManyWebflawsCantbegoodforyou}

It actually worked! And we even got "first blood" on the task :)

The actual URL to get the flag, in case you're wondering:
php://filter/convert.iconv.IBM1154%2fUTF-32BE/resource=/flag

I've chatted with clZ (the task author) about this and he was as surprised as I was with the solution - looks like it wasn't the intended one.

And that's it. A funny story of a crazy 3am idea that actually worked, and yet another PHP quirk that might be useful on another CTF some day.

P.S. It seems that IBM1154 is actually "EBCDIC Cyrillic, Multilingual with euro" (charset).

P.S.2. When the "flag-image" is passed to PHP's getimagesize() we get the following output:

array(5) {
 [0]=>
 int(4)
 [1]=>
 int(88)
 [2]=>
 int(15)
 [3]=>
 string(21) "width="4" height="88""
 ["mime"]=>
 string(18) "image/vnd.wap.wbmp"
}

So, image/vnd.wap.wbmp, is it? Well then. Whatever works ;)

P.S.3. Turns out p4 team had an equally crazy solution. They didn't publish a write-up yet, but I'm looking out for it (here). EDIT: You can read their full write-up here or checkout akrasuski1's comment down below for a brief version.

Comments:

2018-01-22 01:02:58 = mati
{
Amazing
}
2018-01-22 10:18:16 = knapstack
{
Good one ! Hoping to see this for future events. We solved the intended way, with valid GIF header and .pht extension. To bypass the url fetch, I used http://lvh.me/uploads/randomdirname/file.pht . Probably that is the intended one.
}
2018-01-22 13:42:43 = akrasuski1
{
P4 here. Indeed, our solution for this task was pretty crazy too. We ended up writing PHP script bruteforcing all possible combinations of base64-encode, base64-decode and rot13 filters, hoping on of them will end up being image - and it turns out, for whatever reason, that having a null byte as the first byte of the data was enough for it to be considered an image (initially we were trying to get GIF letters, which was enough too). After a couple of minutes of search, we found this monstrosity:

php://filter/read=string.toupper|convert.base64-encode|convert.base64-encode|string.tolower|string.rot13|convert.base64-encode|string.toupper|convert.base64-decode/resource=/flag

This, being passed INS{hehe}, produced data starting with the null byte. Then we were able to download the real data from server, and brute force, char by char, the actual flag. I did it semi-manually so I'm not entirely sure, but I think the encoding was not unambiguous - but since flag thankfully was meaningful words, we found it anyway. Unfortunately, 20 minutes too late to grab third place :/
}
2022-11-27 12:04:34 = tomek7667
{
Wow, greate write-up, resourceful
}

Add a comment:

Nick:
URL (optional):
Math captcha: 9 ∗ 2 + 9 =