FLARE-On 13 write-ups

RE with AI

flare-on 13: Threat Invaders

An impossible telemetry length that gives away a patched .NET runtime hooking the game's own methods from native code.

Me: 25:58, 77.55M tokens, $19.89, didn't step in.
Truix: 7:01, 12.81M tokens, $3.78, stepped in 6 times, 3 refusals.

Muse spent 5h 35m on this FLARE-On challenge and needed two hints from Opus 5.5. That was painful to watch after Sol got through it in 26 minutes without me stepping in.

The first hint was to compare what TelemetryReporter.BuildDocument could send with the capture. The second was to look at what runs before managed code in a single-file bundle. Fifteen minutes after the second hint, Muse had the flag. Sol took a crypto detour too, but found the managed/native boundary on its own.

A native hook that rewrites a game's own methods is basically my old resume, so it stung a little that Sol found the managed/native boundary without me saying a word.

Pull the game out of the bundle

6-ThreatInvaders.7z contains ThreatInvaders/ThreatInvaders.exe and ThreatInvaders/challenge.pcapng. The executable is a .NET 10 Windows single-file bundle, 116,238,508 bytes long.

The original inputs have these SHA-256 hashes:

File SHA-256
ThreatInvaders/ThreatInvaders.exe 7a86a4ea0a22fa62eae69abb41b006e3600df941d0e06942c1044050cf9c0c62
ThreatInvaders/challenge.pcapng 75c92ebcae76c89acfe060dc40c4b93f03fab570b6eaa801b8a42dfc4f79cbc6

The bundle manifest lists 254 entries: the game assembly, 251 Microsoft framework assemblies, and the dependency/runtime configuration files. ThreatInvaders.dll occupies 100,864 bytes at bundle file offset 0x99A000, decimal 10067968.

Sol used its bundle parser to extract that one entry without loading the game:

powershell codewrap
python re-analysis/invadersbundle/extract_bundle.py ThreatInvaders/ThreatInvaders.exe --extract ThreatInvaders.dll --output re-analysis/invadersbundle/reader-assembly

The extracted DLL should hash to:

text codewrap
f64b8b63e143098917c52023218aa4c6a2865df14cd7d1fb09d8b40d2edaee88

re-analysis/runtime-diff/host-image-only.exe is the original executable's native PE image prefix, 0x999200 bytes long, without the large appended bundle. Its SHA-256 is:

text codewrap
cef0b423b54f33aa267414aba7c682f095b271bfc2b3ad1812153b55828c9ba0

The flag can be recovered from those bytes and the capture without launching the game, contacting its server, or executing the recovered commands.

Read the traffic before inventing a winning score

Before trying to beat the score, look at the score that's already in the capture. In Wireshark, use:

text codewrap
http.request || http.response

There is one HTTP conversation:

text codewrap
192.168.198.129:49839 <-> 192.168.198.130:80

It lasts about 330 seconds. The client polls /api/v1/health, /api/v1/highscores?limit=25, and /api/v1/version roughly every five seconds. It also makes one highscore POST and five telemetry POSTs.

The corresponding TShark listing is:

powershell codewrap
& 'C:/Program Files/Wireshark/tshark.exe' -r ThreatInvaders/challenge.pcapng -Y 'http.request || http.response' -T fields -e frame.number -e frame.time_relative -e http.request.method -e http.request.uri -e http.response.code

Sol exported the HTTP bodies and decoded them with its decode_http.py helper:

powershell codewrap
& 'C:/Program Files/Wireshark/tshark.exe' -r ThreatInvaders/challenge.pcapng -Y http.file_data -T fields -E 'separator=,' -e frame.number -e frame.time_relative -e http.request.uri -e http.response.code -e http.file_data | Set-Content -Encoding ascii re-analysis/invaderstraffic/http-followalong.csv

python re-analysis/invaderstraffic/decode_http.py re-analysis/invaderstraffic/http-followalong.csv re-analysis/invaderstraffic/decoded-followalong

The decoder overwrites matching files in an existing output directory, so use a fresh name. It expects those five CSV columns, in that order: frame number, relative time, request URI, response code, and hexadecimal HTTP body. An --export-objects directory is a different input format.

Sol saved the decoded bodies under re-analysis/invaderstraffic/decoded/, the path used in the remaining commands.

The ordinary score submission is frame 213:

text codewrap
player:  FLARE
score:   910
wave:    1
comment: Almost cleared wave one

The comment is Base64 in the JSON. Decoding it gives that sentence, just an ordinary game entry.

