Challenge: Haskell2 · CTF: DalCTF 2026 · Category: Reversing · Difficulty: Medium
TL;DR
We’re handed a stripped compiler for a made-up language called haskell2 (.hs2). The compiler transpiles .hs2 source into C, builds it with cc, and the remote service runs the resulting binary. We send our program base64-encoded.
There is no exotic exploit here — the whole challenge is reverse-engineering an undocumented language well enough to write one valid program. Once you recover the grammar, the language can read an arbitrary file off disk and print it. The flag is just sitting in flag.txt next to the binary.
The winning program is two lines:
| |
Base64 that, send it to the service, and:
| |
Recon
We get a single binary and a network service:
| |
Stripped PIE, dynamically linked against glibc. Running it without arguments just prints usage:
| |
So it’s a command-line compiler: feed it a source.hs2, get an executable out. Let’s see what the remote wants:
| |
That confirms the workflow: we write a .hs2 program, base64-encode it, and the checker on the far end compiles and runs it for us. Our job is to make that program leak the flag.
Pulling the language out of the binary
The binary is stripped, so there’s no function-name roadmap. But the most valuable artifact in a compiler is its error messages and code-generation format strings — and strings gives us a goldmine. The interesting ones, roughly in the order they appear:
Parser error messages (these are the grammar):
| |
Semantic (type-checker) error messages:
| |
Code-generation snippets — the compiler emits C source and pipes it through cc:
| |
Three details jump straight out:
- The runtime can read files —
read_file_maybe(path)doesfopen(path, "rb")and slurps the contents. The language exposes a file-read primitive. - Code generation uses bare
printftemplates — variables becomev_%s, function namesfn_%s, parametersp_%s, and numbers go throughmake_number(%s). Anywhere a%sis filled with attacker-controlled text without escaping is a potential C-injection point. - The vocabulary is bizarre. Keywords include
remember,that,is,tell,me,innocuous,read,file,for,each,in, and the operator<-. This is the “functional programming class I didn’t pay attention to” flavor.
Because the compiler runs locally, the fastest way to nail down the grammar is black-box probing: feed it candidate programs and read the error messages. We don’t even need to read disassembly.
Reconstructing the grammar by probing
I started with the obvious “read a file and print it” shape, guessing Haskell-ish syntax:
| |
So a statement can’t begin with a bare identifier. Probing the leading keywords one at a time:
| |
That second/third result is the key “aha.” When I wrote tell me that x is innocuous, the generated C was:
| |
So tell me <expr> prints an expression, and that x is innocuous was parsed as a Haskell-style function application — that applied to the arguments x, is, innocuous (juxtaposition, no parentheses). The phrase “tell me that … is innocuous” reads like English but is really a function call. Cute. The semantic checker also told us function arguments must be remembered variables — you can’t pass literals.
Statement forms discovered
Working through the error messages:
1. Variable binding
| |
where <expr> is a string literal, a number, a variable, or a function application. Generates v_<name> = ...;.
2. Print
| |
Generates print_value(<expr>);.
3. The monadic file read — this one took the most probing. The error string expected '<-' in monadic file read plus expected a variable name after 'innocuous' told me the keyword innocuous introduces the read:
| |
So the full form is:
| |
innocuous is the “do this effectful thing” keyword — a tongue-in-cheek way of insisting that reading a file is totally harmless. The type checker enforces that the result is an effectful file value that “must be consumed by a line iterator.”
4. Line iterator
| |
The <file-binding> must be a name previously bound by an innocuous … <- read file … statement (hence “line iterator needs a file binding” / “file values must be consumed by a line iterator”). For each line of the file it binds <iter> and runs the inner statement.
Putting it together
| |
Compiling locally against a test file confirms the semantics, and dumping the generated C (by wrapping cc) shows exactly what it does:
| |
Local test:
| |
The file path is an ordinary string literal, so we can point it at any path on disk. No exploit required — the language’s intended read file feature is the file-read primitive.
A note on the injection rabbit hole
Before settling on the boring-but-correct solution, I checked whether the %s codegen templates were exploitable, since C injection would let us run system("cat /flag") regardless of the flag’s location. The compiler turned out to sanitize all three input classes:
| Source token | Emitted as | Sanitized? |
|---|---|---|
Identifiers (v_, fn_, p_) | v_<name> | Yes — alphanumeric + _ only; ., ;, (, " all rejected by the lexer |
| String literals | make_string("...") | Yes — escaped to \n \t \r \\ \" and \%03o octal for non-printables |
| Numbers | make_number(<text>) | Yes — digits/decimal only; 1.5e3, 12abc, 0x41 all rejected |
So there’s no breakout through the C backend. Good challenge hygiene — it nudges you toward actually understanding the language instead of bypassing it.
Firing it at the remote
The checker takes the program base64-encoded on a single line. A tiny helper:
| |
Trying the obvious path first:
| |
For completeness, the other guesses failed exactly as you’d expect (the runtime aborts on a missing file via actual()):
| |
The flag lives in flag.txt in the working directory next to the compiled binary.
Flag
| |
Lessons learned
- A compiler’s strings are its documentation. Parser/semantic error messages and codegen
printftemplates hand you the entire grammar for free — no disassembly needed for a language this small. - Black-box probe a local binary. Because we had the compiler in hand, the fastest path to the grammar was feeding it candidate programs and reading the error one at a time, rather than reverse-engineering the parser in a decompiler.
- Read the flavor text. “I didn’t pay attention in functional programming class” + a language whose read primitive is spelled
innocuous … <- read file …is the whole challenge: the language design is the puzzle, and reading a file is a first-class, intended feature. - Check for the cheap win, but don’t assume it’s there. I sniffed around the
%scodegen for a C-injection breakout; it was properly sanitized, and the legitimateread filefeature was the intended (and sufficient) path all along.
| |