Flare-On 2017 Write-up: “pewpewboat.exe”

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.

This entry was posted in CTFs and tagged .

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.