The final telemetry response, frame 825, is more tempting. It contains:

text codewrap
reward_now: true

That looks like a signal to reveal something after winning. I wouldn't spend time playing until I knew what consumed it.

HttpRemoteScoreClient.UploadTelemetryAsync posts the JSON, checks the HTTP status, and disposes the response. The relevant reconstructed C# is:

c# codewrap
HttpResponseMessage val = await _http.PostAsync(
    "api/v1/telemetry",
    (HttpContent)(object)content,
    ct
).ConfigureAwait(false);

try
{
    if (val.IsSuccessStatusCode)
    {
        LastError = null;
        return true;
    }

    LastError = $"telemetry returned {(int)val.StatusCode}";
    return false;
}
finally
{
    ((global::System.IDisposable)val)?.Dispose();
}

There's no response-body parsing there. The stock method never reads reward_now.

There is also an explicit updater decoy. Both updater entry paths contain this Base64 literal:

text codewrap
LS1pbnN0YWxsLWRpciBXb3VsZG4ndF9pdF9iZV9uaWNlX2lmX3RoaXNfd2FzX3RoZV9yZWFsX2ZsYWdAbm8tZmxhcmUuY29tIC0tY3dkIEJ1dF9zYWRseV9pdF9pc24ndA

Decoded as text, with the missing Base64 padding restored, it says:

text codewrap
--install-dir Wouldn't_it_be_nice_if_this_was_the_real_flag@no-flare.com --cwd But_sadly_it_isn't

The fake flag is Wouldn't_it_be_nice_if_this_was_the_real_flag@no-flare.com. The trailing argument is fairly clear about its status.

The serializer can't send this

This is the clue I'd have wanted before any crypto guesses: the capture claims 16,914 bytes of feedback in a 4,096-byte buffer. Open ThreatInvaders.dll in ILSpy, select SpaceInvaders.Game.TelemetryReporter.BuildDocument, and switch the language dropdown to IL.

There was a tool wrinkle here: the included ILSpy 9.1 CLI emitted the entire assembly when -il and -t were combined. The GUI's method-level IL view avoids that.

BuildDocument has a 12-byte managed method header. Keep the address spaces separate:

Address space Method header First IL instruction
DLL RVA 0x7EC4 0x7ED0
DLL file offset 0x60C4 0x60D0
Full bundle file offset 0x9A00C4 0x9A00D0

The last row adds the DLL's bundle offset, 0x99A000, to its file offsets.

These are the original instructions from IL_0000 through IL_0038:

il codewrap
IL_0000: ldstr ""
IL_0005: stloc.0
IL_0006: ldc.i4.0
IL_0007: stloc.1
IL_0008: ldarg.2
IL_0009: brfalse.s IL_0039
IL_000b: ldarg.2
IL_000c: ldlen
IL_000d: brfalse.s IL_0039
IL_000f: ldc.i4 4096
IL_0014: newarr [System.Runtime]System.Byte
IL_0019: stloc.2
IL_001a: ldarg.2
IL_001b: ldlen
IL_001c: conv.i4
IL_001d: ldc.i4 4096
IL_0022: call int32 [System.Runtime]System.Math::Min(int32, int32)
IL_0027: stloc.1
IL_0028: ldarg.2
IL_0029: ldloc.2
IL_002a: ldloc.1
IL_002b: call void [System.Runtime]System.Array::Copy(class [System.Runtime]System.Array, class [System.Runtime]System.Array, int32)
IL_0030: ldloc.2
IL_0031: ldloca.s 1
IL_0033: call string SpaceInvaders.Game.TelemetryReporter::EncodeFeedbackBuffer(uint8[], int32&)
IL_0038: stloc.0

Local 0 starts as the empty feedback string. Local 1 starts as length zero. A null or empty input skips the allocation and copy. Otherwise, the method allocates a 4,096-byte array, computes Math.Min(feedback.Length, 4096), and copies that many bytes.

The instruction to remember is ldloca.s 1. It pushes the address of local 1. EncodeFeedbackBuffer receives the length by reference.

Later in the same method, that local becomes the JSON field:

il codewrap
IL_0057: dup
IL_0058: ldloc.0
IL_0059: callvirt instance void SpaceInvaders.Game.TelemetryReporter/TelemetryDto::set_FeedbackB64(string)
IL_005e: dup
IL_005f: ldloc.1
IL_0060: callvirt instance void SpaceInvaders.Game.TelemetryReporter/TelemetryDto::set_FeedbackLength(int32)

