Helm Hell

LACTFby smothy

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


Hackerman

"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":

bash
$ 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.

python
all_stmts = re.findall(r'\{\{-(.*?)-\}\}', content, re.DOTALL)
# Then track if/end nesting depth per define block

Step 2: Mapping Helm to Brainfuck

Each Helm operation maps to a BF instruction:

BFHelm PatternCount
+(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 $logbook41
.$cargo = printf "%s%c" $cargo $val9
[if ne $cell 0 + recursive include223
]End of recursive self-call223

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:

brainfuck
>+++++++++[<++++++++++++>-]<>  # Build expected value: 9*12 = 108 = 'l'
,                              # Read input character
<[->-<]>                       # Subtract: expected - input
[>[-]<[-]]                     # If nonzero (mismatch): CLEAR flag
<>                             # Move to next comparison

The 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:

python
# 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 target

The 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

python
#!/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

  1. Parser v1: Line-by-line splitting. Missed multi-statement lines like {{- $X := 1 -}}{{- $helm = add $helm $X -}}. Got 35 chars of BF. Tragic.

  2. Parser v2: Statement splitting, but regex for define blocks used non-greedy (.*?) matching, grabbing the FIRST end tag (from nested if blocks) instead of the actual define end. Cut volumeWorker7940 from 3,439 statements down to ~20.

  3. Missing decrement pattern: The (mod (add $VAR 256) 256) pattern (where $VAR was pre-computed as cell - 1) produced [] (empty loops = infinite loops in BF). Took 3 parser iterations to catch all 62 occurrences.

  4. 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

  1. Go templates are Turing-complete - recursive includes + conditionals + dict mutations = you can implement anything. Including BF interpreters.
  2. Pattern catalog first - instead of guessing patterns, count ALL unique set $sea patterns in the file. Found exactly 7 distinct patterns, making the parser definitive.
  3. Nested block parsing - never use non-greedy regex for matching paired delimiters. Track nesting depth properly.
  4. 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.
  5. Verify with the real tool - always confirm with actual helm template before 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.