Neven Marić
FortID 2026

MiscFortID 2026

Tangled

Two Factorio blueprint strings hide the flag in three places: one build's floor tiles draw a QR code, the other's tiles are a QR code XOR-masked by the first build's machines, and the circuit wires are hand-drawn letters.

  • factorio
  • blueprint
  • qr-code
  • xor
  • data-hiding
  • graph-drawing

What this one teaches

  • Recognising a Factorio blueprint string (0 then base64 of zlib-compressed JSON) and that game share strings can carry arbitrary payloads.
  • Scoring candidate masks by how many QR function-pattern modules they restore, which turns a blind search into a measurable one.
  • When a field is clearly artificial (a 1 to N permutation, constants with no signal), ask what it labels and draw that thing instead of only parsing it.

Hints

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

Hint 1 of 3Show

Both files start with 0eN. Decode them as base64, then zlib, then JSON.

Hint 2 of 3Show

The first QR code is plain floor tiles. The second one is broken, and the machinery in the first file is exactly its size.

Hint 3 of 3Show

After both QR codes, the flag has no closing brace. What else in the blueprint is clearly artificial?

Full solution below. Spoilers ahead.

Task description

An abandoned factory hides a message within its tangled machinery. Untangle the construction and uncover the secret.

The handout is handout_file_1.txt (9,458 bytes) and handout_file_2.txt (1,162 bytes), each a single line of text.

Decoding the files

Both files start with 0eN, the signature of a Factorio blueprint string: one leading version character (0), then base64 of a zlib-compressed JSON object.

What a Factorio blueprint string actually is

Factorio lets players copy a chunk of their factory to the clipboard as a text string so others can paste it back into the game. The string is a 0 (a format-version byte), followed by standard base64 of a zlib-compressed JSON document. The JSON describes a build: a list of entities (machines, belts, inserters, combinators, each with a position and a type), a list of tiles (the floor, such as landfill or foundation), and a list of wires connecting entities’ circuit-network connectors. Because it is just a compressed JSON blob with a one-character prefix, anyone can generate or edit one without the game, which is exactly what makes it a convenient place to smuggle data.

Decoding one is three steps:

import base64, zlib, json
bp = json.loads(zlib.decompress(base64.b64decode(s[1:])))["blueprint"]

Decoding both handouts and counting what is inside:

file 1: entities 290  wires 104  tiles 841   (429 landfill + 412 foundation)
        tiles 29 wide by 29 tall (x -69..-41, y -5..23)
        entities span 24 by 24 tiles (x -66.5..-42.5, y -2.5..21.5)
        version field 2.0.77.0,  zlib header 78 DA (level 9, "best")
file 2: entities 0    wires 0    tiles 330   (all landfill)
        tiles 25 wide by 25 tall (x -12..12, y -12..12)
        version field 2.0.0.0,   zlib header 78 9C (default level)

File 1 is a real Space Age build (biochambers, belts, inserters, combinators) saved by an actual 2.0.77 game at zlib level 9. File 2 has no machinery at all, only 330 floor tiles on a 25 by 25 grid, carries a bare 2.0.0.0 version, and its zlib stream is at the default level a script would produce. That difference, visible before any decoding of the payload, already hints that file 2 was generated rather than copied out of the game.

A QR code in the floor

Drawing file 1’s tiles with landfill as dark and foundation as light gives a clean 29 by 29 grid, which is a QR code at version 3, level M. It scans to the first part of the flag.

The 29 by 29 floor of file 1 drawn with landfill tiles as dark modules and foundation as light: a clean, scannable version 3 QR code

Untangling the second floor

File 2’s 25 by 25 tiles are QR-sized (version 2), but its finder patterns are broken, so it will not scan. The entities of file 1 span exactly 25 by 25 tiles, the same size as the scrambled code, which pointed at the machinery as the key that untangles it.

To make the search measurable instead of blind, I scored each candidate grid on how many of a QR code’s fixed function-pattern modules it reproduced. These are the modules every QR of a given version shares, regardless of payload (the three finders, their separators, the timing rows, the alignment pattern and the dark module), 236 of them for a version 2 symbol. A candidate that gets those right is almost certainly the real grid:

Mask applied to QR 2 Function-pattern match
none 75 of 147 finder modules
belt positions only 119 of 147
several 1 by 1 entity types 147 of 147
one cell per entity, at its position 235 to 236 of 236

So file 2’s floor is the real QR code XOR-ed with a mask that sets one module at each entity’s position. XOR-ing the same mask back in recovers the code:

