Helm Hell - LA CTF 2025 Rev Writeup
Category: Reverse Engineering
Difficulty: Medium
Points: 252
Solves: 48
Flag: lactf{t4k1ng_7h3_h3lm_0f_h31m_73mp14t3s}
Solved by: Smothy @ 0xN1umb

"When you realize someone compiled Brainfuck into Kubernetes manifests... respect."
Challenge Description
I was migrating our CTF infrastructure over to Helm charts instead of our artisan hand-crafted Kubernetes manifests we've been using for years, but I think I messed up the templates, and now it always outputs false whenever I try and render the templates ._.. Can you help me figure out how to get it to output true?
So someone decided the best way to make a rev challenge was to compile an entire flag-checking program into Helm Go templates. 637KB of nautical-themed variable names, recursive template includes, and obfuscated arithmetic. Just DevOps things.
TL;DR
A 12,846-line Helm template implements a Brainfuck interpreter that validates input character by character using multiplication loops. We parsed 232 template functions, extracted a 2,286-char BF program, computed the 40 expected character values from the multiplication constants, and recovered the flag. Kubernetes go brrr.
Initial Recon
What we got:
helm-hell/
├── Chart.yaml # Standard Helm chart metadata
├── values.yaml # input: ""
└── templates/
├── output.yaml # Renders result from "replicaHandler7951"
└── _helpers.tpl # 637KB, 12,846 lines of PAIN
Quick test confirms it always outputs "false":
$ helm template test ./helm-hell/ --set input=""
result: "false"
$ helm template test ./helm-hell/ --set input="lactf{test}"
result: "false"The entry point replicaHandler7951 creates a state dict with nautical-themed variables and calls volumeWorker7940:
sea = dict() # Memory tape
helm = 0 # Data pointer
cargo = "" # Output string
provisions = input # Input string
logbook = 0 # Input pointer
This is Brainfuck. In a Helm chart. Let that sink in.
Step 1: Parsing the Template Structure
The 12,846-line template contains 232 define blocks (functions). The challenge was parsing them correctly - nested if/end blocks meant naive regex matching would grab the wrong end tag.
Key insight: split by {{- ... -}} delimiters into individual statements, then track nesting depth to find where each define block actually ends.
all_stmts = re.findall(r'\{\{-(.*?)-\}\}', content, re.DOTALL)
# Then track if/end nesting depth per define blockStep 2: Mapping Helm to Brainfuck
Each Helm operation maps to a BF instruction:
| BF | Helm Pattern | Count |
|---|---|---|
+ | (mod (add V 1) 256) | 344 |
+ | (mod V 256) (obfuscated add) | 342 |
+ | (mod (add V $ternary_1) 256) | 340 |
- | (mod (add (sub V $var) 256) 256) | 84 |
- | (mod (add (sub V 1) 256) 256) | 77 |
- | (mod (add V 256) 256) (THE KEY FIX) | 62 |
> | $helm = add $helm 1 (+ variants) | 272 |
< | $helm = sub $helm 1 (+ variants) | 269 |
, | index $provisions $logbook | 41 |
. | $cargo = printf "%s%c" $cargo $val | 9 |
[ | if ne $cell 0 + recursive include | 223 |
] | End of recursive self-call | 223 |
The obfuscation was clever - additions like add 124, sub 123 (net +1), and the critical decrement pattern (mod (add $precomputed_minus_1 256) 256) that took multiple parser iterations to catch.
Step 3: Extracting the Brainfuck Program
After fixing all the patterns, we extracted a clean 2,286-character BF program:
The program structure for each character comparison:
>+++++++++[<++++++++++++>-]<> # Build expected value: 9*12 = 108 = 'l'
, # Read input character
<[->-<]> # Subtract: expected - input
[>[-]<[-]] # If nonzero (mismatch): CLEAR flag
<> # Move to next comparisonThe flag cell at position 2 starts as 1 (>>+<<). If ANY comparison fails, it gets cleared to 0. At the end:
- Cell 2 = 1 → output "true" (t=116, r=114, u=117, e=101)
- Cell 2 = 0 → output "false" (f=102, a=97, l=108, s=115, e=101)
Step 4: Computing the Flag
Each character's expected ASCII value is built by a multiplication loop plus an offset:
We simulated the BF program, feeding back the expected value at each input read:
# At each ',' instruction, record the constant in cell[0]
# That's the expected character value
expected = mem[dp-1] # Cell to the left holds the targetThe 40 expected values spell out:
l-a-c-t-f-{-t-4-k-1-n-g-_-7-h-3-_-h-3-l-m-_-0-f-_-h-3-1-m-_-7-3-m-p-1-4-t-3-s-}
The Flag
lactf{t4k1ng_7h3_h3lm_0f_h31m_73mp14t3s}
"taking the helm of helm templates" - l33t speak breakdown confirmed.
The Solve Script
#!/usr/bin/env python3
"""
Helm Hell solver - Smothy @ 0xN1umb
Extracts BF from Helm templates, computes expected flag values.
"""
import re, sys
sys.setrecursionlimit(100000)
with open("helm-hell/templates/_helpers.tpl") as f:
content = f.read()
# Parse template statements and define blocks
all_stmts = re.findall(r'\{\{-(.*?)-\}\}', content, re.DOTALL)
all_stmts = [s.strip() for s in all_stmts]
# ... (full parser with 7 cell operation patterns)
# ... (recursive BF extraction from 232 blocks)
# Simulate BF to extract expected values
def compute_flag(bf_program):
mem, dp, constants = [0]*30000, 0, []
# ... run BF, record cell[dp-1] at each comma
return ''.join(chr(c) for c in constants[:40])
flag = compute_flag(bf)
print(f"Flag: {flag}")
# Output: lactf{t4k1ng_7h3_h3lm_0f_h31m_73mp14t3s}The Graveyard of Failed Attempts
-
Parser v1: Line-by-line splitting. Missed multi-statement lines like
{{- $X := 1 -}}{{- $helm = add $helm $X -}}. Got 35 chars of BF. Tragic. -
Parser v2: Statement splitting, but regex for
defineblocks used non-greedy(.*?)matching, grabbing the FIRSTendtag (from nestedifblocks) instead of the actualdefineend. CutvolumeWorker7940from 3,439 statements down to ~20. -
Missing decrement pattern: The
(mod (add $VAR 256) 256)pattern (where$VARwas pre-computed ascell - 1) produced[](empty loops = infinite loops in BF). Took 3 parser iterations to catch all 62 occurrences. -
Full Helm template interpreter: Wrote a 300-line Python interpreter for Go templates. Hit expression parsing issues with
default 0 (index $sea $var)- the parenthesized sub-expression confused the simple regex parser. Abandoned for the BF extraction approach.
Key Takeaways
- Go templates are Turing-complete - recursive includes + conditionals + dict mutations = you can implement anything. Including BF interpreters.
- Pattern catalog first - instead of guessing patterns, count ALL unique
set $seapatterns in the file. Found exactly 7 distinct patterns, making the parser definitive. - Nested block parsing - never use non-greedy regex for matching paired delimiters. Track nesting depth properly.
- BF simulation for flag extraction - instead of reverse-engineering the comparison logic, just simulate the BF and record what constant is loaded before each input read.
- Verify with the real tool - always confirm with actual
helm templatebefore submitting.
Tools Used
- Python 3 (custom parser + BF interpreter)
- Helm v3.14.0 (for verification)
- matplotlib (screenshots)
- Way too much caffeine
- An unhealthy amount of regex
Writeup by Smothy from 0xN1umb team. Who needs Kubernetes when you have Brainfuck? GG.