Level 4: Really Unfair Battleships Game (rev)
The challenge description states that it is a 'pwn/misc' challenge, but it seemed more like yet another rev challenge.
We are given a Linux .appimage
and a Windows .exe
. The .appimage
suggests that the application is packaged somehow.
Despite this obvious clue, I decided to open the .exe
file in IDA. I then painstakingly stepped through the binary and observed that it wrote an app.asar
file in some temporary directory. This indicates that the app is a packaged Electron application and the app.asar
contains the JavaScript and HTML code for the app.
After solving the challenge, I looked a little deeper into the .appimage
format and realized that it included a --appimage-extract
flag that will automatically unpack the application, revealing the app.asar
file.
Anyway, after extracting the app.asar
file, we can view the minified JavaScript code of the app in rubg/dist/assets/index-c08c228b.js
.
After beautifying the minified code, we observe that there are some interesting functions that appear to interact with a remote HTTP server:
const Du = ee,
ju = "http://rubg.chals.tisc23.ctf.sg:34567",
Sr = Du.create({
baseURL: ju
});
async function Hu() {
return (await Sr.get("/generate")).data
}
async function $u(e) {
return (await Sr.post("/solve", e)).data
}
async function ku() {
return (await Sr.get("/")).data
}
Opening http://rubg.chals.tisc23.ctf.sg:34567/generate in a browser, we observe a string similar to this:
{
"a":[0,0,0,0,0,0,192,0,0,0,0,64,120,64,16,0,16,0,16,0,0,0,0,0,0,0,1,240,0,0,0,0],
"b":"6016998088646410950",
"c":"13099033471658947590",
"d":2954592777
}
We can make a guess that a
represents the location of the enemy ships, and the other properties are identifiers for this particular game.
We can also identify an initialization method, E
, that calls Hu
to fetch data from the /generate
endpoint. E
then calls the f
function to generate the board, which is then stored into t.value
.
function f(x) {
let _ = [];
for (let y = 0; y < x.a.length; y += 2) _.push((x.a[y] << 8) + x.a[y + 1]);
return _
}
async function E() {
i.value = 101;
let x = await Hu();
t.value = f(x), n.value = BigInt(x.b), r.value = BigInt(x.c), s.value = x.d, i.value = 1, l.value.fill(0), c.value = [], o.value = ""
}
Running the f
function on the board configuration produces t.value
of [0, 0, 0, 49152, 0, 64, 30784, 4096, 4096, 4096, 0, 0, 0, 496, 0, 0]
.
We also notice the m
function is bound to the onClick
event further down in the code:
{
ref_for: !0,
ref: "shipCell",
class: on(l.value[y - 1] === 1 ? "cell hit" : "cell"),
onClick: H => m(y - 1),
disabled: l.value[y - 1] === 1
}
We can guess that m
is the event handler for click events on the board cells.
Let's take a closer look:
function d(x) {
return (t.value[Math.floor(x / 16)] >> x % 16 & 1) === 1
}
async function m(x) {
if (d(x)) {
if (t.value[Math.floor(x / 16)] ^= 1 << x % 16, l.value[x] = 1, new Audio(Ku).play(), c.value.push(`${n.value.toString(16).padStart(16, "0")[15 - x % 16]}${r.value.toString(16).padStart(16, "0")[Math.floor(x / 16)]}`), t.value.every(_ => _ === 0))
if (JSON.stringify(c.value) === JSON.stringify([...c.value].sort())) {
const _ = {
a: [...c.value].sort().join(""),
b: s.value
};
i.value = 101, o.value = (await $u(_)).flag, new Audio(_s).play(), i.value = 4
} else i.value = 3, new Audio(_s).play()
} else i.value = 2, new Audio(qu).play()
}
The function m
takes an integer x
, which is probably the index of the cell the player clicked. Then, the function d
is called with x
. This probably checks if the cell clicked contains an enemy ship. Knowing that the board is a 16x16 grid, x
probably ranges from 0 to 255.
Next, we notice that the function $u
make a request to the /solve
endpoint, which probably will return the flag. Sent in the request body are the parameters a
, which is derived from the c
array and b
, which is s.value
. Looking back at the E
function we can observe that s.value
is just x.d
, which is 2954592777
.
The c
array is generated here:
c.value.push(`${n.value.toString(16).padStart(16, "0")[15 - x % 16]}${r.value.toString(16).padStart(16, "0")[Math.floor(x / 16)]}`)
x
is our cell index, while n
and r
are the constants obtained from the initial configuration:
n = 6016998088646410950 = 0x5380abe1d7f942c6
r = 13099033471658947590 = 0xb5c91d8a732ef406
Now we just need to find all x
such that d(x)
is true, then find the corresponding characters in n
and r
to find the correct c
array.
n = "5380abe1d7f942c6"
r = "b5c91d8a732ef406"
c = []
tval = [0, 0, 0, 49152, 0, 64, 30784, 4096, 4096, 4096, 0, 0, 0, 496, 0, 0]
def d(x):
return tval[x//16] >> (x%16) & 1 == 1
for i in range(256):
has_ship = d(i)
if has_ship:
c.append(n[15-i%16]+r[i//16])
print("".join(sorted(c)))
Result: 0307080a1438395974787d8894a8d4f4
All that's left is to send this value to the /solve
endpoint to get our flag:
import requests
a = "0307080a1438395974787d8894a8d4f4"
b = 2954592777
print(requests.post("http://rubg.chals.tisc23.ctf.sg:34567/solve", json={"a":a, "b":b}).text)
Flag: TISC{t4rg3t5_4cqu1r3d_fl4wl355ly_64b35477ac}