The stock encoder lives at DLL RVA 0x7E5C, with IL beginning at DLL file offset 0x6068. Its reconstructed C#, minus an ILSpy diagnostic comment:

c# codewrap
private static string EncodeFeedbackBuffer(byte[] fixedBuffer, ref int length)
{
    if (fixedBuffer.Length != 4096)
    {
        throw new ArgumentException(
            $"feedback buffer must be exactly {4096} bytes",
            "fixedBuffer"
        );
    }

    if (length > 0)
    {
        return Convert.ToBase64String(fixedBuffer);
    }

    return "";
}

It reads length. It never writes through the reference.

That gives exactly two legal stock outputs:

Feedback input Serialized feedback_b64 Serialized feedback_length
Null or empty Empty string 0
Nonempty Base64 of a complete 4,096-byte buffer 1..4096

Now compare that to all five requests.

powershell codewrap
$doc = Get-Content -Raw re-analysis/invaderstraffic/decoded/frame-672.json | ConvertFrom-Json
$doc | Select-Object session_id, feedback_length, games_played
[Convert]::FromBase64String($doc.feedback_b64).Length

Frame 672 reports 16914, or 0x4212, while its decoded buffer is exactly 4,096 bytes. It isn't the only impossible request:

Frame Advertised length Decoded buffer size Contents before padding Stock contradiction
21 0 4096 All zero Nonempty Base64 with length zero
280 0 4096 60 bytes of Crashes sometimes. But overall very fun, could play all day! Nonempty Base64 with length zero
468 8208 = 0x2010 4096 16 active bytes Length exceeds the cap
672 16914 = 0x4212 4096 530 active bytes Length exceeds the cap
821 16430 = 0x402E 4096 46 active bytes Length exceeds the cap

All five use session ID:

text codewrap
478b635d-c335-4c12-b1de-16e0565be335

Sol's audit script checks those two legal cases:

powershell codewrap
python re-analysis/invadersbundle/audit_telemetry.py re-analysis/invaderstraffic/decoded

The result:

text codewrap
Stock-compatible requests: 0/5

Even the apparently boring first two requests fail. This was a much better lead than trying to find a magic score.

The crypto detour and the native host

Sol spent time guessing encryption before it had the transform. One agent tried 7,224 AES-GCM and ChaCha20-Poly1305 combinations using API-key material, GUIDs, scores, hashes, and guessed nonces. A separate probe pushed that to 15,990. Zero authenticated results.

Raw DEFLATE at offsets 4, 6, and 10 in frame 672 produced four, two, and five bytes of distraction. Sol tightened the probe to require a terminated stream and at least 16 output bytes. The lengths looked tempting too: 530 = 512 + 18, and 46 = 18 + 28. None of it mattered; the native code explains every length without any AEAD.

The managed-code agent found no additional application assembly, module initializer, or managed resource carrying a hidden stage. It initially dismissed the native image as ordinary CoreCLR.

Sol then compared all 251 bundled framework DLLs against the matching official .NET 10.0.11 packages:

powershell codewrap
python re-analysis/runtime-diff/compare_runtime.py ThreatInvaders/ThreatInvaders.exe re-analysis/runtime-diff/microsoft.netcore.app.runtime.win-x64.10.0.11.nupkg re-analysis/runtime-diff/microsoft.windowsdesktop.app.runtime.win-x64.10.0.11.nupkg

The totals:

text codewrap
251 MATCH
0 DIFFER
0 NOT_IN_PACK

Every framework DLL matched. In a separate comparison, Sol used pefile to compare section sizes and SHA-256 hashes against runtimes/win-x64/native/singlefilehost.exe from Microsoft.NETCore.App.Host.win-x64 10.0.11. The host's .text differed from that stock binary, and a string comparison found an added ReplaceHighscores.

An unsigned diagnostic provider with version 42.42.42.42424 also attracted attention. It was a different CoreCLR DAC build - the Data Access Component debuggers use to read runtime state - with no connection Sol could find to the command channel. The method hooks were in the host.

Follow the managed entrypoint replacement

Open re-analysis/runtime-diff/host-image-only.exe in IDA. Its preferred image base is 0x140000000.

For the .text windows used here:

text codewrap
VA          = 0x140000000 + RVA
file offset = RVA - 0xC00

Those file offsets also apply to the original full bundle, because this smaller artifact preserves the original prefix.

In IDA, press Shift+F12, find ReplaceHighscores, and follow its xrefs with X. The resolver is at VA 0x1400F5520, RVA 0xF5520. It retains the resolved method identities in globals; following xrefs to qword_1407FE598, qword_1407FE5B8, qword_1407FE5C0, or qword_1407FE5C8 leads to 0x1400C6E30.

