foundryvtt unauthenticated rce part1/3 - dir overwrite

FYI: THIS HAS BEEN FIXED IN FOUNDRY 0.7.10+ AND 0.8.2+. So please update your foundry instance!

Ayo. Im back with another virtual tabletop vulnerability post :). This time were taking a look at my favorite vtt platform foundryvtt. I’ll be making a series of posts covering the vulnerabilities i found within the software. Most notably an authentication bypass leading into an arbitrary file write. This first part will be about how i found a way to overwrite arbitrary directories on the target filesystem along with a small information leak at the end. The next post will be about how i managed to dump all the other users credentials as a regular user. Im saving the most interesting part for last (full admin authentication bypass) as thats by far the hardest to explain. With the posts i’ll also release the relevant exploits i wrote.

FoundryVTT

Foundry is a software you can use to host online tabletop games. A license costs about 50$ and like most paid software, it’s proprietary. When you purchase the software, you’ll get a server written in nodejs along with a guide on how to get your friends to join. As it’s fully self hosted, it’s reasonable to assume most people will be running this server on their personal computer.

Unobfuscating javascript-obfuscator

So because we host the server, we have the servers code. Neat. Only problem: it’s obfuscated. So how do we make the code readable? We unobfuscate it. At the time i had no idea what obfuscator they used and i couldn’t find any public unobfuscator that works, so i made my own. Later on i found out (by asking the creator) that they’re actually using a public obfuscator but hey, now i know how to make my own js unobfuscator. I wont go into detail on how i made it because it’s really not that interesting. You can go look at the code if you want :).

Also a heads up: many variables and stuff in this post are named by me, i don’t actually have the original code.

How i found the first (and simplest) bug

First thing i did when i got the code was look for obvious fuckups / low hanging fruit. From using foundry alot i knew that it had lots of file uploads and because those tend to be problematic i was searching the sauce code for all file uploads i could find. Looking through the code it was clear to me that the foundry devs are not entirely ignorant about their programs security. There were alot of checks to make sure you couldn’t escape the directory foundry wants you to stay in for reading and writing files. But if you look hard enough you WILL find something that’s just how the game is played.

So what did i find?

Arbitrary directory overwrite

A way to overwrite arbitrary directories via the module installation process.

Installing modules in foundry

First i’ll quickly go over what modules are and how they’re installed in foundry. Modules are just plugins to extend the software. When a server admin installs them, they’ll just get hosted as files. The only code they’re allowed to have is js code executed on the players browsers. By installing an arbitrary module, there is no risk of code execution in of itself unless you happen to have a 0-day for the browser the players are using.

Installing them is done with a POST request to the /setup page. The request body has a JSON like this:

1
2
3
4
5
6
{
  "action": "installPackage",
  "type": "module",
  "name": "blah",
  "manifest": "https://manifest_url_to_module"
}

So esentially the only thing we do is direct foundry to a url where a manifest file is hosted. The manifests are json too. This is what they look like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "name": "blah",
  "title": "memes",
  "description": "does memes n shit",
  "version": "69",
  "minimumCoreVersion" : "0.7.9",
  "compatibleCoreVersion" : "0.7.9",
  "authors": [{ "name": "catnip", "email": "catnip@catnip.fyi", "discord": "catnip#0420" }],
  "esmodules": [ "src/main.js" ],
  "languages": [{ "lang": "en", "name": "English", "path": "lang/en.json" }],
  "url": "https://github.com/sum-catnip",
  "download": "https://github.com/sum-catnip",
  "manifest": "https://github.com/sum-catnip",
  "readme": "https://github.com/sum-catnip",
  "changelog": "https://github.com/sum-catnip",
  "bugs": "https://github.com/sum-catnip"
}

Now we know what data the server receives but the way more interesting question is what the server does with this data.

Discovering the bug

The post request is processed in packages/views.js:installPackage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
async function installPackage({type, name, manifest}) {
    const loc_0 = PACKAGE_TYPE_MAPPING[type];
    if (!loc_0) throw new Error('Invalid package type ' + type + ' requested');
    if (!manifest) throw new Error('A manifest URL must be provided');
    
    let pck = await checkPackage({ 'type': type, 'name': name, 'manifest': manifest });
    // some more irrelevant checks

    name = pck.name;
    let dl_url = pck.download;

    // ... blah blah ...

    const loc_4 = await loc_0.install(name, dl_url, dl_url_protected);

    // just more blah blah
}

