Tags: csaw pwn pwnable heap-overflow heap
Rating:

Hungman is a 300 point exploitation challenge from CSAW CTF Quals 2016. On the [challenge website](https://ctf.csaw.io/challenges), we are given two files for download: the [hungman executable](https://ctf.csaw.io/static/uploads/be71ed453f93d9cc30baa65314126248/hungman) and the [libc](https://ctf.csaw.io/static/uploads/d49d14b3cd81ede59498d62795565584/libc-2.23.so) used on the challenge server. By connecting to the service, you can play a difficult game of hangman. You only get three incorrect guesses before you lose, and the word to guess appears to be random lowercase letters.

My first step in solving this challenge was to open the hungman executable with IDA Pro. The program keeps track of the player using a structure I named `hangman_game`, which looks like this:
```c
// Game structure
typedef struct hangman_game {
int score;
int name_size; // Size of name (including \0)
char* name;
bool letters_found[26];
} hangman_game;
```
There are also three global variables of interest:
```c
hangman_game* g_hangman;
char g_highscore_message[512];
int g_highscore;
```
Hungman consists of three functions, which I've named `main()`, `ask_name()`, and `play_hangman()`. The `main` function looks like this:
```c
int main(void) {
char yes_or_no;
int dev_urandom_fd;
// Disable output buffering
setvbuf(stdout, NULL, _IONBF, 0);
// Set default highscore message
memset(g_highscore_message, '\0', 512);
memcpy(g_highscore_message, "Default Highscore ", 20);
// Default highscore is 64
g_highscore = 64;
// Source of random data
dev_urandom_fd = open("/dev/urandom", O_RDONLY);
if (dev_urandom_fd == -1) {
exit(EXIT_FAILURE);
}
// Ask for the player's name and create game struct
g_hangman = ask_name();
printf("Welcome %s\n", g_hangman->name);
// Game loop
do {
// Play a game of hangman
play_hangman(g_hangman, dev_urandom_fd);
// Print high score
printf("%s ", g_highscore_message);
printf("score: %d\n", g_highscore);
// Ask to continue
printf("Continue? ");
scanf(" %c", &yes_or_no);
} while (yes_or_no != 'n');
return close(dev_urandom_fd);
}
```
There's not much of interest here. It just shows how the control flow works around the game. The `ask_name()` function is a little more interesting:
```c
hangman_game* ask_name(void) {
char* name_buffer;
hangman_game* hangman;
int bytes_read;
char* name_end;
char input_str[248];
// Ask for the player's name
write(STDOUT_FILENO, "What's your name?\n", 18);
memset(input_str, '\0', 248);
bytes_read = read(STDIN_FILENO, input_str, 247);
// Ignore everything but the first line of input
name_end = strchr(input_str, '\n');
if (name_end != NULL) {
*name_end = '\0';
}
// Allocate just enough space to hold the name entered by the user
name_buffer = (char*)malloc(bytes_read);
// Allocate more than enough space to hold the game structure
hangman = (hangman_game*)malloc(128);
// Initialize the fields of the game structure and return it
memset(hangman, '\0', 128);
hangman->name = name_buffer;
hangman->name_size = bytes_read;
memcpy(hangman->name, input_str, bytes_read);
return hangman;
}
```
As you can see, `hangman->name` is allocated to hold just enough space for the name the player initially enters. This will be important very soon. Finally, `play_hangman()` is where all the fun happens. Here is the reversed C code for it:
```c
void play_hangman(hangman_game* hangman, int dev_urandom_fd) {
char guess;
char yes_or_no;
int tries_remaining;
int letters_found;
int name_size;
int last_counter;
int bytes_read;
uint64_t i;
uint64_t j;
char* puzzle_word;
char* name_buffer;
char* end_of_name;
// Allocate space for the puzzle word
puzzle_size = hangman->name_size;
puzzle_word = (char*)malloc(puzzle_size);
if (puzzle_word != NULL) {
// Read random data for the puzzle
read(dev_urandom_fd, puzzle_word, puzzle_size);
// Each puzzle character is a random undercase letter
for (i = 0; i < puzzle_size - 1; i++) {
puzzle_word[i] ^= hangman->name[i];
puzzle_word[i] = (uint8_t)puzzle_word[i] % 26 + 'a';
}
// Variables that track gameplay progress
tries_remaining = 3;
letters_found = 0;
guess = '_';
// Loop once for each guess
while (tries_remaining > 0) {
// Print out the puzzle word, with blanks (_) where letters
// haven't yet been guessed.
for (i = 0; i < puzzle_size - 1; i++) {
if (hangman->letter_found[puzzle_word[i] - 'a']) {
write(STDOUT_FILENO, &puzzle_word[i], 1);
}
else {
write(STDOUT_FILENO, "_", 1);
}
}
// Print newline after puzzle word and read next guess
write(STDOUT_FILENO, "\n", 1);
scanf(" %c", &guess);
// If the guess isn't lowercase, it's definitely wrong
if (guess >= 'a' && guess <= 'z') {
// If the player already guessed this letter,
// that means this guess is wrong.
if (hangman->letter_found[guess - 'a']) {
puts("nope");
--tries_remaining;
}
else {
// Check if the guess is correct
last_counter = letters_found;
for (j = 0; j < puzzle_size - 1; j++) {
if (puzzle_word[j] == guess) {
// Mark this letter as found and increment
// correct letter counter.
hangman->letter_found[guess - 'a'] = true;
++letters_found;
}
}
// Check if the guess was incorrect
if (last_counter == letters_found) {
--tries_remaining;
}
// Check if this guess just solved the puzzle
if (letters_found >= puzzle_size - 1) {
hangman->score += 8 * (puzzle_size - 1);
goto gameover;
}
}
}
else {
// Guess wasn't a lowercase letter
puts("nope");
--tries_remaining;
}
}
// Formula for counting the score for losing
hangman->score += 0.25 * (puzzle_size - 1) * letters_found;
gameover:
// Check if the player got a high score
if (hangman->score > g_highscore) {
// Ask the player to change names
puts("High score! change name?");
scanf(" %c", &yes_or_no);
if (yes_or_no == 'y') {
// Allocate space to read the new name
name_buffer = (char *)malloc(248);
memset(name_buffer, '\0', 248);
// [BUG1]
// Read in the new name and update the stored size
// in the game structure
bytes_read = read(STDIN_FILENO, name_buffer, 248);
hangman->name_size = bytes_read;
// Cut the name string at the first newline
end_of_name = strchr(name_buffer, '\n');
if (end_of_name != NULL) {
// [BUG2]
*end_of_name = '\0';
}
// [BUG3]
// Copy new name into the game structure's name buffer
memcpy(hangman->name, name_buffer, bytes_read);
free(name_buffer);
}
// [BUG4]
// Update high score message and value
snprintf(g_highscore_message, 512, "Highest player: %s",
hangman->name);
g_highscore = hangman->score;
}
// Clear game state
memset(hangman->letter_found, false, 26);
free(puzzle_word);
}
}
```
This function is much larger, and contains multiple bugs. The first one I noticed was the one I marked as `[BUG1]`. The issue here is that the call to `read()` may read up to 248 bytes which is the total size of the allocated name buffer. This could cause the name buffer to not be NUL-terminated. This bug is the cause for `[BUG2]` and `[BUG4]`.
`[BUG2]` is an OOB heap write bug that allows replacing a `0x0a` byte outside of the name buffer with a `0x00` byte. That could potentially be exploitable, though that would be very difficult and prone to failure.
`[BUG4]` is an infoleak bug allows leaking adjacent heap data (at least until the next `0x00` or `0x0a` byte).
`[BUG3]` is the most interesting bug in this case. It is a fairly standard heap buffer overwrite bug. The bug is the fact that the buffer is never resized after it is allocated in `ask_name()` to hold just enough space for the name that the user entered initially. So, by first setting the name to something small and then later changing to a longer name, this will write data outside of this object in the adjacent heap space.
Now that we know that there is a heap buffer overwrite vulnerability, it's time to see how the allocations are performed to see what we can corrupt using this bug. The first allocation performed is the name buffer (`g_hangman->name`). After this, the game structure (`g_hangman`) is allocated. By testing with [gdb-peda](https://github.com/longld/peda), I verified that `g_hangman` is always allocated directly after `g_hangman->name` (with some padding in between). Due to this ordering of allocations, triggering the heap overwrite from `[BUG3]` will allow us to corrupt the `hangman_game` structure.
I decided to focus on corrupting `g_hangman->name`, as we can both read and write the data pointed to by this pointer. I gained an arbitrary memory read primitive by setting `g_hangman->name` to the address I want to read from and reading the highscore player's name, with the caveat that we can't read NUL bytes because the message is created with `snprintf()`. This also becomes an arbitrary memory write primitive by getting a highscore again after setting `g_hangman->name` to the address to write. The name you send is written to that address.
My goal is to overwrite a GOT function pointer with the address of `system()` and call the overwritten function with `/bin/bash -i\0` as the first parameter to get a shell. Therefore I needed to pick a GOT function where I have control over the first argument. I chose to use `snprintf()`, because it is called in `play_hangman()` after the `memcpy()` call, and it is given `g_highscore_message` as its first parameter. Since I can have an arbitrary memory write primitive, I can control the contents of this buffer.
The first step of my exploit is to leak the address of `snprintf()` in the GOT. I compare this to the address of `snprintf()` in the provided libc to determine the ASLR slide of standard library functions. After this, I replace the GOT entry for `snprintf()` with the address of `system()`, adjusted for ASLR. I use a single memory write to do both this and overwrite `g_highscore_message`, as this buffer is just after the GOT in the executable. This means I'm going to be overwriting every entry in the GOT after `snprintf()`, so I try to set those to the correct locations to prevent any early crashes. After the end of the GOT, I just write zeros until I get to `g_highscore_message`, which I fill with `/bin/bash -i\0`. After this, the `play_hangman()` function gets to this statement:
```c
snprintf(g_highscore_message, 512, "Highest player: %s",
hangman->name);
```
At this point, we've replaced `snprintf()` with `system()` and filled `g_highscore_message` with `/bin/bash -i\0`, so this code will actually do the following:
```c
system("/bin/bash -i");
```
And this completes the exploit. Running it gives me a shell, which I use to read the flag: `flag{this_looks_like_its_a_well_hungman}`. The Python script I wrote using [Gallopsled's pwntools](https://github.com/Gallopsled/pwntools) can be viewed here: [hungman.py](https://ghostbin.com/paste/bxf7v)
