Neven Marić
FortID 2026

ReversingFortID 2026

Paint Me a Flag

A binary replays 960,000 chained flood fills per image, each encrypted with a keystream fed by the previous fill's result. Real replay is far too slow, but regions only ever merge, so a union-find reimplementation does it in 14 seconds.

  • flood-fill
  • union-find
  • keystream
  • custom-format
  • optimization
  • reverse-engineering

What this one teaches

  • Reading a custom binary file format and a chained keystream out of a stripped ELF, and working out the record layout from the file size alone.
  • Spotting redundant work in a slow program (a fill that is immediately undone) and replacing it with an exact shortcut.
  • How a flood-fill-only workload keeps its regions connected, and why that makes union-find with per-root size and hash a faithful model.
  • A heavy/light split for neighbour-colour queries when a few components have thousands of neighbours.
  • Using a format's own integrity check as a test oracle for a reimplementation.

Hints

Stuck? Open one hint, then go back and try again before reading the next.

Hint 1 of 3Show

Every .pmaf file is 40 + 8 * W * H bytes. Work out what an 8-byte record is.

Hint 2 of 3Show

The reference binary finishes part 1 instantly and does not finish part 2. Compare the two headers.

Hint 3 of 3Show

Count the fills per operation. Which of them change anything?

Full solution below. Spoilers ahead.

Task description

It was an honor to paint this flag, I hope you’ll enjoy it.

What the handout contains

The handout is handout.zip, about 127 MB unpacked. Inside are one program and two sets of thirteen files:

  • paint_me_a_flag, a stripped x86-64 ELF (a compiled Linux binary with its symbol names removed), written in C++ and linked against libpng.
  • flag_part_01.png to flag_part_13.png, which all look like RGB static. Part 1 is 256x96, parts 2 to 13 are 1600x600.

Handout part 2 as shipped: a 1600 by 600 image that looks like uniform random RGB static, with no readable text

  • flag_part_01.pmaf to flag_part_13.pmaf, binary operation files in a custom format.

There is no remote service. The flag is fully contained in the handout, so the whole task is understanding the program well enough to run it on all 13 parts in a reasonable time.

What the binary does

The usage line is:

usage: paint_me_a_flag START.png OPERATIONS.pmaf OUTPUT.png

It replays a list of Paint-style bucket fills (a bucket fill is the paint-pot tool that recolours every pixel connected to the clicked one that shares its colour) onto a starting canvas, then writes the result. Run on part 1 it finishes instantly and draws a short piece of text. Run on part 2 it was still going after the 120-second timeout I set, so I killed it. That gap between the small instant part and the large stuck part is the whole shape of the challenge.

A good first question with any custom format is what one record looks like. Every .pmaf file is exactly 40 + 8 * W * H bytes, so after a 40-byte header there is one 8-byte record per pixel. The headers of part 1 and part 2 differ in only one field, a u32 at offset 0x20 that is 0 for part 1 and 0x40 (64) for parts 2 to 13:

$ xxd flag_part_01.pmaf | head -3
00000000: 504d 4146 030d 0a1a 0001 0000 6000 0000  PMAF........`...
00000010: 0060 0000 0000 0000 f344 2127 3d4e 6678  .`.......D!'=Nfx
00000020: 0000 0000 0000 0000 a619 6b84 d06b 49c2  ..........k..kI.
$ xxd flag_part_02.pmaf | head -3
00000000: 504d 4146 030d 0a1a 4006 0000 5802 0000  PMAF....@...X...
00000010: 00a6 0e00 0000 0000 5a37 3ab0 b4f2 e48a  ........Z7:.....
00000020: 4000 0000 0000 0000 e5bd 2bf8 a113 a73d  @.........+....=

That one differing field turns out to be the number of repaint rounds per operation, and it is why part 1 is instant and the rest are not.

The binary is small and I read it statically. Its strings include operation stream lost synchronization, bucket position is outside the canvas, not a Paint Me a Flag operation file and unexpected data after operation stream, which already sketch the parser’s contract. The functions that matter are main, the header parser, and the bucket fill (a breadth-first search, meaning it explores outward one ring of neighbours at a time). I ran the binary itself only twice: once to capture a reference output for part 1, and once to confirm part 2 does not finish.

The file format

