← Blog · 2026-07-13 · 12 min read
Trial by Fire (2): Escaping a Sandbox to Light the Campfire Blue
In the previous post, we showed how we gained code execution inside the campfire-controller server. Unfortunately, that wasn’t enough. The server runs inside a Linux sandbox that prevents direct access to the real fire controller, and the only interface it exposes refuses to accept the color BLUE.
In this post, we’ll dig into the sandbox itself, uncover the subtle mistakes in its design, and chain them together into a full sandbox escape that finally turns the campfire blue.
The Sandbox
After achieving code execution, contestants will quickly discover that writing “BLUE” to the control file doesn’t work. The campfire-controller runs inside a Linux sandbox that restricts what the process can actually do, and more importantly, what files it can access.
The Challenge
To achieve “FLAMES BLUE”, contestants must escape the sandbox
and write directly to /fire_color_controller. The namespace isolation,
restricted filesystem, and UID mapping all stand in the way. Simply having code execution
inside the Go server process is not enough — you need to break out of the container’s
carefully constructed jail.
Architecture Overview
The production container (Dockerfile.prod) sets up a layered
isolation architecture:
- A
sandboxuser is created with UID 2000 - The
/fire_color_controllerFIFO is created with mode0600(owner read/write only), owned by root - The container entrypoint launches several processes before starting the sandbox
When the container starts (jail_entrypoint_prod.sh), it spawns:
- fire_color_reader.py: Reads from
/fire_color_controllerand outputs the flame status - control_server.py: Acts as a gatekeeper between the sandbox and the fire color controller
- ns_root: The sandbox supervisor that sets up namespaces and runs the Go server
The Control Server
The control_server.py script is the critical chokepoint. It
creates a FIFO at /control (owned by UID 2000) and listens for color commands.
When it receives input, it checks against an allowlist:
VALID = ("YELLOW", "ORANGE", "RED")
Only these three colors are forwarded to /fire_color_controller.
The script explicitly excludes “BLUE” — even if you achieve code execution and
write “BLUE” to /control, it will be silently dropped.
The ns_root Sandbox
The ns_root binary (ns_root.c) implements a custom
sandbox using Linux namespaces. It:
- Creates new user, PID, and mount
namespaces using
clone() - Sets up UID mapping: UID 0 inside the sandbox maps to UID 2000 outside (defined in
common.hasREAL_UID) - Prepares a minimal root filesystem at
/newrootwith only essential files bind-mounted - Bind-mounts
/controlinto the sandbox so the Go server can communicate withcontrol_server.py - Pivots root into the new filesystem and executes the Go server
The Go server runs as UID 0 inside the user namespace, which corresponds to UID 2000 in the host namespace. This means:
- The server can write to
/control(owned by UID 2000) - The server cannot write to
/fire_color_controller(owned by root, mode 0600) - Even with arbitrary code execution inside the sandbox, you’re still UID 2000 from the kernel’s perspective
The Monitors
The ns_root supervisor doesn’t just launch the sandbox and
walk away. It spawns two monitor processes every 3 seconds that enter the sandbox’s
namespaces to perform surveillance and enforcement:
Process Monitor (proc_monitor.c)
The process monitor joins the sandbox’s PID and mount
namespaces via setns(), then walks /proc to list all
running processes and their command lines. This information is piped back to a drainer
process in the parent namespace for logging.
Security Monitor (security_monitor.c)
The security monitor is more aggressive. It also joins the sandbox’s
PID and mount namespaces, then recursively walks the entire filesystem
looking for shell scripts (files ending in .sh). When it finds one, it deletes
it.
static int remove_sh_cb(const char *fpath, const struct stat *sb, int typeflag, struct FTW *ftwbuf) {
// ...
if (!should_remove_sh(fpath, sb))
return 0;
setresgid_must(REAL_GID, REAL_GID, 0, "setresgid(REAL_GID)");
setresuid_must(REAL_UID, REAL_UID, 0, "setresuid(REAL_UID)");
unlink(fpath);
setresuid_must(0, 0, 0, "setresuid(0)");
setresgid_must(0, 0, 0, "setresgid(0)");
return 0;
}
This creates an interesting dynamic: even if you achieve code execution and write a shell script to disk, the security monitor will delete it within seconds.
A TOCTOU Red Herring
Astute readers might notice a potential time-of-check-time-of-use (TOCTOU)
vulnerability in the security monitor. The code checks whether a file is a symbolic link in
should_remove_sh():
static int should_remove_sh(const char *path, const struct stat *sb) {
// ...
if (S_ISLNK(sb->st_mode))
return 0;
return 1;
}
Between this check and the subsequent unlink() call, an attacker
could theoretically replace the regular file with a symbolic link pointing to a sensitive
target like /fire_color_controller. If the monitor followed the symlink during
deletion, it could delete arbitrary files.
However, this vulnerability is not exploitable. Notice that
the monitor drops privileges to UID/GID 2000 before calling unlink(). Since
/fire_color_controller is owned by root with mode 0600, the
unprivileged unlink() call would simply fail with EACCES. The
privilege drop is a defense-in-depth measure that neutralizes this entire class of attacks —
even if you win the race, you can only delete files that the sandbox user already has
permission to delete.
Exploiting the Process Monitor
While the security monitor is hardened against privilege escalation, the
process monitor has a subtle vulnerability. Looking at proc_monitor.c:
static void setup_monitor_stderr(void) {
if (getenv("DEBUG") != NULL)
return;
int dev_null_fd = open_must("/dev/null", O_WRONLY, 0);
dup2_must(dev_null_fd, STDERR_FILENO, "dup2(/dev/null->stderr)");
close(dev_null_fd);
}
The monitor opens /dev/null to silence its stderr output.
Crucially, because the monitor joins the sandbox’s mount namespace, it
sees the same filesystem as the Go server — including any modifications we make.
Since the sandbox runs inside a user namespace, processes
within it have CAP_SYS_ADMIN and can perform mount operations. This allows us to
replace /dev entirely:
<action>/bin/busybox mkdir /tmp/my_dev</action>
<action>/bin/busybox mount --bind /tmp/my_dev /dev</action>
The first command creates an empty directory. The second bind-mounts it over
/dev, effectively hiding the real device nodes. Now when the process monitor
enters our mount namespace and opens /dev/null, it’s opening a path we
control — because /dev is now just our empty /tmp/my_dev
directory.
We can create a symbolic link at /dev/null pointing to any file we
want, and the process monitor will open it for writing. But where do we point it? We’re
still confined to the sandbox’s mount namespace — /fire_color_controller
isn’t even visible from inside the jail.
Escaping via pivot_root
The answer lies in a subtle bug in the sandbox setup. Looking at
child_main.c, the sandbox calls pivot_root() to change the root
filesystem, then unmounts the old root:
static void pivot_to_newroot(const char *newroot) {
mount_must(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL,
"mount(/ rprivate)");
mount_must(newroot, newroot, NULL, MS_BIND | MS_REC, NULL,
"bind-mount newroot to itself");
char put_old[PATH_MAX];
snprintf(put_old, sizeof(put_old), "%s/%s", newroot, "oldroot");
mkdir_if_missing(put_old, 0755);
pivot_root_must(newroot, put_old, "pivot_root");
umount2_must("/oldroot", MNT_DETACH,
"umount2(/oldroot, MNT_DETACH)");
rmdir_must("/oldroot", "rmdir(/oldroot)");
child_log("pivot_root complete. old root detached.");
}
Notice what’s missing? The pivot_root(2) man page warns:
pivot_root() does not change the caller’s current working directory (unless it is on the old root directory), and thus it should be followed by a chdir("/") call.
The sandbox never calls chdir("/") after
pivot_root(). This means the Go server’s current working directory still
points to a location in the original mount namespace — outside the jail!
We can leverage this through procfs. The /proc/[pid]/cwd symlink
points to a process’s current working directory. Since the server runs as PID 1 inside
the PID namespace, /proc/1/cwd gives us a handle to a directory outside our mount
namespace jail. From there, we can traverse to any file on the real filesystem:
<action>/bin/busybox ln -s /proc/1/cwd/../../../fire_color_controller /dev/null</action>
This creates a symbolic link at /dev/null that points to
/proc/1/cwd/../../../fire_color_controller. When the process monitor opens
/dev/null, it follows this symlink chain:
/dev/null→/proc/1/cwd/../../../fire_color_controller/proc/1/cwdresolves to the server’s cwd (outside the jail)../../../fire_color_controllertraverses up to the container’s root and reaches the real FIFO
The process monitor, running as root in the host user
namespace, successfully opens /fire_color_controller for writing — bypassing both
the mount namespace isolation and the file permissions that would block UID 2000.
Controlling the Output
We’ve redirected where the process monitor writes, but what does it
actually write? Looking at proc_monitor.c:
static void list_proc_comm(int out_fd) {
// ...
while ((de = readdir(d)) != NULL) {
// ...
char comm_path[256];
snprintf(comm_path, sizeof(comm_path), "/proc/%s/cmdline", n);
// ... read cmdline and write to out_fd ...
char line[512];
int len = snprintf(line, sizeof(line), "%s: %s\n", n, buf);
write_all(out_fd, line, (size_t)len, "procmon write");
}
}
The monitor iterates over all processes, reads each process’s
/proc/[pid]/cmdline, and writes it to the output file descriptor (which is now
our symlink to /fire_color_controller).
We can control this output by spawning a process with a crafted command line.
The exploit uses nc (netcat) with “BLUE” embedded in its
arguments:
<action>/bin/busybox nc -l -p 1234 -e BLUE 0 0 0 0 0 ...</action>
When the process monitor runs and lists this process, it writes something like:
42: nc -l -p 1234 -e BLUE 0 0 0 0 0 ...
The fire_color_reader.py script reads from
/fire_color_controller and checks if any valid color appears in the line. Since
“BLUE” is present, it matches and outputs “FLAMES BLUE” — achieving the
challenge goal!
Red Herrings and AI Bombs
Modern CTF challenges must account for contestants using AI coding assistants. We deliberately planted several red herrings and “AI bombs” — traps designed to mislead both humans and AI tools that might be analyzing the code.
Some of the red herring worked, some were less effective. Overall, it seems that CTF was challenging and possible to both AI users and abstainers. Although AI use certainly did shift the focus — players had to steer the agents to not assume incorrect assumptions, which proved difficult to without understanding the code themselves.
Conclusion
Trial by Fire was designed as a realistic exploit chain rather than a collection of isolated vulnerabilities. Solving it required combining several individually limited primitives—including server-side template injection, information disclosure, arbitrary file write, race conditions, and Linux namespace quirks—into a complete end-to-end compromise.
While some elements were intentionally exaggerated for the sake of the challenge, the techniques themselves are rooted in real classes of vulnerabilities that continue to appear in modern software. We hope the challenge gave participants an opportunity to explore how seemingly minor issues can become much more impactful when chained together.
If you have questions about this challenge, or about any of our previous BlueHat CTFs, we’d love to hear from you. We always enjoy discussing the design decisions behind these challenges and the different approaches teams took to solving them.