Flare-On 2017 Challenge #5 — pewpewboat.exe
As usual, the first thing to do when tackling the challenge is to run the binary first, to see what it does. You will soon learn that it’s not actually a Windows executable, but rather a 64-bit Linux ELF.
$ ./pewpewboat.exe
Loading first pew pew map...
1 2 3 4 5 6 7 8
_________________
A |_|_|_|_|_|_|_|_|
B |_|_|_|_|_|_|_|_|
C |_|_|_|_|_|_|_|_|
D |_|_|_|_|_|_|_|_|
E |_|_|_|_|_|_|_|_|
F |_|_|_|_|_|_|_|_|
G |_|_|_|_|_|_|_|_|
H |_|_|_|_|_|_|_|_|
Rank: Seaman Recruit
Welcome to pewpewboat! We just loaded a pew pew map, start shootin'!
Enter a coordinate:
So this is a Battleship game. Playing manually for a bit, I see the “ships” form up in the shape what looked like a letter. Hmm could this be the flag?
It’s now time to read the code.
Static Analysis
Looking at the main
function, the following (cleaned up and renamed) decompiled output is obtained:
v3 = time(0LL); srand(v3); gameState = malloc(576uLL); v9 = 'L'; v10 = 'o'; v11 = 'a'; v12 = 'd'; .... (snip) .... v36 = '.'; v37 = '\n'; v38 = 0; printf(format, &v9); seed = genInitialSeed(); for ( i = 0; i <= 99; ++i ) { memcpy(gameState, (char *)&magicTable + 576 * i, 576uLL); decryptGameData((char *)gameState, 576u, seed); if ( (unsigned int)mainProgLoop((__int64)gameState) != 1 ) break; seed = *((_QWORD *)gameState + 2); // next seed comes from solve gameState } free(gameState);
Just a quick note that v9
, v10
till v38
are actually part of a char[]
string, but IDA Pro doesn’t know about it.
The function genInitialSeed (0x403C85)
generates the initial seed that is used to “decrypt” the game data. I’ll leave you to read it, but it essentially MD5s the string "loading... %d%%"
in a loop, incrementing %d
each time.
You will then see a for-loop of 99 levels, with each iteration copying the game data from a table of 576 bytes, then decrypting it with seed
:
memcpy(gameState, &magicTable + 576 * i, 576uLL); decryptGameData(gameState, 576u, seed); if ( mainProgLoop(gameState) != 1 ) break; seed = *(gameState + 2); // next seed comes from solved gameState
Control then goes to the mainProgLoop
, which prompts for user input performs hit/miss checking. After that round is complete, seed
will be set to the solved state, ready to decrypt the subsequent round.
Main Loop
Let’s now look at the mainProgLoop (0x403C05)
function, which is where the program reads user input and figures out if it’s a hit or miss.
prevInput = 0LL; v2 = 0; for ( i = 0; i <= 63; ++i ) { clearScreen(); drawGrid(state); v2 = process((void *)state, prevInput); if ( (signed int)v2 > 0 ) break; prevInput = *(_QWORD *)(state + 8); getInput(state); } return v2;
From the drawGrid (0x403263)
function, we can see that our entered coordinates and the actual position of the “ships” are both stored in the gameState
. This code will check the correct ship coordinates against the user’s entered coordinates, and only if there is a match, an X is drawn.
printf(format, &colHeader); for ( row = 0; row <= 7; ++row ) { printf("%c ", (row + 'A')); for ( col = 0; col <= 7; ++col ) { putchar('|'); mask = 1LL << (8 * row + col); if ( mask & *(gameState + 8) && mask & *gameState ) printf(format, &v5); else if ( col <= 7 ) putchar('_'); } puts("|"); }
The mask
is generated by 1 << 8 * row + col
, meaning A1
would be 0x1
and A2
is 0x2
, B1
is 0x100
, etc. The 64-bit integer stored at gameState + 0
represents all the actual ship positions and the user’s input so far is stored in gameState + 8
, also 64-bit. Therefore, a set bit in this mask represents a ship at the corresponding row/col position.
We move on to the process (0x04038D6)
function, which processes input from the user, checks for hits and advance the game state as necessary. In this function, you will find references to more stuff inside the game state, such as your rank (e.g. “Seaman Recruit”):
v7 = 'R'; v8 = 'a'; v9 = 'n'; v10 = 'k'; v11 = ':'; v12 = ' '; v13 = '%'; v14 = 's'; v15 = '\n'; v16 = '\n'; v17 = 0; printf(&v7, gameState + 30);
Also, you will find the “level welcome” message at gameState + 62
. In the first level, this string reads “Welcome to pewpewboat! We just loaded a pew pew map, start shootin’!”.
Lastly, we have come to the getInput (0x40377D)
function, called at the end of the mainProgLoop
:
if ( fgets(input, 17, stdin) ) { c = (char)(input[0] & 0xDF) - 'A'; n = input[1] - '1'; if ( c < 0 || c > 7 || n < 0 || n > 7 ) { sub_403411(input); } else { *(_QWORD *)(gameState + 8) |= 1LL << (8 * c + n); // forms a bitmask *(_BYTE *) (gameState + 28) = input[0] & 0xDF; // input coord letter *(_BYTE *) (gameState + 29) = input[1]; // input coord num } }
From the code, you can see a check to make sure that letter c
and number n
are within range (0 – 7). Once that happens, the mask
will be created, same as above, and stored into gameState + 8
. The corresponding letter and number are placed into gameState + 28
and +29
, respectively.
Curiously, if the c
and n
checks are out of range, it calls another function, sub_403411
. What does this function do? For now, it doesn’t look too important, so let’s move on first.
So once we complete all levels we should get the flag… or will we?
Solving Game Levels
If there is going to be 99 levels as the for-loop suggests, I will not sit down and play each level by hand. We need a smarter solution.
I have been using Frida for some time now. If you haven’t heard, it is basically a dynamic instrumentation framework, allowing you to modify code and data of a process programmatically. It can be controlled via a Javascript script, and has a pretty powerful API.
We can simulate user input by simply patching the part where fgets
is called, in this case the getInput
function. We shall simulate whatever getInput
does by writing to the same locations in the gameState
and return. We can get the correct “ship” coordinates from the 64-bit integer mask at gameState + 0
.
// patch getInput Interceptor.replace(getInput, new NativeCallback(function(state) { // iterate through all set positions, then "input" them var positions = Memory.readU64(state); positions = positions.shr(coord); while (positions.compare(0) != 0) { if (positions.and(1).compare(1) == 0) break; positions = positions.shr(1); coord++; } var bitmask = Memory.readU64(state.add(8)); var newbit = uint64(1).shl(coord); bitmask = bitmask.or(newbit); Memory.writeU64(state.add(8), bitmask); // coordinates var c = 'A'.charCodeAt(0) + (coord >> 3); var n = '1'.charCodeAt(0) + (coord & 7); Memory.writeU8(state.add(28), c); Memory.writeU8(state.add(29), n); if (debug) console.log('guess: ' + String.fromCharCode(c) + String.fromCharCode(n)); coord++; if (coord > 077) { coord = 0; } return 0; }, 'int', ['pointer']));
After solving one level, you will find that you are prompted for this:
NotMd5Hash("AJEE") >
This prompt basically tries to slow you down by asking you to compute an MD5 hash of the given string. Let’s patch that out as well, and while we are at it, the clearScreen
function as well:
var clearScreen = ptr(0x4031E1); var notMd5Prompt = ptr(0x403530); Interceptor.replace(clearScreen, new NativeCallback(function() {}, 'int', [])); Interceptor.replace(notMd5Prompt, new NativeCallback(function() {}, 'int', []));
And as we expected, the “ship” positions do indeed form a letter. Let’s add a function that helps to print all these letters. There is also bound to be something in the gameState
messages, so let’s dump the entire gameState
for each level as well.
Interceptor.attach(mainProgLoop, { onEnter: function(args) { coord = 0; var state = args[0]; var gameState = Memory.readU64(state); console.log('level ' + level + ' state: ' + gameState.toString(16)); console.log(drawMap(gameState)); var f = new File('pew-dump-' + level + '.bin', 'wb'); var data = Memory.readByteArray(state, 576); f.write(data); f.close(); level++; }, });
When the script is pieced together, running frida
will automatically solve all levels:
frida -l pew.js -f pewpewboat.exe -o pew.log --no-pause
As expected, the final message on level 10 reads:
Rank: Congratulation!
Aye!PEWYouPEWfoundPEWsomePEWlettersPEWdidPEWya?PEWToPEWfindPEWwhatPEWyou’rePEWlookingPEWfor,PEWyou’llPEWwantPEWtoPEWre-orderPEWthem:PEW9,PEW1,PEW2,PEW7,PEW3,PEW5,PEW6,PEW5,PEW8,PEW0,PEW2,PEW3,PEW5,PEW6,PEW1,PEW4.PEWNextPEWyouPEWletPEW13PEWROTPEWinPEWthePEWsea!PEWTHEPEWFINALPEWSECRETPEWCANPEWBEPEWFOUNDPEWWITHPEWONLYPEWTHEPEWUPPERPEWCASE.
Thanks for playing!
When you read “between the lines”, the message is:
Aye! You found some letters did ya? To find what you’re looking for, you’ll want to re-order them: 9, 1, 2, 7, 3, 5, 6, 5, 8, 0, 2, 3, 5, 6, 1, 4. Next you let 13 ROT in the sea! THE FINAL SECRET CAN BE FOUND WITH ONLY THE UPPER CASE.
This is where the printed maps come in handy:
level 0 state level 1 state
. . . . . . . . . . . . . . . .
. . . @ @ @ @ . . . . @ . . . @
. . . @ . . . . . . . @ . . . @
. . . @ . . . . . . . @ . . . @
. . . @ @ @ @ . . . . @ @ @ @ @ o o o
. . . @ . . . . . . . @ . . . @
. . . @ . . . . . . . @ . . . @
. . . . . . . . . . . . . . . .
Pieceing the letters in the specified order gives you: ohGJuRERvFGuREHz
.
Transforming the string using ROT-13 then gets you: buTWhEREiSThERUm
.
But this doesn’t look like a flag (even with uppercase)!
The Last Bit
If you recall, the getInput
function had a check for valid input, but called sub_403411
if it failed:
if ( fgets(input, 17, stdin) ) { c = (char)(input[0] & 0xDF) - 'A'; n = input[1] - '1'; if ( c < 0 || c > 7 || n < 0 || n > 7 ) { sub_403411(input); } else ...
The string matches the condition and it is exactly 16 characters (+ NULL
= 17). Run the game once again and provide this string when asked for the coordinates:
Welcome to pewpewboat! We just loaded a pew pew map, start shootin'!
Enter a coordinate: BUTWHEREISTHERUM
very nicely done! here have this key: y0u__sUnK_mY__P3Wp3w_b04t@flare-on.com
As usual, you can download my pew.js
Frida script here.