All fields are little-endian:

Offset Type Field
0x00 8 bytes magic PMAF\x03\r\n\x1a
0x08 u32 width
0x0c u32 height
0x10 u64 operation count (non-zero)
0x18 u64 seed
0x20 u32 rounds (0 or 64)
0x24 u32 reserved, must be 0

W * H must be at most 0xFFFFFF, because pixel indices are packed into 24 bits later. Pixels are stored as u32 = R<<16 | G<<8 | B, row-major, so pixel (x, y) sits at index p = y * W + x.

I re-derived every header field by parsing the 13 files directly, which also confirmed the 40 + 8 * W * H size rule and the rounds split:

$ python -I parse_pmaf.py handout
file                        W      H      count               seed rounds  resv     filesize size_ok
flag_part_01.pmaf         256     96      24576 78664e3d272144f3      0     0       196648 True
flag_part_02.pmaf        1600    600     960000 8ae4f2b4b03a375a     64     0      7680040 True
flag_part_03.pmaf        1600    600     960000 78d88582641cb379     64     0      7680040 True
flag_part_04.pmaf        1600    600     960000 9e39e76c27612a32     64     0      7680040 True
...
flag_part_13.pmaf        1600    600     960000 36b2e5373a68623c     64     0      7680040 True

Every part passes filesize == 40 + 8 * count and count == W * H, so the one-operation-per-pixel reading is right, and each part carries its own seed.

The fill returns a fingerprint of the region

The bucket fill walks the connected same-colour region containing pixel p, using 4-neighbour connectivity (up, down, left, right). It recolours that region and returns three values:

  • n: the number of pixels in the region.
  • h: the XOR, taken over every pixel p in the region, of mix(p ^ 0x243f6a8885a308d3).
  • c: the region’s old colour.

Here mix is the splitmix64 finalizer, a short sequence of shifts and multiplies that scrambles a 64-bit value so that flipping one input bit changes output bits all over. In the solver source it reads:

static inline u64 mix(u64 z) {
    z ^= z >> 30; z *= 0xbf58476d1ce4e5b9ULL;
    z ^= z >> 27; z *= 0x94d049bb133111ebULL;
    z ^= z >> 31; return z;
}

The one detail that unlocks the whole task is that h is an XOR. XOR is commutative, so (n, h, c) depends only on which pixels are in the region, never on the order the fill visited them in. A different data structure that ends up with the same set of pixels produces the same fingerprint, which is what lets me replace the binary’s pixel-by-pixel BFS with something faster and still match it exactly.

The operations are encrypted with a chained keystream

Each 8-byte operation is XORed with a keystream (a pseudorandom sequence combined with the data to hide it) whose state depends on the result of every previous fill. Decrypting operation i needs the exact (n, h, c) of every earlier fill, so the simulation cannot be skipped or sampled. It has to be replayed faithfully from the start. This is the main loop, with C1 = 0xd6e8feb86659fd93:

S = mix(mix(count) ^ seed ^ H ^ (W<<32) ^ rounds*C1 ^ 0x6f3d9b21a4c875e1)
for i in 0..count-1:
    ig = i * 0x9e3779b97f4a7c15
    d  = mix(ig ^ S) ^ op[i]
    x  = d & 0xffffff                 # pixel index
    y  = (d >> 24) & 0xffffff         # RGB colour
    assert d>>48 == mix(((y<<24)|x) ^ ig ^ S) >> 48     # "lost synchronization"
    (n,h,c) = fill(x, y)
    S = mix(n*C1 ^ mix(h) ^ S ^ ig ^ mix(c<<24 | y))
    for j in 1..rounds:                                   # the expensive part
        col = (mix(ig ^ S ^ j*C1) & 0xfffffe) | 1         # retried if == 0xf2eadf
        (n1,h1,c1) = fill(x, col)
        A = mix(n1*C1 ^ mix(h1) ^ ig ^ S ^ mix(c1<<24 | col))
        (n2,h2,c2) = fill(x, y)                           # paint it back
        S = mix(n2*C1 ^ mix(h2) ^ ig ^ mix(c2<<24 | y) ^ A)