CoreCLR represents each managed method with a MethodDesc, which owns its entrypoint. When the JIT compiles a method, the runtime publishes the native code address through that method's entrypoint machinery; the slot can live in the MethodDesc itself or its MethodTable. Hooking that publication step lets this host swap the code for five specific methods without changing the managed DLL.

At 0x1400C6E30, the hook compares the incoming method identity against its saved targets, preserves the original entrypoint, and substitutes its own function.

For the same view through IDASQL, start one session on the host copy. Install the IDASQL build matching your IDA version (9.3 here) alongside IDA and add that directory to PATH so the bare idasql command resolves:

powershell codewrap
idasql -s re-analysis/runtime-diff/host-image-only.exe --http 0 --bind 127.0.0.1

Use the endpoint printed by that process in a second terminal:

powershell codewrap
$endpoint = Read-Host 'Paste the IDASQL HTTP server URL printed at startup'

curl.exe --silent --show-error --fail-with-body --max-time 60 --data-binary "SELECT * FROM binary; SELECT disasm_range(0x1400C6E44,0x1400C6F05); SELECT disasm_range(0x1400C6699,0x1400C66E5); SELECT disasm_range(0x1400C6BF4,0x1400C6D33);" "$endpoint/query"

Don't open a database already owned by an IDA GUI through a second process.

This is the original publication-hook window, VA 0x1400C6E44 through 0x1400C6F00, beginning at raw file offset 0xC6244:

x86asm codewrap
1400c6e44: mov     rdi, rcx
1400c6e47: test    rax, rax
1400c6e4a: jz      short loc_1400C6E5F
1400c6e4c: cmp     rcx, rax
1400c6e4f: jnz     short loc_1400C6E5F
1400c6e51: mov     cs:qword_1407FE5D8, rdx
1400c6e58: lea     rbx, sub_1400C66F0
1400c6e5f: mov     rax, cs:qword_1407FE598
1400c6e66: test    rax, rax
1400c6e69: jz      short loc_1400C6E7E
1400c6e6b: cmp     rdi, rax
1400c6e6e: jnz     short loc_1400C6E7E
1400c6e70: mov     cs:qword_1407FE5D0, rbx
1400c6e77: lea     rbx, sub_1400C6A70
1400c6e7e: mov     rax, cs:qword_1407FE5C8
1400c6e85: test    rax, rax
1400c6e88: jz      short loc_1400C6E9D
1400c6e8a: cmp     rdi, rax
1400c6e8d: jnz     short loc_1400C6E9D
1400c6e8f: mov     cs:qword_1407FE5E0, rbx
1400c6e96: lea     rbx, sub_1400C6B70
1400c6e9d: mov     rax, cs:qword_1407FE5C0
1400c6ea4: test    rax, rax
1400c6ea7: jz      short loc_1400C6EBC
1400c6ea9: cmp     rdi, rax
1400c6eac: jnz     short loc_1400C6EBC
1400c6eae: mov     cs:qword_1407FE5B0, rbx
1400c6eb5: lea     rbx, sub_1400C6BA0
1400c6ebc: mov     rax, cs:qword_1407FE5B8
1400c6ec3: test    rax, rax
1400c6ec6: jz      short loc_1400C6EDB
1400c6ec8: cmp     rdi, rax
1400c6ecb: jnz     short loc_1400C6EDB
1400c6ecd: mov     cs:qword_1407FE5A8, rbx
1400c6ed4: lea     rbx, sub_1400C6DF0
1400c6edb: call    sub_140087270
1400c6ee0: test    al, al
1400c6ee2: jz      short loc_1400C6F05
1400c6ee4: mov     rcx, rdi
1400c6ee7: call    sub_1400C8210
1400c6eec: test    al, al
1400c6eee: jz      short loc_1400C6F05
1400c6ef0: xor     r8d, r8d
1400c6ef3: mov     rdx, rbx
1400c6ef6: mov     rbx, [rsp+28h+arg_8]
1400c6efb: add     rsp, 20h
1400c6eff: pop     rdi
1400c6f00: jmp     sub_1400C7F60

Immediately before this window, RBX receives the proposed native entrypoint from RDX, and RAX receives the first saved method identity.

RCX, then RDI, carries the native MethodDesc. Each test checks that a target was resolved; each cmp checks whether this is that particular managed method. A matching branch saves the original pointer and puts the replacement address in RBX.