I condensed this method to the most relevant parts only. First we get a PACKAGE_TYPE_MAPPING of module (thats what i put as the type in my earler POST request). This just means loc_0 will be the module declared inside packages/module.js. There are different package types but theyr’e all just plugins for the software, don’t worry about it too much. The module inherits its install function from packages/package.js so package.js is actually the file we’d be interested in.

After getting the type mapping, we “check the package” (line 6). This will go fetch the json from the manifest url we gave it:

1
2
3
4
...
const res = await fetch(manifest_url, { 'referrerPolicy': 'no-referrer' });
res_json = await res.json();
...

and also does some stuff like checking if all the required fields are present. This means the pck variable is our json manifest. The code then passes the download url and the name from inside our manifest to loc_0.install (line 14), this is where the magic happens. Can you spot the error? packages/package.js:install:

 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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
static async install(name, dl_url, dl_url_protected) {
    const {logger, express} = config;
    const pck_type = this.name.toLowerCase();
    const path_noext = path.join(this.baseDir, name);
    const path_zip = path.join(this.baseDir, name + '.zip');
    let progress_old = 0;
    const progress_cb = (step, progres, step_name) => {
        if (progres === progress_old)
            return;
        progress_old = progres;
        let msg = step_name + ' - ' + progres + '%';
        logger.info(msg);
        express.io.emit('progress', {
            'action': 'installPackage',
            'type': pck_type,
            'name': name,
            'pct': progres,
            'msg': msg,
            'step': step
        });
    };
    await Files.downloadFile(dl_url, path_zip, { 'onProgress': prog => progress_cb('Downloading', prog, 'Downloading Package') });
    let rmroot = '';
    let archive_json = await Files.summarizeArchive(path_zip, { 'manifestPath': pck_type + '.json' });
    if (archive_json.manifest === null) {
        const loc_7 = archive_json.contents.find(path => path.endsWith(pck_type + '.json'));
        rmroot = path.dirname(loc_7) + '/';
        archive_json = await Files.summarizeArchive(path_zip, { 'manifestPath': '' + rmroot + pck_type + '.json' });
    }
    if (!archive_json.manifest) {
        fs.unlinkSync(path_zip);
        throw new Error('The downloaded package ' + name + ' did not contain the expected ' + pck_type + '.json manifest file.');
    }
    const manifest_json = JSON.parse(archive_json.manifest);
    await Files.rmdir(path_noext);
    await Files.extractArchive(path_zip, path_noext, {
        'onProgress': (arg_0_1, arg_1_1, arg_2_1, prog_percent) => progress_cb('Installing', prog_percent, 'Installing Package'),
        'removeRoot': rmroot
    });
    if (dl_url_protected) {
        const sig_filepath = path.join(path_noext, 'signature.json');
        fs.writeFileSync(sig_filepath, JSON.stringify({
            'key': dl_url_protected.key,
            'signature': dl_url_protected.signature
        }, null, 2));
    }
    progress_cb('Cleanup', 100, 'Cleaning Up Artifacts');
    fs.unlinkSync(path_zip);
    progress_cb('Complete', 100, 'Installation Complete');
    this.packages = null;
    return manifest_json;
}

This function just assumes that the name parameter is trusted but its completely user supplied and unchecked. Remember that the name parameter is comming directly out of the name field in our json manifest.

const path_zip = path.join(this.baseDir, name + ‘.zip’);

If we supply ../../../../../../tmp/x as the module name inside the manifest, path_zip would be basedir/../../../../../../tmp/x.zip. Ok we can write files anywhere on the system but they have to end with .zip and also theyre deleted immediately after (line 48). While we could probably make this exploitable on linux by finding a way to crash the function before the file is deleted and abuse the fact linux doesn’t really care about file extensions, there is a better way. path_noext doesn’t append .zip so we completely control that path.

const path_noext = path.join(this.baseDir, name);