The top 16 bits of the decrypted d are a check value, recomputed from x, y and the current state. If any fill returns the wrong (n, h, c), the state S diverges, the next check fails, and the binary prints lost synchronization and stops. After the final operation the file must be exactly at EOF, which is the unexpected data after operation stream check. These two guards turn out to be a gift: they make the program verify my reimplementation for me.

I confirmed every constant above against the solver source (solve/pmaf.cpp) rather than trusting the notes: the Weyl increment 0x9e3779b97f4a7c15, the chaining multiplier C1 = 0xd6e8feb86659fd93, the hash key 0x243f6a8885a308d3, the state seed 0x6f3d9b21a4c875e1, the round colour mask (mix(...) & 0xfffffe) | 1 with its retry when the colour equals 0xf2eadf, and the splitmix64 finalizer multipliers all match.

Why it is slow, and the shortcut

The difficulty is work amplification, with no memory-safety bug anywhere. With rounds = 64 each operation does 1 + 2*64 = 129 BFS fills (one real fill, then 64 rounds of paint-a-temporary-colour and paint-it-back). The operations behave like the game Flood-It: almost every one recolours the main background blob, which grows to cover most of the 960,000-pixel canvas. The cost is roughly 960,000 operations * 129 fills * blob size, which is why part 2 never finished.

Three observations collapse that:

  1. The rounds only paint the region a temporary colour and then paint it straight back. Unless the temporary colour happens to match a neighbouring region (which would merge them), both fills just read back the current region’s (size, hash). Those rounds never need to touch a pixel.
  2. Flood fills never split a region. Recolouring a connected region keeps it connected, and the most that can happen is that it merges with a neighbour that already has the new colour. A structure where groups only ever merge is exactly what a union-find was built for.
  3. The only fast query the solver actually needs is “which neighbouring regions have colour c”, to find merges after a real fill and to decide whether a round would cause one.

A union-find (also called a disjoint-set structure: it keeps items partitioned into groups, answers which group an item is in, and merges two groups in near-constant time) models the regions directly. Each root stores its region’s size and XOR hash, and both of those combine by addition and XOR when two roots merge, so the fingerprint stays exact across merges.

Bootstrapping the regions from the raw image

The solver starts one union-find element per pixel, with size = 1 and hash = mix(p ^ 0x243f6a8885a308d3) on each. It then makes two passes over the canvas. The first pass unions every pixel with its right and down neighbour when they share a colour, which collapses each same-colour region to a single root with the correct size and XOR hash. The second pass walks the same right and down edges again and, whenever two edges connect different roots, records them as neighbours in an adjacency set. After that any root with more than 48 neighbours is promoted to heavy. From there the simulation is just repeated fills, and because fills only ever recolour a root and merge it with same-colour neighbours, the partition only coarsens, which is exactly the one-directional behaviour union-find handles well.

Storing adjacency as a set that starts as a small vector and upgrades to a hash map once it grows past 32 entries keeps the common case (tiny regions with a few neighbours) cheap while still letting the background blob hold thousands of neighbours without quadratic scans.

Neighbour queries needed a heavy/light split

Why a plain neighbour-colour counter was still too slow, and the fix

My first version kept, for every component, a count of boundary edges per neighbour colour, which made each round’s “does a neighbour have colour c” check O(1). But every real operation recolours the background blob, and the blob borders thousands of components (about 3,700 on average early in part 2). Each recolour then had to update all of those neighbours’ counters. That version managed roughly 140k operations per minute and got slower as the blob grew.

The fix is a heavy/light split, a standard trick for graphs with a few very high-degree nodes:

  • A component with more than 48 neighbours is heavy. Each heavy component keeps a lazy colour -> [neighbour roots] index, and stale entries (neighbours that have since merged or changed colour) are dropped the next time that list is read.
  • When any component changes colour, it notifies only its heavy neighbours. There are few of those, and each component keeps a short list of which heavy components border it.
  • A light component answers neighbour queries by scanning its own small adjacency set directly.

So when the blob recolours, it touches only a handful of heavy neighbours instead of thousands. Merging the blob with a light neighbour costs time proportional to the light side’s adjacency, which is the small-to-large merging rule that keeps the total work near-linear.

The solver, pmaf.cpp, is a union-find reimplementation of the binary that uses this split. The real fill becomes “recolour the root, then merge it with each neighbour that now shares its colour”, and a round that cannot cause a merge is served entirely from the root’s stored (size, hash) without touching pixels.