For the encoder, the save is at 0x1400C6EAE, followed by:

x86asm codewrap
1400c6eb5: lea     rbx, sub_1400C6BA0

At the end, mov rdx,rbx passes the chosen pointer onward into the entrypoint-publication path.

The recovered targets are:

Managed method Native replacement RVA
LocalHighscoreStore.ReplaceHighscores 0xC66F0
UpdateLauncher.LaunchUpdate 0xC6A70
TelemetryReporter.HasFeedback 0xC6B70
TelemetryReporter.EncodeFeedbackBuffer 0xC6BA0
TelemetryReporter.GetSessionID 0xC6DF0

There's a separate wrapper for TelemetryReporter.GetMachineGuid at RVA 0xF54E0, outside the five selections above. Its call/copy at VA 0x1400F5501 through 0x1400F550C saves the original result's 16 raw bytes. The session wrapper similarly saves its 16 bytes at 0x1400C6E1B.

The game assembly stays unchanged while these managed calls enter native replacements.

Explain the impossible length

The length cap works exactly as written, and the hook just changes the length afterward. Jump to the replacement encoder at 0x1400C6BA0.

Its unrolled XOR operations at 0x1400C6BF4 through 0x1400C6CF9 construct:

text codewrap
bootstrap[i] = machineGuid[i] XOR sessionGuid[15 - i]

The relevant native stores are:

Host VA Effect
0x1400C6D1D Set the saved length to 8208, hexadecimal 0x2010
0x1400C6D8A OR the captured stdout length with 0x4000
0x1400C6DB1 Write that value through the encoder's length-reference argument

That final store changes local 1 in BuildDocument. The Math.Min happened before the call. There is no second cap after it.

Frames 21 and 280 take the ordinary-encoding path at 0x1400C6D97, but still reach the store at 0x1400C6DB1: the hook replaces the length with the zero saved in qword_1407FE5E8 after the stock encoder has already returned the nonempty Base64 string.

The upper bits describe the phase, while the lower bits carry the active size in these captured records:

text codewrap
0x2010 -> bootstrap tag 0x2000, active length 0x010 = 16
0x4212 -> output tag    0x4000, active length 0x212 = 530
0x402E -> output tag    0x4000, active length 0x02E = 46

The JSON still carries a Base64 encoding of a 4,096-byte array. The remaining bytes are zero padding. feedback_length & 0xFFF recovers these three active lengths.

Read the leaderboard as a command channel

The first unusual entry is BOOM, score 31337, with comment:

text codewrap
ZnVuIQ== -> fun!

It first appears at frame 221 and is still present at frame 322. Before the bootstrap has been sent, the highscore hook hashes the first four decoded comment bytes at 0x1400C69F6 and compares against 0x05045A536B62B2C6, the hash of fun!. With both GUID-captured flags set, it writes 1 to byte_1407FD5FE at 0x1400C6A23, requesting the bootstrap record. The trigger is the comment, not the name BOOM or score 31337.

Later, two rank-one entries appear for one poll each:

Frame Name Score Decoded comment size
651 BANANA 2066361377 = 0x7B2A2C21 18
800 BENIGN 1932142625 = 0x732A2821 28

The next polls don't contain them. Adjacent health responses still report ten entries, and there's no corresponding client highscore POST.

Inspect the first entry directly:

powershell codewrap
$first = (Get-Content -Raw re-analysis/invaderstraffic/decoded/frame-651.json | ConvertFrom-Json).entries[0]
$first | Select-Object player_name, score, comment_b64
[Convert]::FromBase64String($first.comment_b64).Length

The Base64-decoded ciphertexts are:

text codewrap
BANANA, frame 651:
f9b98fb126700b7788443febbea5c57e7cdf

BENIGN, frame 800:
19e9e3319836f33895fc45366134431a66484953ec12b6aaac23b4de

The native handler uses the score and the first four UTF-8 name bytes as a selector:

text codewrap
0x7B2A2C21 XOR LE32("BANA") = 0x3A646D63
0x732A2821 XOR LE32("BENI") = 0x3A646D63

0x3A646D63 in little-endian byte order:
63 6d 64 3a
 c  m  d  :

That input is assembled at host VA 0x1400C680F. The selected comment reaches the byte transform at 0x1400C686F.

The name and score jointly encode cmd:, so the handler doesn't need magic string comparisons for the names.

After decryption, those comments contain a command path and argument separated by ;. The LaunchUpdate replacement can substitute those buffers, call the original launch helper, and capture stdout. At 0x1400C6B4B, it passes that output through the same transform before sending it back as telemetry.

