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.
What this one teaches
- Recognising a Factorio blueprint string (
0then 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.

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.

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.

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 flagReveal
FortID{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.