The full solver, pmaf.cpp
// Fast reimplementation of paint_me_a_flag (FortID rev challenge).
// usage: pmaf START.raw OPERATIONS.pmaf OUT.raw
// START/OUT are raw RGB24 dumps (W*H*3 bytes); PNG conversion is done in Python.
//
// Each op is a bucket fill. The op stream is XORed with a splitmix64 keystream
// whose state is chained on every fill's (component size, XOR-hash, old colour).
// The original BFS-fills 1 + 2*rounds times per op, over components that grow
// to most of the canvas. Here same-colour connected components live in a
// union-find (components only ever merge, never split), so a fill is just
// "recolour root, merge with neighbours of the new colour".
//
// Neighbour-colour queries use a heavy/light split: components with many
// neighbours ("heavy") keep a lazy colour -> neighbour-list index that their
// neighbours push into when they recolour; light components are scanned.
#include <cstdio>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <vector>
#include <unordered_map>
#include <utility>
using namespace std;
typedef uint64_t u64;
typedef uint32_t u32;

static const u64 B = 0xbf58476d1ce4e5b9ULL, P = 0x94d049bb133111ebULL;
static const u64 G = 0x9e3779b97f4a7c15ULL, C1 = 0xd6e8feb86659fd93ULL;
static const u64 HK = 0x243f6a8885a308d3ULL;
static inline u64 mix(u64 z) { z ^= z >> 30; z *= B; z ^= z >> 27; z *= P; z ^= z >> 31; return z; }

// set of u32: linear vector when small, hash map when large
struct USet {
    vector<u32> v;
    unordered_map<u32, char> *h = nullptr;
    size_t size() const { return h ? h->size() : v.size(); }
    bool has(u32 k) const {
        if (h) return h->count(k);
        for (u32 x : v) if (x == k) return true;
        return false;
    }
    void add(u32 k) {
        if (h) { (*h)[k] = 1; return; }
        if (has(k)) return;
        v.push_back(k);
        if (v.size() > 32) { h = new unordered_map<u32, char>(); h->reserve(128); for (u32 x : v) (*h)[x] = 1; vector<u32>().swap(v); }
    }
    void erase(u32 k) {
        if (h) { h->erase(k); return; }
        for (size_t i = 0; i < v.size(); i++) if (v[i] == k) { v[i] = v.back(); v.pop_back(); return; }
    }
    template <class F> void each(F f) const {
        if (h) { for (auto &p : *h) f(p.first); }
        else for (u32 x : v) f(x);
    }
    void clear() { delete h; h = nullptr; vector<u32>().swap(v); }
};

static const size_t HEAVY = 48;
static u32 W, H, N;
static vector<u32> par, sz, col, stamp;
static vector<u64> hs;
static vector<USet> adj;                                   // exact neighbour roots
static vector<unordered_map<u32, vector<u32>> *> cidx;      // heavy only: colour -> neighbour roots (lazy, may be stale)
static vector<vector<u32>> hnb;                             // heavy neighbours (lazy, may be stale)
static u32 curStamp = 0;

static u32 findr(u32 x) { while (par[x] != x) { par[x] = par[par[x]]; x = par[x]; } return x; }
static bool heavy(u32 r) { return cidx[r] != nullptr; }

static void makeHeavy(u32 r) {
    cidx[r] = new unordered_map<u32, vector<u32>>();
    adj[r].each([&](u32 m) { (*cidx[r])[col[m]].push_back(m); hnb[m].push_back(r); });
}

// distinct current roots of colour c adjacent to r (compacts r's lazy list)
static void nbrsOfColour(u32 r, u32 c, vector<u32> &out, bool firstOnly) {
    out.clear();
    ++curStamp;
    if (heavy(r)) {
        auto it = cidx[r]->find(c);
        if (it == cidx[r]->end()) return;
        vector<u32> &L = it->second;
        size_t k = 0;
        for (size_t i = 0; i < L.size(); i++) {
            u32 m = findr(L[i]);
            if (m == r || col[m] != c || stamp[m] == curStamp) continue;
            stamp[m] = curStamp;
            L[k++] = m;
            out.push_back(m);
        }
        L.resize(k);
        if (k == 0) cidx[r]->erase(it);
    } else {
        adj[r].each([&](u32 m) { if (col[m] == c && (!firstOnly || out.empty())) out.push_back(m); });
    }
}