A leaderboard delivers shell commands, the updater executes them, and feedback carries the output. That's a hell of a feature for a Space Invaders game. I'm happy to stick to decrypting the capture.

Recover the capture machine's GUID

The first 16 bytes of frame 468 are:

text codewrap
54 ae 03 2e a8 75 b0 f8 cc 57 8a 51 1f e7 16 31

The encoder gives the relationship:

text codewrap
bootstrap[i] = machineGuid[i] XOR sessionGuid[15 - i]

XOR the same session byte back in and the machine byte is recovered.

GUID byte order is where this goes wrong. The session text is:

text codewrap
478b635d-c335-4c12-b1de-16e0565be335

A Windows/.NET GUID stores its first DWORD and next two WORDs little-endian. The remaining eight bytes retain their order. Python exposes that representation as .bytes_le.

Step Bytes
session.bytes_le 5d638b4735c3124cb1de16e0565be335
session.bytes_le[::-1] 35e35b56e016deb14c12c335478b635d
Bootstrap XOR reversed session bytes 614d587848636e4980454964586c756c

Render that last byte sequence with uuid.UUID(bytes_le=...) and the result is:

text codewrap
78584d61-6348-496e-8045-4964586c756c

Reversing the printed GUID string is a different operation. So is reversing uuid.UUID(...).bytes. Either gives the wrong seed.

This recovers the capture machine's GUID, not your own registry value.

The native seed mixer occupies host RVA 0xC65FF through 0xC6692. It reads the machine bytes as two little-endian 64-bit words:

text codewrap
lo = 0x496E634878584D61
hi = 0x6C756C5864494580

MUL and ADD below are the Knuth MMIX LCG constants, also used as PCG's default 64-bit multiplier and increment. This transform takes the top byte directly, without PCG's output permutation. The complete Python reproduction of the seed mixer and transform from recover_flag.py is:

python codewrap
MASK64 = (1 << 64) - 1
MUL = 0x5851F42D4C957F2D
ADD = 0x14057B7EF767814F


def native_seed(machine_guid_bytes_le: bytes) -> int:
    lo = int.from_bytes(machine_guid_bytes_le[:8], "little")
    hi = int.from_bytes(machine_guid_bytes_le[8:], "little")
    x = (lo ^ (hi + (lo << 6) + (lo >> 2) - 0x61C8864680B583EB)) & MASK64
    t = (0xFF51AFD7ED558CCD * (x ^ (x >> 33))) & MASK64
    u = (0xC4CEB9FE1A85EC53 * (t ^ (t >> 29))) & MASK64
    return u ^ (u >> 32)


def transform(data: bytes, state: int) -> tuple[bytes, int]:
    out = bytearray(len(data))
    for index, byte in enumerate(data):
        state = (state * MUL + ADD) & MASK64
        out[index] = byte ^ (state >> 56)
    return bytes(out), state

For this machine GUID, native_seed returns:

text codewrap
0xC4BFC80D59D86B9B

Keep the full state, then take its top byte

The native transform starts at VA 0x1400C65E0. At 0x1400C65EA it loads global state qword_1407FFA20. The branch at 0x1400C65F9 skips seed construction when that state already exists.

The byte loop is at VA 0x1400C6699 through 0x1400C66E3, starting at file offset 0xC5A99:

x86asm codewrap
1400c6699: xor     ecx, ecx
1400c669b: test    edi, edi
1400c669d: jz      short loc_1400C66E5
1400c669f: mov     r8, 5851F42D4C957F2Dh
1400c66a9: mov     r9, 14057B7EF767814Fh
1400c66b3: nop     dword ptr [rax+00h]
1400c66b7: nop     word ptr [rax+rax+00000000h]
1400c66c0: imul    rax, r8
1400c66c4: lea     rdx, [rax+r9]
1400c66c8: mov     cs:qword_1407FFA20, rdx
1400c66cf: shr     rdx, 38h
1400c66d3: xor     [rbx+rcx], dl
1400c66d6: inc     ecx
1400c66d8: cmp     ecx, edi
1400c66da: jnb     short loc_1400C66E5
1400c66dc: mov     rax, cs:qword_1407FFA20
1400c66e3: jmp     short loc_1400C66C0

RBX points to the buffer. EDI is its active length. ECX is the byte index, and RAX holds the current generator state.

The multiply and add wrap at 64 bits:

text codewrap
state = (state * 0x5851F42D4C957F2D + 0x14057B7EF767814F) mod 2^64

