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:

1
2
innocuous data <- read file "flag.txt"
for each line in data tell me line

Base64 that, send it to the service, and:

1
dalctf{n3w_l&nguAg3_uNl0ck3d}

Recon

We get a single binary and a network service:

1
2
3
4
$ file haskell2
haskell2: ELF 64-bit LSB pie executable, x86-64, ... dynamically linked,
          interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=93cda5e1...,
          for GNU/Linux 3.2.0, stripped

Stripped PIE, dynamically linked against glibc. Running it without arguments just prints usage:

1
2
3
$ ./haskell2 --help
usage: ./haskell2 [-o output] source.hs2
       ./haskell2 --help

So it’s a command-line compiler: feed it a source.hs2, get an executable out. Let’s see what the remote wants:

1
2
$ nc instancer.dalctf2026.com 34292 < /dev/null
Send base64-encoded haskell2 program:

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
expected a statement
expected 'that' after 'remember'
expected a variable name after 'remember that'
expected 'is' after variable name
expected a string, number, or variable expression
expected a newline after statement
expected 'me' after 'tell'
expected a variable name after 'innocuous'
expected '<-' in monadic file read
expected 'read' after '<-'
expected 'file' after 'read'
expected a string path after 'read file'
expected 'each' after 'for'
expected an iterator name after 'for each'
expected 'in' after iterator name
expected a file binding after 'in'
expected 'tell' in line iterator

Semantic (type-checker) error messages:

1
2
3
4
5
semantic error at line %d: '%s' is already remembered
semantic error at line %d: function arguments must be remembered variables
semantic error at line %d: effectful read must be bound before use
semantic error at line %d: file values must be consumed by a line iterator
semantic error at line %d: line iterator needs a file binding

Code-generation snippets — the compiler emits C source and pipes it through cc:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef enum { VALUE_UNSET, VALUE_STRING, VALUE_NUMBER } ValueType;
typedef struct { ValueType type; const char *string; double number; } Value;
static Value make_string(const char *string) { ... }
static Value make_number(double number) { ... }
static MaybeValue read_file_maybe(const char *path) {
    FILE *file = fopen(path, "rb");
    ...
}
static Value actual(MaybeValue maybe, const char *context) { ... }
static void print_value(Value value) { ... }
static Value multiply_value(Value left, Value right) { ... }
...
static Value fn_%s( Value p_%s
v_%s
cc %s -std=c11 -o %s %s
wrote executable: %s

Three details jump straight out:

  1. The runtime can read filesread_file_maybe(path) does fopen(path, "rb") and slurps the contents. The language exposes a file-read primitive.
  2. Code generation uses bare printf templates — variables become v_%s, function names fn_%s, parameters p_%s, and numbers go through make_number(%s). Anywhere a %s is filled with attacker-controlled text without escaping is a potential C-injection point.
  3. 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:

1
2
3
$ printf 'data <- read file "flag.txt"\nfor each line in data tell me that line is innocuous\n' > t.hs2
$ ./haskell2 -o t.out t.hs2
parse error at 1:1: expected a statement near 'data'

So a statement can’t begin with a bare identifier. Probing the leading keywords one at a time:

1
2
3
4
[remember that x is "hi"]                  => wrote executable        
[tell me that "hi" is innocuous]           => semantic error: function arguments must be remembered variables
[remember that x is "hi"
 tell me that x is innocuous]              => C backend error: implicit declaration of 'fn_that'

That second/third result is the key “aha.” When I wrote tell me that x is innocuous, the generated C was:

1
print_value(fn_that(v_x, v_is, v_innocuous));

So tell me <expr> prints an expression, and that x is innocuous was parsed as a Haskell-style function applicationthat 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

1
remember that <name> is <expr>

where <expr> is a string literal, a number, a variable, or a function application. Generates v_<name> = ...;.

2. Print

1
tell me <expr>

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:

1
2
3
$ printf 'innocuous data\ndata <- read file "flag.txt"\n' > p.hs2
$ ./haskell2 -o p.out p.hs2
parse error at 1:15: expected '<-' in monadic file read near '...'

So the full form is:

1
innocuous <name> <- read file "<path>"

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

1
for each <iter> in <file-binding> tell <stmt>

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

1
2
innocuous data <- read file "flag.txt"
for each line in data tell me line

Compiling locally against a test file confirms the semantics, and dumping the generated C (by wrapping cc) shows exactly what it does:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(void) {
    v_data = actual(read_file_maybe("flag.txt"), "flag.txt");
    if (v_data.type != VALUE_STRING) {
        fputs("runtime error: line iterator expects file contents\n", stderr);
        exit(1);
    }
    const char *iter = v_data.string;
    while (*iter != '\0') {
        const char *line_start = iter;
        while (*iter != '\0' && *iter != '\n') iter++;
        size_t line_length = (size_t)(iter - line_start);
        char *line_copy = malloc(line_length + 1);
        memcpy(line_copy, line_start, line_length);
        line_copy[line_length] = '\0';
        Value p_line = make_string(line_copy);
        print_value(p_line);
        putchar('\n');
        free(line_copy);
        if (*iter == '\n') iter++;
    }
    return 0;
}

Local test:

1
2
3
$ echo "secret flag contents here" > flag.txt
$ ./haskell2 -o p.out p.hs2 && ./p.out
secret flag contents here

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 tokenEmitted asSanitized?
Identifiers (v_, fn_, p_)v_<name>Yes — alphanumeric + _ only; ., ;, (, " all rejected by the lexer
String literalsmake_string("...")Yes — escaped to \n \t \r \\ \" and \%03o octal for non-printables
Numbersmake_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:

1
2
3
4
5
#!/bin/bash
# submit.sh <path-to-read>
prog=$(printf 'innocuous data <- read file "%s"\nfor each line in data tell me line\n' "$1")
printf '%s' "$prog" | base64 -w0 | { read b64; printf '%s\n' "$b64"; } \
  | timeout 60 nc instancer.dalctf2026.com 34292

Trying the obvious path first:

1
2
3
$ ./submit.sh flag.txt
Send base64-encoded haskell2 program:
dalctf{n3w_l&nguAg3_uNl0ck3d}

For completeness, the other guesses failed exactly as you’d expect (the runtime aborts on a missing file via actual()):

1
2
$ ./submit.sh /flag
Runtime Error: runtime error: read file failed: /flag

The flag lives in flag.txt in the working directory next to the compiled binary.


Flag

1
dalctf{n3w_l&nguAg3_uNl0ck3d}

Lessons learned

  • A compiler’s strings are its documentation. Parser/semantic error messages and codegen printf templates 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 %s codegen for a C-injection breakout; it was properly sanitized, and the legitimate read file feature was the intended (and sufficient) path all along.