// fold root b into root a (adjacent, same colour); returns new root
static u32 merge(u32 a, u32 b) {
    bool ha = heavy(a), hb = heavy(b);
    if ((hb && !ha) || (ha == hb && adj[a].size() < adj[b].size())) { swap(a, b); swap(ha, hb); }
    adj[a].erase(b);
    adj[b].erase(a);
    adj[b].each([&](u32 m) {
        adj[m].erase(b);
        adj[m].add(a);
        adj[a].add(m);
        if (ha && !hb) { (*cidx[a])[col[m]].push_back(m); hnb[m].push_back(a); }
    });
    if (hb) {
        for (auto &kv : *cidx[b]) { auto &d = (*cidx[a])[kv.first]; d.insert(d.end(), kv.second.begin(), kv.second.end()); }
        delete cidx[b]; cidx[b] = nullptr;
    }
    for (u32 h : hnb[b]) { u32 hr = findr(h); if (hr != a && hr != b) hnb[a].push_back(hr); }
    vector<u32>().swap(hnb[b]);
    adj[b].clear();
    par[b] = a; sz[a] += sz[b]; hs[a] ^= hs[b];
    if (hnb[a].size() > 4 * adj[a].size() + 64) {  // dedup lazy heavy-neighbour list
        ++curStamp; size_t k = 0;
        for (u32 h : hnb[a]) { u32 hr = findr(h); if (hr != a && heavy(hr) && stamp[hr] != curStamp) { stamp[hr] = curStamp; hnb[a][k++] = hr; } }
        hnb[a].resize(k);
    }
    if (!ha && adj[a].size() > HEAVY) makeHeavy(a);
    return a;
}

struct Res { u64 n, h; u32 c; };
static vector<u32> tmp;

static Res fill(u32 x, u32 nc) {
    u32 r = findr(x);
    Res res{sz[r], hs[r], col[r]};
    if (col[r] == nc) return res;
    col[r] = nc;
    ++curStamp;
    for (u32 h : hnb[r]) {  // tell heavy neighbours about the new colour
        u32 hr = findr(h);
        if (hr == r || !heavy(hr) || stamp[hr] == curStamp) continue;
        stamp[hr] = curStamp;
        (*cidx[hr])[nc].push_back(r);
    }
    nbrsOfColour(r, nc, tmp, false);
    vector<u32> same(tmp);
    for (u32 m : same) r = merge(r, m);
    return res;
}

static bool hasNbrColour(u32 r, u32 c) { nbrsOfColour(r, c, tmp, true); return !tmp.empty(); }

static u64 rd64(const unsigned char *p) { u64 v = 0; for (int i = 7; i >= 0; i--) v = (v << 8) | p[i]; return v; }
static u32 rd32(const unsigned char *p) { return p[0] | p[1] << 8 | p[2] << 16 | (u32)p[3] << 24; }