The code saves that full state before shifting the temporary in RDX. 38h is 56 decimal, so DL becomes the updated state's top byte:

text codewrap
output_byte = input_byte XOR (state >> 56)

This is a 64-bit linear congruential generator used as an XOR stream. There's no authentication tag. So much for the AES-GCM and ChaCha20-Poly1305 guesses.

The constants matter less than the fact that the state is global: it survives the return, and both directions share it.

Decrypt the records in capture order

Commands and replies take turns in one stream. It contains four encrypted records:

Record Active bytes Keystream interval State after record
Frame 651, BANANA comment 18 [0,18) 0xADC977B0537D0DD5
Frame 672, directory stdout 530 [18,548) 0xE0A7ED13C894471F
Frame 800, BENIGN comment 28 [548,576) 0xAA2878B62761D1DB
Frame 821, file stdout 46 [576,622) 0x6E17AF91D88B5E71

Reconstruction of the bootstrap GUID recovery and four records sharing one continuous LCG state

The record-chain diagram: commands and stdout take turns consuming the same stream.

Frame 468 supplies the bootstrap material. It doesn't consume the first 16 bytes of this encrypted stream.

Starting at the recovered seed, frame 651 decrypts to:

text codewrap
cmd:cmd.exe;/c dir

The resulting state is 0xADC977B0537D0DD5. Continue from there for exactly 530 bytes of frame 672.

The recovered output, with its original CRLF line endings displayed as ordinary lines, is:

text codewrap
 Volume in drive Z is Shared Folders
 Volume Serial Number is 0000-0064

 Directory of Z:\CTFs\FlareOn\SpaceInvaders\space-invaders\src\SpaceInvaders\bin\Release\net10.0-windows\publish\win-x64

15/09/2026  19:22              .
23/08/2026  10:30              ..
15/09/2026  19:34               324 config.json
30/08/2026  23:23                46 flag.txt
15/09/2026  17:29       116.238.508 ThreatInvaders.exe
               3 File(s)    116.238.878 bytes
               2 Dir(s)  216.322.199.552 bytes free

The listing puts flag.txt at 46 bytes, matching the final telemetry payload's active length.

The state is now 0xE0A7ED13C894471F. Use it on frame 800's 28-byte comment:

text codewrap
cmd:cmd.exe;/c type flag.txt

Then decrypt the final 46 bytes starting from 0xAA2878B62761D1DB.

Sol tried resetting the state. Decrypting BENIGN from the initial seed produced:

text codewrap
833d08badd2b9c6178c01fe6f0f2a60073e542e46ba3b02fa264dbd4

Skipping the directory output and decrypting either BENIGN or the final payload immediately after BANANA also produced garbage.

By the time the final record arrives, the generator has consumed 18 + 530 + 28 = 576 bytes. Starting fresh fails. Consuming the entire 4,096-byte padded directory buffer also fails, because those padding bytes were never part of the encrypted stdout.

So if BANANA decrypts and the rest doesn't, the seed's fine. You've lost your place in the stream.

Replay the recovery without running the game

Sol's recovery script uses only Python's standard library:

powershell codewrap
python re-analysis/invaderscrypto/recover_flag.py re-analysis/invaderstraffic/decoded

Or use the freshly regenerated HTTP bodies:

powershell codewrap
python re-analysis/invaderscrypto/recover_flag.py re-analysis/invaderstraffic/decoded-followalong

It validates the input before attempting the chronological decode. These are the frame-loading and command-selection helpers from the script:

python codewrap
import argparse
import base64
import hashlib
import json
import uuid
from pathlib import Path


def load_frame(root: Path, number: int) -> dict:
    obj = json.loads((root / f"frame-{number}.json").read_text(encoding="utf-8"))
    if not isinstance(obj, dict):
        raise ValueError(f"Frame {number} must contain a JSON object")
    return obj


def load_phase(root: Path, number: int, expected_length: int, expected_tagged_length: int) -> tuple[dict, bytes]:
    obj = load_frame(root, number)
    if obj["feedback_length"] != expected_tagged_length:
        raise ValueError(f"Frame {number}: unexpected tagged feedback_length")
    packed = base64.b64decode(obj["feedback_b64"], validate=True)
    if len(packed) != 4096 or any(packed[expected_length:]):
        raise ValueError(f"Frame {number}: expected zero-padded 4096-byte feedback")
    return obj, packed[:expected_length]