The only 2 places this variable is used are:

1
2
3
4
5
await Files.rmdir(path_noext);
await Files.extractArchive(path_zip, path_noext, {
    'onProgress': (arg_0_1, arg_1_1, arg_2_1, prog_percent) => progress_cb('Installing', prog_percent, 'Installing Package'),
    'removeRoot': rmroot
});

First it removes the path and then it extracts the zip there. Which means we can replace an arbitrary directory with the contents of our zip. On windows we can simply put an exe in one of the many autostart dirs. On linux we can replace the .ssh dir or some motd or something else entirely. Going from arbitrary file writing to code execution is outside the scope of this post but it’s very far from impossible, especially in this scenario of people self hosting a server on their personal desktop.

The exploit

I wrote a small POC exploit that works like this:

  • zip up the in dir
  • make a module.json where the name field contains the target path ex: ../kektop
  • make a post request to /setup with action: installPackage and the manifest pointing to our evil module.json

This is how you use it:

  • get an admin session with my other exploit (releasing soon TM)
  • put some malicious data in the in dir
  • run exploit against target:

python ziphaxx.py http://localhost:30000

  • start http server in out dir:

python -m http.server 1337

  • specify target dir: ../kektop

The exploit will also guide you through those steps.

I’ll talk about getting the admin session soon but for now let’s not worry about that. I have another thing that makes this exploit just a bit more reliable.

A helping hand

While looking for a way to bypass authentication, i stumbled upon a small infoleak. Foundry uses WebSocket for most of the communication. On the node server, every incomming socket gets initialized with some handlers for certain api calls. But there are 2 handlers which get connected before the server checks if the client is even authenticated.

 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
async function activate(ctx) {
    const handshake = ctx.handshake.query;
    const sess = auth.sessions.sessions[handshake.session];
    if (!sess) return;
    ctx.session = sess;
    ctx.on('getWorldStatus', requestWorldState);
    ctx.on('getSetupData', x => requestSetupData(sess, x));
    files_1.default.socketListeners(ctx, handleEvent);
    if (!game.world || !game.ready) return;
    const uid = sess.worlds[game.world.id];
    const usr = game.users.find(u => u._id === uid);
    if (!usr) return;
    ctx.userId = uid;
    ctx.user = usr;
    Activity.socketListeners(ctx);
    document_1.default.socketListeners(ctx, handleEvent);
    embedded_1.default.socketListeners(ctx, handleEvent);
    compendium_1.default.socketListeners(ctx, handleEvent);
    Scene.socketListeners(ctx, handleEvent);
    JournalEntry.socketListeners(ctx, handleEvent);
    Playlist.socketListeners(ctx, handleEvent);
    System.socketListeners(ctx);
    Module.socketListeners(ctx);
    World.socketListeners(ctx);
}

Eveyone who has an http connection, has a session so if (!sess) return; doesn’t matter. if (!usr) return; is the real show stopper. But before that we have

1
2
ctx.on('getWorldStatus', requestWorldState);
ctx.on('getSetupData', x => requestSetupData(sess, x));

getSetupData is a pretty cool data leak because it get’s us the absolute path where foundry stores it’s config. By default this is within the users home directory so it leaks the username aswell. This is what we get:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
async function requestSetupData(sess, res) {
    const loc_0 = global.config;
    const loc_1 = {
        'version': loc_0.ver,
        'systems': System.getPackages(),
        'modules': Module.getPackages(),
        'adminKey': loc_0.adminKey ? PASSWORD_SAFE_STRING : '',
        'isSetup': !![],
        'isAdmin': sess.admin,
        'options': loc_0.userOptions,
        'files': loc_0.files.clientConfig,
        'languages': Module.getDefaultLanguageOptions(),
        'worlds': World.getPackages(),
        'world': game.world ? game.world.data : null,
        'coreUpdate': await loc_0.updater.getUpdateNotification()
    };
    return res(loc_1);
}

And before you get too excited, no this doesn’t leak the adminKey, but it does tell us if there is an adminKey set. As it turns out foundry doesn’t even force you to set an admin key.

The Sauce

2021-02-16 20:39:33 +0100 +0100