int main(int argc, char **argv) {
    if (argc != 4) { fprintf(stderr, "usage: %s start.raw ops.pmaf out.raw\n", argv[0]); return 2; }
    FILE *f = fopen(argv[2], "rb"); if (!f) { perror("ops"); return 1; }
    fseek(f, 0, SEEK_END); long fl = ftell(f); fseek(f, 0, SEEK_SET);
    vector<unsigned char> ops(fl); if (fread(ops.data(), 1, fl, f) != (size_t)fl) return 1; fclose(f);
    if (fl < 40 || memcmp(ops.data(), "PMAF\x03\r\n\x1a", 8)) { fprintf(stderr, "bad magic\n"); return 1; }
    W = rd32(&ops[8]); H = rd32(&ops[12]);
    u64 count = rd64(&ops[16]), seed = rd64(&ops[24]);
    u32 rounds = rd32(&ops[32]), resv = rd32(&ops[36]);
    N = W * H;
    fprintf(stderr, "W=%u H=%u count=%llu seed=%016llx rounds=%u resv=%u\n", W, H, (unsigned long long)count, (unsigned long long)seed, rounds, resv);
    if ((u64)fl != 40 + 8 * count) { fprintf(stderr, "size mismatch / trailing data\n"); return 1; }

    vector<unsigned char> img((size_t)N * 3);
    f = fopen(argv[1], "rb"); if (!f || fread(img.data(), 1, img.size(), f) != img.size()) { fprintf(stderr, "bad start raw\n"); return 1; } fclose(f);
    vector<u32> px(N);
    for (u32 i = 0; i < N; i++) px[i] = img[3*i] << 16 | img[3*i+1] << 8 | img[3*i+2];

    par.resize(N); sz.assign(N, 1); col = px; hs.resize(N); stamp.assign(N, 0);
    adj.resize(N); cidx.assign(N, nullptr); hnb.resize(N);
    for (u32 i = 0; i < N; i++) { par[i] = i; hs[i] = mix((u64)i ^ HK); }
    // union initial equal-coloured neighbours (plain DSU, adjacency built afterwards)
    auto uni = [&](u32 a, u32 b) { a = findr(a); b = findr(b); if (a == b) return; if (sz[a] < sz[b]) swap(a, b); par[b] = a; sz[a] += sz[b]; hs[a] ^= hs[b]; };
    for (u32 y = 0; y < H; y++) for (u32 x = 0; x < W; x++) {
        u32 p = y * W + x;
        if (x + 1 < W && px[p] == px[p+1]) uni(p, p+1);
        if (y + 1 < H && px[p] == px[p+W]) uni(p, p+W);
    }
    for (u32 y = 0; y < H; y++) for (u32 x = 0; x < W; x++) {
        u32 p = y * W + x;
        u32 nb[2] = {x + 1 < W ? p + 1 : UINT32_MAX, y + 1 < H ? p + W : UINT32_MAX};
        for (u32 q : nb) {
            if (q == UINT32_MAX) continue;
            u32 a = findr(p), b = findr(q);
            if (a != b) { adj[a].add(b); adj[b].add(a); }
        }
    }
    for (u32 i = 0; i < N; i++) if (par[i] == i && adj[i].size() > HEAVY) makeHeavy(i);

    u64 S = mix(mix(count) ^ seed ^ (u64)H ^ ((u64)W << 32) ^ (u64)rounds * C1 ^ 0x6f3d9b21a4c875e1ULL);
    u64 slow = 0;
    for (u64 i = 0; i < count; i++) {
        u64 ig = i * G;
        u64 a = ig ^ S;
        u64 d = mix(a) ^ rd64(&ops[40 + 8 * i]);
        u32 x = d & 0xffffff, y = (d >> 24) & 0xffffff;
        if ((u64)(d >> 48) != (mix((((u64)y << 24) | x) ^ a) >> 48)) { fprintf(stderr, "\nlost sync at op %llu\n", (unsigned long long)i); return 1; }
        if (x >= N) { fprintf(stderr, "\nbucket outside canvas at op %llu\n", (unsigned long long)i); return 1; }
        Res r0 = fill(x, y);
        S = mix(r0.n * C1 ^ mix(r0.h) ^ S ^ ig ^ mix(((u64)r0.c << 24) | y));
        for (u32 j = 1; j <= rounds; j++) {
            u64 sd = ig ^ S ^ (u64)j * C1;
            u32 c = (mix(sd) & 0xfffffe) | 1;
            while (c == 0xf2eadf) { sd = mix(sd + G); c = (mix(sd) & 0xfffffe) | 1; }
            u32 rt = findr(x);
            Res r1, r2;
            if (!hasNbrColour(rt, c)) {
                // fill C with c, then back with y: component unchanged, no merges
                r1 = {sz[rt], hs[rt], col[rt]};
                r2 = {sz[rt], hs[rt], c};
            } else {
                slow++;
                r1 = fill(x, c);
                r2 = fill(x, y);
            }
            u64 A = mix(r1.n * C1 ^ mix(r1.h) ^ ig ^ S ^ mix(((u64)r1.c << 24) | c));
            S = mix(r2.n * C1 ^ mix(r2.h) ^ ig ^ mix(((u64)r2.c << 24) | y) ^ A);
        }
        if ((i & 0xffff) == 0) fprintf(stderr, "\rop %llu/%llu slow=%llu", (unsigned long long)i, (unsigned long long)count, (unsigned long long)slow);
    }
    fprintf(stderr, "\ndone, slow-path rounds=%llu\n", (unsigned long long)slow);
    for (u32 i = 0; i < N; i++) { u32 c = col[findr(i)]; img[3*i] = c >> 16; img[3*i+1] = c >> 8; img[3*i+2] = c; }
    f = fopen(argv[3], "wb"); fwrite(img.data(), 1, img.size(), f); fclose(f);
    return 0;
}