land = {(t["position"]["x"], t["position"]["y"]) for t in bp2["tiles"]}
qr2 = [[(x, y) in land for x in range(-12, 13)] for y in range(-12, 13)]
x0 = min(e["position"]["x"] for e in bp1["entities"])
y0 = min(e["position"]["y"] for e in bp1["entities"])
for e in bp1["entities"]:
    x, y = e["position"]["x"], e["position"]["y"]
    qr2[round(y - y0)][round(x - x0)] ^= True   # flip the module under each entity

The one subtlety is that entities two tiles wide (decider combinators, splitters) sit on a half-tile boundary, so their position is something like -66.5. Python’s round (round half to even) on position - min(position) lands each one on the module the author intended, module for module. The result is a version 2-M code that scans to the second part. Joined with part 1, the text reads as the start of the flag with no closing brace, so something was still missing.

File 2’s floor before and after. Left: the 25 by 25 tiles as they ship, with broken, incomplete finder patterns that will not scan. Right: the same grid after XOR-ing one module per file-1 entity position, now a clean version 2 QR code with correct finders

What did not untangle it

Before scoring against the function patterns, I tried Arnold cat-map and shear permutations of QR 2 over their full periods, XOR against every 25 by 25 crop of QR 1, and XOR with the full footprint of multi-tile entities (the whole 3 by 3 of a biochamber, say). That last one fixed the finders but left the timing and data wrong, which is what the scoring table shows as the jump from 147 to 236: the key is one module per entity, not the entity’s whole area.

The wires are letters

Both tile layers re-encode bit for bit from the decoded text, so neither floor held anything more, and the remaining part had to be in the machinery itself. One field stood out as artificial. Every wired entity with a circuit condition carries the same constant as every other entity on its wire network, and the 29 networks use each value from 1 to 29 exactly once. Many of those conditions carry no signal at all ([empty] < 13), which a working factory would never need. The numbers looked like an index, but reading entity attributes in that order produced nothing.

What found the answer was to stop reading the wires as data and draw them. Treat each network as a picture: every entity is a dot at its position, every wire a line between two dots. Grouping the 104 wires into networks and drawing each network on its own canvas, each one turns out to be a single small hand-drawn character, and its circuit-condition constant is that character’s position in the text.

parent = {}
def find(a):
    parent.setdefault(a, a)
    while parent[a] != a: a = parent[a]
    return a
for e1, c1, e2, c2 in bp1["wires"]:          # group by (entity, connector)
    parent[find((e1, c1))] = find((e2, c2))  # keeps red and green networks apart, as the game does
# label each network by its shared circuit-condition constant (1..29),
# then draw each network's wires as line segments between entity positions, on its own canvas

Drawing all 104 wires on one canvas is unreadable spaghetti; the characters only separate when each network gets its own canvas. Factorio’s y axis points down, so the text comes out upright with no flipping. Three of the networks are a single horizontal stroke, which are the underscores. Read in label order, the 29 drawings spell the closing part of the flag and the final }, and the three parts together form one sentence about belts, spaghetti and trains.

The 104 circuit wires grouped into 29 networks, each network drawn on its own panel as line segments between entity positions. In label order they form hand-drawn leetspeak characters spelling the closing part of the flag, ending in a brace

One reading cost a submission. By analogy with the leetspeak elsewhere in the flag, I first read the drawing at position 13 as a digit, and the flag was rejected. Looking again, the position-13 drawing (a slanted top stroke over a small closed hook at the lower left) is plainly a different shape from the real digit later in the text at position 21 (a flat top bar, then a zigzag diagonal stem running down to a long tail). The two are different characters, and taking the shape at face value rather than by analogy gave the accepted reading.

Check

The handout’s intended solver is a short script using zxing-cpp (a barcode decoder) and Pillow. The structural facts above (the entity, wire and tile counts, the grid sizes, the version and zlib-level difference between the two files) I re-derived by decoding the blueprints myself, shown in the decode block. QR 1 (version 3-M, mask 5) and QR 2 (version 2-M, mask 6) each re-encode to their tile grids with zero module mismatches, and the assembled flag was accepted by the platform. Part 3 is read by eye from the wire drawings, so it is the part to double-check, and it is exactly what the first rejected submission got wrong.

Defence

User-shareable content strings (blueprints, save codes, level codes) are an easy covert channel. Even a field that looks purely functional, such as wire topology, can carry a drawing, and the two layers here hid data in plain floor tiles and in an XOR of two of them. A platform that accepts such strings should validate their schema and treat the decoded content as untrusted input rather than assuming it only describes a game object.

The flagRevealFortID{Wh3n_7h3_8e1ts_b3c0me_5p49hett1_tr41n5_8ec0m3_th3_0n1y_r34s0n4b1e_50lut10n}

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