Remote Challenge Deployment
Writing pwn challenges is tricky for a few reasons. Chief among them is that when someone pwns our system we don’t actually want them to own it! This is how we set up our dockers.
The technologies:
- Docker Compose
- Xinetd
- Docker Engine
Using these 3 technologies allow us to do the following:
- Have our challenges automatically start when connected to
- Sandbox our challenges so users with shell can’t destroy the whole challenge box
- Distribute challenge binaries/libraries built on the docker so there is minimal discrepancy between local and remote
- Handle many connections concurrently
- Do everything easily and efficiently
Note that for all of the below I assume the directory structure:
-+ example +-+ dist | +- Dockerfile | +- Dockerfile-build | +- docker-compose.yml | +- example.xinetd | +- wrapper.sh +-+ src +- example.c +- example.Makefile
There are other things that need to be in a directory to distribute a challenge but this is the minimum for deployment.
How do we set this up? First, a dockerfile.
# We can build from whatever version # we want. 20.04 is most typical now. # FROM ubuntu:18.04 FROM ubuntu:20.04 # Install any software needed to # build the challenge RUN apt-get update RUN apt-get install -y xinetd RUN apt-get update RUN apt-get install -y build-essential RUN apt-get install -y gcc-multilib # Change example to the name of your challenge. ENV USER example WORKDIR /home/$USER RUN useradd $USER # This adds the critical files. # wrapper.sh wraps the executable by # `cd`ing to the right place COPY ./deploy/wrapper.sh /home/$USER/ # The xinetd configuration provides run options # but is very boilerplate. See below. COPY ./deploy/$USER.xinetd /etc/xinetd.d/$USER # This assumes a C file but works perfectly OK # with go/C++/rust, just install the right compiler COPY ./src/$USER.c /home/$USER/ # This makefile provides the build and outputs # the challenge binary (see below) COPY ./src/$USER.Makefile /home/$USER/Makefile # We don't want to forget the flag! COPY ./solve/flag.txt /home/$USER/flag.txt # This runs make in the challenge dir RUN make -C /home/$USER/ # Set permissions. Be *VERY* careful # about changing this! RUN chown -R root:$USER /home/$USER RUN chmod -R 550 /home/$USER RUN chmod -x /home/$USER/flag.txt RUN touch /var/log/xinetdlog # Whatever port you configured in xinetd. # PROBABLY this should stay 1337. Just change # The passthrough port in docker-compose.yml below. EXPOSE 1337 # Start the container by starting xinetd and outputting # the xinetd log for debugging. CMD service xinetd start && sleep 2 && tail -f /var/log/xinetdlog
To go along with this template dockerfile, we typically use a docker-compose file:
version: "3.7" services: example: container_name: example build: dockerfile: ./deploy/Dockerfile context: ../ logging: driver: "json-file" ports: - "6666:1337" example-build: container_name: example-build build: dockerfile: ./deploy/Dockerfile-build context: ../ logging: driver: "json-file" volumes: - build:/home/example/build volumes: build: name: example-build driver: local driver_opts: type: none device: /home/b01lers/some-path-to-output-the-binary o: bind
The only things we have to change here are the name, example
, and the device path. This docker-compose file will start two containers, one to actually run the program with xinetd, another to just build it in the same environment and exit. This is important because building it on your own machine may not be exactly the same as building on the docker!
The device path is where the container will output the built challenge binary.
Now, we also need a build dockerfile. This looks like the below, typically:
FROM ubuntu:20.04 # Install dependencies RUN apt-get update RUN apt-get install -y build-essential RUN apt-get install -y gcc-multilib # Change example to the name of your challenge. # Set up the user ENV USER example WORKDIR /home/$USER RUN useradd $USER # Add source files COPY ./src/$USER.c /home/$USER/ COPY ./src/$USER.Makefile /home/$USER/Makefile # Copy the binary AND any libraries it depends on # into the build output directory RUN mkdir /home/$USER/build RUN make -C /home/$USER/ && cp /home/$USER/$USER /home/$USER/build/$USER && sh -c "ldd /home/$USER/build/$USER | grep '=>' | cut -d' ' -f3 | xargs -I '{}' cp -L -v '{}' /home/$USER/build/" RUN chown -R root:$USER /home/$USER RUN chmod -R 550 /home/$USER CMD sleep 5
The final pieces of the puzzle are the wrapper and xinetd configuration. The xinetd configuration:
service example { disable = no socket_type = stream protocol = tcp wait = no log_type = FILE /var/log/xinetdlog log_on_success = HOST PID EXIT DURATION log_on_failure = HOST # Change the username to the name of your challenge user = example bind = 0.0.0.0 # Change the server to your executable server = /home/example/wrapper.sh type = UNLISTED # Change the PORT to your desired challenge port port = 1337 per_source = 10 }
This provides ratelimiting (again, change example
to the challenge name) and super serving the challenge. Finally, the wrapper:
<syntaxhighlight lang="sh">#!/bin/bash
cd /home/example/ && ./example</syntaxhighlight>
What this does is every time xinetd accepts a connection on port 1337, it will run the script (which does a cd
and runs the challenge) as if its stdin and stdout were the socket. It abstracts and simplifies the entire process significantly.
Finally, you'll want a service file. This goes in /etc/systemd/system/
:
[Unit] Description=chalname [Service] WorkingDirectory=/home/b01lers/ctf-name-year/pwn/chalname/deploy ExecStart=/usr/bin/docker-compose up --project chalname --build [Install] WantedBy=multi-user.target