picoCTF
Babygame01 [100 pts]
Get the flag and reach the exit. Welcome to BabyGame! Navigate around the map and see what you can find! The game is available to download here. There is no source available, so you’ll have to figure your way around the map. You can connect with it using nc saturn.picoctf.net [port #]
.
We’re given a 32-bit ELF binary. checksec shows the following:
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
I decompiled the binary with Ghidra. After a bit of renaming, I got this for main()
:
/* WARNING: Function: __x86.get_pc_thunk.bx replaced with injection: get_pc_thunk_bx */
/* WARNING: Globals starting with '_' overlap smaller symbols at the same address */
undefined4 main(void)
{
int move;
undefined4 uVar1;
int in_GS_OFFSET;
int player_x;
int player_y;
char win_condition;
undefined map [2700];
int local_14;
undefined *local_10;
local_10 = &stack0x00000004;
local_14 = *(int *)(in_GS_OFFSET + 0x14);
init_player(&player_x);
init_map(map,&player_x);
print_map(map,&player_x);
signal(2,sigint_handler);
do {
do {
move = getchar();
move_player(&player_x,(int)(char)move,map);
print_map(map,&player_x);
} while (player_x != 0x1d);
} while (player_y != 0x59);
puts("You win!");
if (win_condition != '\0') {
puts("flage");
win();
fflush(_stdout);
}
uVar1 = 0;
if (local_14 != *(int *)(in_GS_OFFSET + 0x14)) {
uVar1 = __stack_chk_fail_local();
}
return uVar1;
}
First, let’s break down what’s happening in this program. Essentially, upon starting, we’re provided a map of where we can travel and given a certain set of moves that can be found in the program’s decompilation:
- wasd (self-explanatory)
- p (instantly solve, i.e. get to the X in the map)
- l (change the player icon displayed in the map)
There is a win() function that prints out the flag, and it is called in main() once the following conditions are satisfied:
- The X is reached
- Some variable on the stack (I named it win_condition) cannot be a null byte.
Notably, in the player_init() function, the win_condition variable appears to have been set to a null byte (since it’s at an offset of 2 on the stack compared to the player variable itself). Additionally, it is located on the stack right after the map, which can be found in Ghidra at the start of main():
**************************************************************
* FUNCTION *
**************************************************************
undefined main(undefined1 param_1)
undefined AL:1 <RETURN> XREF[1]: 080497e1(W)
undefined1 Stack[0x4]:1 param_1 XREF[1]: 08049764(*)
undefined4 EAX:4 move XREF[1]: 080497e1(W)
undefined4 Stack[0x0]:4 local_res0 XREF[2]: 0804976b(R),
080498a1(*)
undefined1 Stack[-0x10]:1 local_10 XREF[1]: 0804989b(*)
undefined4 Stack[-0x14]:4 local_14 XREF[2]: 0804978a(W),
0804988a(R)
undefined1[270 Stack[-0xaa0 map XREF[4]: 080497a5(*),
080497be(*),
080497f6(*),
08049817(*)
undefined1 Stack[-0xaa4 win_condition XREF[1]: 0804984e(R)
undefined4 Stack[-0xaa8 player_y XREF[1]: 08049831(R)
undefined4 Stack[-0xaac player_x XREF[6]: 0804978f(*),
0804979e(*),
080497b7(*),
080497fe(*),
08049810(*),
08049826(R)
undefined1 Stack[-0xaad local_aad XREF[2]: 080497e6(W),
080497ec(R)
So, essentially, we need to find a way to overwrite the win_condition variable.
Now, the move_player()
function turns out to be crucial for this exact purpose:
/* WARNING: Function: __x86.get_pc_thunk.bx replaced with injection: get_pc_thunk_bx */
void move_player(int *player,char move,int map)
{
int new_tile_char;
if (move == 'l') {
new_tile_char = getchar();
player_tile = (undefined)new_tile_char;
}
if (move == 'p') {
solve_round(map,player);
}
*(undefined *)(*player * 0x5a + map + player[1]) = 0x2e;
if (move == 'w') {
*player = *player + -1;
}
else if (move == 's') {
*player = *player + 1;
}
else if (move == 'a') {
player[1] = player[1] + -1;
}
else if (move == 'd') {
player[1] = player[1] + 1;
}
*(undefined *)(*player * 0x5a + map + player[1]) = player_tile;
return;
}
Because the map is basically stored as a 1-d array, with its rows and columns calculated with a bit of math, finding a specific tile’s location on the stack is calculated with this:
*player * 0x5a + map + player[1]
However, notice how, in all of the move options, none of them actually set bounds on player movement! Meaning, we can go beyond the supposed “limit” of the player’s position of (89, 29), as well as go to the negatives. This can be confirmed with a bit of testing of going out of bounds of the map in the program.
Importantly, the calculated tile location is set to a non-null byte, either the player_tile variable or just 0x2e. So, with the move_player function, if we can set the calculated tile location to the location of the win_condition variable, we can overwrite the null byte with some other byte and then get to the X (i.e. input “p”) to get the flag!
Conveniently, win_condition, as aforementioned, is located directly after map on the stack. Essentially, it is before the stack in the memory by an offset of 0x4 bytes, meaning that, if we set player[1] to -4, we can get to its location!
So, since the player starts at (4, 4), the sequence wwwwaaaaaaaap
should get our flag!
wwwwaaaa
sets the player location to (0, 0)- The following
aaaa
sets the player location to (0, -4) - The
p
solves the program, i.e. moves the player to the X, and thus we get the flag!
Connect to the service and send the sequence for the flag:
picoCTF{gamer_m0d3_enabled_8985ce0e}