Getting RCE on Monero forums with wrapwrap
I had my eyes on cfreal's wrapwrap for a while, waiting to find a worthy bug to play with it. It finally happened!
In short, wrapwrap
is a neat algorithm built on PHP stream filters to add a prefix and/or suffix to another stream, i.e. a file. This becomes handy in cases where you would have an arbitrary file read bug, to be later blocked because the file has to abide to a specific structure (like JSON, XML) or a MIME type based on its contents (like GIF, PNG, PDF).
Finding a good bug
I started with the assumption PHP applications would usually use libmagic
for MIME detection, through the built-in fileinfo module, and have the flags FILEINFO_MIME_TYPE
or FILEINFO_MIME
set. Among the GitHub results, monero-project/monero-forum
looked fun.
Looking at Monero forums
The forum is based on Laravel 4; its routes are stored in app/routes.php
. Among them, an "image proxy" does not require authentication:
/* Image Proxy */
;
I assume this acts as a proxy for images of forum posts to prevent IP leaks to third-parties.
The GET parameter link
flows into file_get_contents()
and we have the expected MIME type validation after that—this is the exact situation I was looking for. It will be simpler to work with the GIF case, as $image
will be returned as-is while we can suspect that intervention/image
would do more complex things.
A GET parameter could be tedious for our payload though: we are limited to 8 KB and wrapwrap
will need much more than that, should we need a append data and read a big file. We need to find the smallest prefix to pass the MIME type check, and ideally without any suffix.
libmagic's magic
I recommend reading "libmagic: The Blathering" by Evan Sultanik on the Trail of Bits blog to learn about libmagic
and its DSL.
PHP vendors libmagic
under ext/fileinfo/
with a few patches. In create_data_file.php
, they take a compiled version of all rules to create data_file.c
. We can assume they likely wouldn't have to patch rules of a format as simple as GIF and we can take those from upstream instead. Child tests are omitted below:
0 string GIF8 GIF image data
!:strength +80
!:mime image/gif
!:apple 8BIMGIFf
!:ext gif
Let's feed this prefix to wrapwrap
to get the chain of filters we'll be using. To account for padding, it will effectively preprend GIF8MM
to the stream but it won't be a problem here.
$ wrapwrap.py 'config/app.php' GIF8 '' 16
php://filter/convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7G |convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.base64-decode/resource=TARGET
Failing to get RCE… or not?
My go-to Laravel code execution path relies on leaking APP_KEY
from the configuration, and forging cookies to obtain an unserialization primitive—finding a new popchain is usually the fun part.
Reading the Apache configuration files got me the full path to the application and I could extract the APP_KEY
from /var/www/forum.getmonero.org/webroot/app/config/app.php
.
Peeking at the configuration, I had the assumption that because Redis was set as the default session driver, this couldn't work as cookies wouldn't be serialized using the native PHP format:
return ;
Reading the server's composer.lock
shows Laravel v4.2.22 is used:
"name": "laravel/framework",
"version": "v4.2.22",
"source": ,
In this version and even with this session backend, cookies are used to persist CSRF tokens and are systematically unserialized:
So 'driver' => 'redis'
doesn't matter in the end. There aren't that many classes around so I just took the usual Guzzle/FW1 chain to write a file under the web root. Here's your RCE!
Disclosure
Fortunately for them, this appeared to live on a server hosting various other cryptocurrency-related projects, but the main domain https://getmonero.org is in another castle.
I reported this bug to the Monero project on December 16th but it didn't get much traction through the suggested disclosure channels. Good think IRC is still a thing, +selsta quickly found the right person to nuke the forums once and for all—thanks!
Conclusion
I've personally been ignoring these bugs since phar://
became unexploitable with recent versions of PHP, the same way as I was ignoring them before, but these are clearly useful again. And it's not gonna change anytime soon ;-)