def load_score_comment(root: Path, number: int, expected_name: str, expected_length: int) -> bytes:
    entry = load_frame(root, number)["entries"][0]
    if entry["player_name"] != expected_name:
        raise ValueError(f"Frame {number}: expected the {expected_name} entry at rank one")
    name4 = entry["player_name"].encode("utf-8")[:4]
    if len(name4) != 4 or (entry["score"] ^ int.from_bytes(name4, "little")) != 0x3A646D63:
        raise ValueError(f"Frame {number}: native score/name selector is not 'cmd:'")
    comment = base64.b64decode(entry["comment_b64"], validate=True)
    if len(comment) != expected_length:
        raise ValueError(f"Frame {number}: expected {expected_length}-byte comment")
    return comment


def parsed_command(plaintext: bytes, name: str) -> str:
    if not plaintext.startswith(b"cmd:") or b";" not in plaintext[4:]:
        raise ValueError(f"{name}: decrypted bytes lack native 'cmd:' / ';' command framing")
    path, _, argument = plaintext[4:].partition(b";")
    if not path or not argument:
        raise ValueError(f"{name}: empty command path or argument")
    return path.decode("ascii") + " " + argument.decode("ascii")

Together with MASK64, MUL, ADD, native_seed, and transform shown earlier, this is the remaining recovery path:

python codewrap
def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("decoded", type=Path, help="directory of frame-N.json captured HTTP bodies")
    args = p.parse_args()
    root = args.decoded

    phase1, nonce = load_phase(root, 468, 16, 0x2010)
    phase2, directory_ciphertext = load_phase(root, 672, 530, 0x4212)
    phase3, flag_ciphertext = load_phase(root, 821, 46, 0x402E)

    if not (phase1["session_id"] == phase2["session_id"] == phase3["session_id"]):
        raise ValueError("Phase requests belong to different sessions")

    digest = hashlib.sha256(directory_ciphertext).hexdigest()
    if digest != "45f3e775c701a9d59f5694df495e4c5960fe1197287f2c092afa290e31cc2430":
        raise ValueError(f"Frame 672: SHA-256 mismatch: {digest}")

    if flag_ciphertext.hex() != "4a275439d3d2f48e83716a4243be8e266db6bf7554b36fd1cef652b54bfac70f5bfaa393893afd6c24cd4d3bb203":
        raise ValueError("Frame 821: ciphertext mismatch")

    session = uuid.UUID(phase1["session_id"])
    machine_le = bytes(a ^ b for a, b in zip(nonce, session.bytes_le[::-1]))
    state = native_seed(machine_le)
    print(f"MachineGuid: {uuid.UUID(bytes_le=machine_le)} (raw={machine_le.hex()}); initial state={state:#018x}")

    banana = load_score_comment(root, 651, "BANANA", 18)
    command, state = transform(banana, state)
    print(f"BANANA command: {parsed_command(command, 'BANANA')} ; state={state:#018x}")

    directory, state = transform(directory_ciphertext, state)
    listing = directory.decode("ascii")
    if "flag.txt" not in listing:
        raise ValueError("Frame 672: directory listing does not contain flag.txt")
    print(f"Directory output ({len(directory)} bytes):\n{listing}")
    print(f"State after directory: {state:#018x}")

    benign = load_score_comment(root, 800, "BENIGN", 28)
    command, state = transform(benign, state)
    print(f"BENIGN command: {parsed_command(command, 'BENIGN')} ; state={state:#018x}")

    flag, state = transform(flag_ciphertext, state)
    if not flag.endswith(b"@flare-on.com"):
        raise ValueError("Frame 821: decoded output is not a FLARE-On flag")
    print(f"FLAG ({len(flag)} bytes): {flag.decode('ascii')} ; state={state:#018x}")


if __name__ == "__main__":
    main()

The variable named nonce holds the bootstrap XOR record, not an AEAD nonce. The script refuses to run if the frames don't match the ones above.

The decode works through these values (output trimmed and reformatted, flag omitted):

text codewrap
MachineGuid: 78584d61-6348-496e-8045-4964586c756c
raw=614d587848636e4980454964586c756c
initial state=0xc4bfc80d59d86b9b

BANANA command: cmd.exe /c dir
state=0xadc977b0537d0dd5

Directory output: 530 bytes
State after directory: 0xe0a7ed13c894471f

BENIGN command: cmd.exe /c type flag.txt
state=0xaa2878b62761d1db

Final output: 46 bytes
state=0x6e17af91d88b5e71

CTFd accepted the final plaintext. A long strange trip, according to the flag. After watching Muse spend five and a half hours here, I can't argue with that.

Accepted flag: