July 13, 2026
12 min
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.
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.
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.
The production container (Dockerfile.prod) sets up a layered isolation architecture:
sandbox user is created with UID 2000/fire_color_controller FIFO is created with mode 0600 (owner read/write only), owned by rootWhen the container starts (jail_entrypoint_prod.sh), it spawns:
/fire_color_controller and outputs the flame statusThe 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 binary (ns_root.c) implements a custom sandbox using Linux namespaces. It:
clone()common.h as REAL_UID)/newroot with only essential files bind-mounted/control into the sandbox so the Go server can communicate with control_server.pyThe Go server runs as UID 0 inside the user namespace, which corresponds to UID 2000 in the host namespace. This means:
/control (owned by UID 2000)/fire_color_controller (owned by root, mode 0600)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:
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.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.
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.
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.
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/cwd resolves to the server's cwd (outside the jail)../../../fire_color_controller traverses up to the container's root and reaches the real FIFOThe 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.
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!
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.
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.