Replaying all 13 parts

run.py converts each PNG to raw RGB, runs the solver, and converts the raw result back to a PNG:

$ g++ -O2 -o build/pmaf pmaf.cpp
$ python3 -I run.py ../handout res build/pmaf
The driver, run.py (PNG conversion plus solver calls)
"""Convert PNG<->raw RGB and drive the fast pmaf solver for each flag part.

usage: python -I run.py HANDOUT_DIR OUT_DIR SOLVER_BIN [part numbers...]
"""
import subprocess
import sys
from pathlib import Path

from PIL import Image

handout, out, solver = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3]
parts = [int(p) for p in sys.argv[4:]] or range(1, 14)
out.mkdir(parents=True, exist_ok=True)

for n in parts:
    start = Image.open(handout / f"flag_part_{n:02d}.png").convert("RGB")
    raw_in, raw_out = out / f"start_{n:02d}.raw", out / f"out_{n:02d}.raw"
    raw_in.write_bytes(start.tobytes())
    subprocess.run([solver, str(raw_in), str(handout / f"flag_part_{n:02d}.pmaf"), str(raw_out)], check=True)
    Image.frombytes("RGB", start.size, raw_out.read_bytes()).save(out / f"out_{n:02d}.png")
    raw_in.unlink()
    raw_out.unlink()
    print(f"part {n:02d} -> {out / f'out_{n:02d}.png'}", flush=True)

A typical run on one of the large parts:

W=1600 H=600 count=960000 seed=8ae4f2b4b03a375a rounds=64 resv=0
op 917504/960000 slow=0
done, slow-path rounds=0
real    0m14.384s

That is about 14 seconds per 1600x600 part, against the original binary which did not finish part 2 inside 120 seconds.

The results were checked two ways. Part 1 has rounds = 0, and its output came out byte-identical to the reference the original binary produced (solve/build/ref01.png). For parts 2 to 13, the 16-bit check inside every operation depends on the chained state, so the fact that every operation decoded without lost sync and each file ended exactly at EOF means the state chain matched the binary bit for bit across all 960,000 operations. The counter slow=0 and slow-path rounds=0 record that no round ever hit the merge case, although the exact fallback is implemented for the case that it does.

What each part spells

Each part draws a short chunk of text in dark navy on a beige (#F2EADF) background. Read left to right in part order, the chunks are the flag. The strings below are read off the rendered outputs (solve/res/out_01.png and the montage solve/res/montage_02_13.png):

Part Text Part Text
01 Fort 08 Y0ur
02 ID{P 09 _Fr3
03 41n7 10 nch_
04 _M3_ 11 Fl4g
05 L1k3 12 zzz!
06 _0n3 13 !!}
07 _0f_

The font has a dotted zero and a curved-tail lowercase l, so 0 versus O and l versus 1 read cleanly even in the leetspeak. Concatenated, the thirteen chunks are FortID{P41n7_M3_L1k3_0n3..., a play on a line from the film Titanic.

All 13 parts after replaying the fills, in order: each renders a short dark-navy chunk of leetspeak on a beige background, and read left to right then top to bottom they spell the flag

What I took from it

Look for an invariant before optimising a slow program. The cost here was redundant work, a region painted and then painted back, and the realisation that the fill result depends only on the set of pixels (not the traversal order) turned a brute-force replay into a near-linear one. The order-independent XOR hash is what made that substitution safe: a format that wanted to force exact replay would have hashed pixels in visit order instead. The per-operation check value doubled as a free test oracle, so any modelling mistake surfaced immediately as lost sync, and I never needed the slow binary as a reference beyond part 1. Work-amplification only protects a secret while no shortcut exists, and this one had an algorithmic shortcut hiding in plain sight.

The flagRevealFortID{P41n7_M3_L1k3_0n3_0f_Y0ur_Fr3nch_Fl4gzzz!!!}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.