flare-on 13: GhostStream
A WIM with an NTFS alternate data stream, a seven-day gate, and a child process that sabotages its own strlen.
Me: 14:28, 18.64M tokens, $4.89, stepped in 3 times.
Truix: 4:41, 2.85M tokens, $1.50, stepped in once.
I wasn't expecting a Harry Potter challenge in FLARE-On to end with a Game of Thrones reference. Getting there took a hidden NTFS stream, a seven-day wait Sol wasn't going to sit through, and a strlen that lies.
The parent reads the potion text file or its hidden stream, then transforms the buffer in a window procedure. It sends the result over \\.\pipe\FlareChallenge as a key for its embedded child executable. The child decrypts a 38-byte blob using an RC4 variant, a repeating spell string, and a string length. Two parent gates stand in the way, and the child lies about that length.
Sol patched both parent gates to reach the transform and capture the pipe key.
Keep the alternate stream
GhostStream came in a WIM image with an executable and a text file about Polyjuice Potion. The text says Ron added too much boomslang skin and the transformation now lasts exactly seven days. Sol found the hidden stream almost immediately, then spent much longer getting the program to use it.
Extract this onto NTFS or you lose the stream. Sol used these commands:
powershell codewrap
& 'C:\Program Files\7-Zip\7z.exe' x -y -pflare 2-GhostStream.7z
& 'C:\Program Files\7-Zip\7z.exe' l -slt GhostStream.wim
& 'C:\Program Files\7-Zip\7z.exe' x -y GhostStream.wim
The WIM listing identifies the extra file data before Sol even opens the executable:
text codewrap
Path = GhostStream\PolyjuicePotion.txt
Folder = -
Size = 316
Alternate Stream = -
Alternate Streams = 1
The extraction reported:
text codewrap
Folders: 1
Files: 2
Alternate Streams: 1
Alternate Streams Size: 18
Size: 35644
Copying the visible text onto FAT or exFAT loses the named stream. If your extraction produces only the ordinary file contents, fix that before debugging anything.
In Windows PowerShell 5.1:
powershell codewrap
Get-Item -LiteralPath 'GhostStream/PolyjuicePotion.txt' -Stream * |
Format-Table Stream,Length
[BitConverter]::ToString(
[byte[]](Get-Content -LiteralPath 'GhostStream/PolyjuicePotion.txt' `
-Stream 'LordVoldemort' -Encoding Byte)
)
The result is:
text codewrap
Stream Length
------ ------
:$DATA 316
LordVoldemort 18
41-6C-62-75-73-44-75-6D-62-6C-65-64-6F-72-65-20-0D-0A
Those 18 bytes are:
python codewrap
b"AlbusDumbledore \r\n"
Keep the space before \r\n. AlbusDumbledore is 15 bytes, the space makes 16, and CRLF makes 18. The space matters for Sol's first attempt to use the stream contents directly as a pipe key.
File hashes
The archive, WIM, parent, and extracted child have these SHA-256 values:
| File | SHA-256 |
|---|---|
2-GhostStream.7z |
52f4c307c62169699523219def0e36f523a8b8e31e192972a0c234031d42c413 |
GhostStream.wim |
6c5cd2798e359a9196118617bdcc71afede1b0916dc6e713b9af4e5c3a716469 |
GhostStream/GhostStream.exe |
20a879a65d0408de57a6b94dcf011b5eaa52527673f52c3e18574038c7f07ff2 |
GhostStream/Partner_CTF.exe |
4df12c144f031e324e2f53ccfe38ade3460913aefaa2f785d072549300218af3 |
Partner_CTF.exe is the child executable embedded in the parent's BIN/101 resource. Both are x64 Windows PEs. The child is 14,848 bytes.
Pull out the child without running the parent
Sol initially tried extracting .rsrc/BIN/101 with 7-Zip. That reported No files to process. Trying the name 101 did the same thing. Extracting everything produced the child's sections under GhostStream/peparts, rather than a usable copy of the whole embedded PE.
The listing showed what was happening:
text codewrap
Path = .rsrc\BIN\101
Size = 14848
Packed Size = 14848
--
Path = .rsrc\BIN\101
Type = PE
Physical Size = 14848
CPU = x64
7-Zip had descended into the embedded executable. Sol moved to pefile, which was already installed:
powershell codewrap
py -3.12 -c "import pefile; print(pefile.__version__)"
The installed version was 2024.8.26. Sol extracted the child with:
python codewrap
import pefile, pathlib
pe = pefile.PE('GhostStream/GhostStream.exe')
for e in pe.DIRECTORY_ENTRY_RESOURCE.entries:
for e2 in e.directory.entries:
for e3 in e2.directory.entries:
d = e3.data.struct
print(e.name or e.id, e2.name or e2.id, d.OffsetToData, d.Size)
if d.Size == 14848:
pathlib.Path('GhostStream/Partner_CTF.exe').write_bytes(
pe.get_data(d.OffsetToData, d.Size)
)
It reported:
text codewrap
BIN 101 32944 14848
24 1 47792 381
OffsetToData is the resource data RVA. pe.get_data() handles the RVA-to-file translation. This reads the parent and writes a separate child file; it doesn't launch either executable.
Sol did launch the parent for a baseline. After roughly ten seconds, it produced:
text codewrap
[*] Try harded to find the secrets.
[+] Execution complete.
Press any key to continue . . .
[Partner] Process started.
[FAILED] Try again with better debugging
There's no need to repeat that launch to recover the flag.
Around here, Sol was also hunting for my IDA installation and IDASQL, which exposes an IDA database through SQL. The executable reader couldn't import ida_domain and idapro, idasql --version returned command not found, and a glob across my user directory timed out. It fell back to Capstone while looking.
At 23:10:03 ET I pointed it at the install:
idasql is at [my IDA folder] btw
Follow the parent's filename selector
The image base is 0x140000000 for both executables. Keep the three address forms separate:
| Location | VA in IDA | RVA | File offset |
|---|---|---|---|
| Parent ADS selector | 0x140001A8C |
0x1A8C |
0xE8C |
| Parent age comparison | 0x140001BAA |
0x1BAA |
0xFAA |
Child strlen call |
0x1400014F7 |
0x14F7 |
0x8F7 |
| Child success branch | 0x1400016A4 |
0x16A4 |
0xAA4 |
The RVA is VA - image_base. A file offset also depends on the PE section mapping. Seeking to file offset 0x14F7 won't land on the child's call instruction.
In IDA, load an unaltered working copy, press G, and enter the full VA. Space switches to text view, X follows references, and F5 gives pseudocode if the decompiler is available.
Sol's IDASQL queries pulled out the two parent branches. I've shortened the executable path to idasql:
powershell codewrap
$analysis = Join-Path $env:TEMP ([Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $analysis | Out-Null
Copy-Item -LiteralPath 'GhostStream/GhostStream.exe' `
-Destination (Join-Path $analysis 'GhostStream.exe')
idasql -s (Join-Path $analysis 'GhostStream.exe') -q "SELECT * FROM binary; SELECT disasm_range(0x140001A8C,0x140001AA5); SELECT disasm_range(0x140001BA6,0x140001BB7);"
The binary result should have the original parent hash, 20a879...7f07ff2, rather than one of the diagnostic copies.
The selector is small:
x86asm codewrap
; GhostStream.exe
; VA 0x140001A8C, RVA 0x1A8C, file offset 0xE8C
140001a8c: cmp cs:dword_140006668, 1
140001a93: jnz short loc_140001AC0
140001a95: mov r8, [rdi+10h]
140001a99: mov rdx, [rdi+8]
140001a9d: mov rcx, [rdi]
140001aa0: call sub_140001130
Unless the global DWORD equals exactly 1, jnz skips the decode-and-format path. The ordinary filename is:
text codewrap
%sPolyjuicePotion.txt
The gated path decodes:
text codewrap
%sPolyjuicePotion.txt:LordVoldemort
The %s is the parent executable's directory. The decoder also uses the executable's basename, so preserve GhostStream.exe when making a diagnostic copy.
GhostRuntime, the subagent handling the parent's stream and runtime gates, later reproduced that filename decode. The 35 encoded bytes come from immediates at parent RVA 0x1F1B through 0x1F59:
text codewrap
f2401d3c394637423630321d3c41363c3bfb41454107193c3f31233c3931323a3c3f41
The decoder at VA 0x140001130 sums those bytes modulo 256. The sum is 0xE1, which selects mode 2. Its filename-dependent value is:
text codewrap
filename = GhostStream.exe
filename[0] = G
filename[len(filename) >> 1] = r
G XOR r = 0x35
For that mode, each output byte is:
text codewrap
(0x35 + encoded_byte - 2) & 0xFF
The resulting bytes are:
text codewrap
2573506f6c796a75696365506f74696f6e2e7478743a4c6f7264566f6c64656d6f7274
Which gives the ADS filename above. The same decoder recovers \\.\pipe\FlareChallenge from another constant.
Sol initially applied the decoder's other formula, (encoded_byte + 1) XOR filename_value, to this input. That was the wrong mode. Checking the 0xE1 checksum against the switch fixed it. Renaming the parent to a.exe also produces garbage because it changes the filename-derived value.
An xref query for dword_140006668 found its read at the comparison, with no ordinary initialization setting it to 1. In GhostStream/debug/GhostStream.exe, Sol changed the comparison's immediate byte at RVA 0x1A92 from 01 to 00, so the default zero now selected the ADS path.
Seven days means process age
Seven days sounded excessive to me. The calculation in main takes the potion clue literally:
x86asm codewrap
; GhostStream.exe, original Capstone listing
; Length calculation and multiplication
140002054 mov rax, rbx
140002057 inc rax
14000205a cmp byte ptr [rcx + rax], 0
14000205e jne 0x140002057
140002060 mov edx, 0x5c
140002065 imul esi, eax, 0x29f
Later:
x86asm codewrap
140002234 mov r8d, 1
14000223a add esi, 0xf
14000223d call qword ptr [rip + 0x1e15]
GhostStream.exe is 15 characters long. 0x29F is 671, so the threshold is:
text codewrap
15 * 671 + 15 = 10080 minutes
10080 / 60 / 24 = 7 days
The helper at VA 0x140001680 resolves time APIs through GetProcAddress at 0x140001720 and 0x1400017EA. It obtains the current process handle at 0x140001732, collects its creation time through the call at 0x140001754, and subtracts that FILETIME from the current FILETIME.
The decompiled assignment is:
c codewrap
/* 14000182E */ qword_140006670 = (v11 - v12) / 0x23C34600uLL;
0x23C34600 is 600000000, the number of 100-nanosecond FILETIME ticks in a minute. The value is whole process-age minutes. Changing the WIM timestamp wouldn't help.
main loads that value at 0x1400022DF and passes it to the window procedure alongside the threshold. The relevant window-procedure data fields are:
Offset from r9 |
Use on this path |
|---|---|
+0x00 |
Pointer to the file/pipe-key buffer |
+0x08 |
Pointer to a separate buffer used by the other age branch |
+0x10 |
Elapsed process-age minutes |
+0x18 |
Threshold, 10080 |
+0x20 |
Previous GUI message value |
The decision is:
x86asm codewrap
; GhostStream.exe
; VA 0x140001BA6, RVA 0x1BA6
; cmp at VA 0x140001BAA, file offset 0xFAA
140001ba6: mov rax, [r9+18h]
140001baa: cmp [r9+10h], rax
140001bae: jbe loc_140001C34
140001bb4: mov rdx, [r9]
jbe is unsigned less-than-or-equal. The transform path therefore requires elapsed time greater than 10080, not equal to it.
The other path mutates the separate buffer and puts it in the SESSION_TOKEN_CACHE environment variable with SetEnvironmentVariableA.
There's also a listener on 127.0.0.1:31337 accepting OVERRIDE:. It XORs the supplied bytes with 0x33, then adds 5, writing the result into the key buffer. Sol tried override-derived keys, which looked promising until GhostRuntime followed the next write: the main thread sleeps for ten seconds, then reads the key file into that same buffer. The file read overwrites the socket-provided bytes before the GUI transform.
Turn Dumbledore into the pipe key
The window procedure remembers the previous message and combines its low byte with the current message:
x86asm codewrap
; GhostStream.exe, original Capstone listing
140001b73 mov eax, dword ptr [r9 + 0x20]
140001b77 mov dword ptr [r9 + 0x20], ebx
140001b7b movzx r11d, bl
140001b7f xor r11b, al
140001b82 cmp ebx, 1
140001b85 je 0x140001b9d
The transform happens on message 1, WM_CREATE. After the age comparison, the first loop XORs the string with the byte in r11b. Then it applies this mask to the first 15 bytes:
x86asm codewrap
140001bf7 xor byte ptr [rdx], 0x8b
140001bfa xor byte ptr [rdx + 1], 0x8f
140001bfe xor byte ptr [rdx + 2], 0x92
140001c02 xor byte ptr [rdx + 3], 0x85
140001c06 xor byte ptr [rdx + 4], 0x88
140001c0a xor byte ptr [rdx + 5], 0x96
140001c0e xor byte ptr [rdx + 6], 0x98
140001c12 xor byte ptr [rdx + 7], 0x9b
140001c16 xor byte ptr [rdx + 8], 0x94
140001c1a xor byte ptr [rdx + 9], 0x8b
140001c1e xor byte ptr [rdx + 0xa], 0x95
140001c22 xor byte ptr [rdx + 0xb], 0xe6
140001c26 xor byte ptr [rdx + 0xc], 0xed
140001c2a xor byte ptr [rdx + 0xd], 0xf0
140001c2e xor byte ptr [rdx + 0xe], 0xe7
140001c32 jmp 0x140001c84
Sol first forced only the ADS selector in a diagnostic parent. The child received AlbusDumbledore , including the space, and still failed. It was reading the right stream now, but skipping the age-gated transformation.
In a later diagnostic run, Sol replaced the load at RVA 0x22DF (file offset 0x16DF) with mov eax,10081; nop; nop: 48 8b 05 8a 43 00 00 became b8 61 27 00 00 90 90. That made the value passed to the window procedure exceed the threshold. The captured child key was:
text codewrap
key 11 b'HarryPotter'
The full reconstructed transformed buffer is:
text codewrap
48 61 72 72 79 50 6f 74 74 65 72 00 00 00 00 a2 8f 88
The first NUL is at index 11. The parent sends the effective C-string HarryPotter through \\.\pipe\FlareChallenge.
Working backward from that captured key and the original mask gives a previous-message low byte of 0x83, making the first XOR byte 0x83 ^ 1 = 0x82. That fits WM_NCCALCSIZE; Sol recovered it from the key rather than logging the message itself.
Sol's short reconstruction reproduces the NUL-terminated key:
python codewrap
ads = b"AlbusDumbledore \r\n"
mask = bytes.fromhex("8b 8f 92 85 88 96 98 9b 94 8b 95 e6 ed f0 e7")
observed_previous_message = 0x83 # inferred from the previously recorded forced-gate run
wire = bytes(a ^ (observed_previous_message ^ 1) ^ m for a, m in zip(ads, mask))
print(wire.split(b"\0", 1)[0])
# b'HarryPotter'
zip() stops after the 15 mask bytes. That covers the part containing the entire resulting C-string and its first four NULs. The full GUI transform also touches the remaining input bytes, which explains the a2 8f 88 tail in the longer buffer.
The child replaces strlen
The child reads from the pipe around VA 0x1400010E2 through 0x140001211. Its key schedule at 0x14000139E through 0x1400013EC builds a 256-byte RC4 state from that input. The output loop also uses a repeating spell string as an XOR input and a length returned by strlen.
That strlen is lying.
Open the original Partner_CTF.exe, rather than Partner_Debug.exe, Partner_19.exe, or Partner_18.exe. Sol's IDASQL command:
powershell codewrap
$analysis = Join-Path $env:TEMP ([Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $analysis | Out-Null
Copy-Item -LiteralPath 'GhostStream/Partner_CTF.exe' `
-Destination (Join-Path $analysis 'Partner_CTF.exe')
idasql -s (Join-Path $analysis 'Partner_CTF.exe') -q "SELECT * FROM binary; SELECT disasm_range(0x1400014F3,0x1400014FD); SELECT disasm_range(0x140001699,0x1400016AD);"
The call looks ordinary:
x86asm codewrap
; Partner_CTF.exe
; strlen call VA 0x1400014F7, RVA 0x14F7, file offset 0x8F7
1400014f3: lea rcx, [rbp+310h+Str]; Str
1400014f7: call cs:strlen
On Windows x64, rcx holds the first argument and rax receives the return value. The local string was copied from Wingardium Leviosa, an 18-byte ASCII literal at VA 0x140003380.
But the child has already called VirtualProtect with 0x40 (PAGE_EXECUTE_READWRITE) to make the code at ucrtbase!strlen writable, then overwritten its first 23 bytes. These are the original stores:
x86asm codewrap
; Partner_CTF.exe, original Capstone listing
; r14 holds the strlen code address
140001412 mov rcx, r14
140001415 mov edx, 0x17
14000141a mov r8d, 0x40
140001420 call qword ptr [rip + 0x1bea]
140001426 movups xmm6, xmmword ptr [r14]
14000142a mov ebx, dword ptr [r14 + 0x10]
14000142e lea rdx, [rbp + 0x90]
140001435 movzx edi, word ptr [r14 + 0x14]
14000143a lea rcx, [rip + 0x1f2f]
140001441 movzx esi, byte ptr [r14 + 0x16]
140001446 xorps xmm0, xmm0
140001449 mov dword ptr [r14], 0x1304c031
140001450 mov r8d, 0x40
140001456 mov dword ptr [r14 + 4], 0x17501b2
14000145e mov dword ptr [r14 + 8], 0x301100e8
140001466 mov dword ptr [r14 + 0xc], 0x48c2fe01
14000146e mov dword ptr [r14 + 0x10], 0x2138c1ff
140001476 mov word ptr [r14 + 0x14], 0xf375
14000147d mov byte ptr [r14 + 0x16], 0xc3
Written little-endian, those immediates produce:
text codewrap
31 c0 04 13 b2 01 75 01 e8 00 11 30 01 fe c2 48 ff c1 38 21 75 f3 c3
There is an overlapping-instruction trick in those bytes. A linear disassembly sees:
text codewrap
+0x00 xor eax, eax
+0x02 add al, 0x13
+0x04 mov dl, 1
+0x06 jne +0x09
+0x08 call ...
The branch at +0x06 enters at +0x09, inside what the linear sweep interpreted as a call. The bytes there are 00 11 30 01: add dl to the byte at rcx, then XOR that byte with al. dl increments for each character. al remains 0x13, so the return value is 19.
This also explains the spell string Sol initially got wrong. Before measuring the local Wingardium Leviosa buffer, the child calls the modified routine on its global string at VA 0x140005078. The original disk bytes are:
text codewrap
42 78 73 5d 62 76 60 5e 75 29 54 70 63 6e 6f 6c 56 6a 4e
The byte-level operation is an incrementing add followed by XOR 0x13. For example, the first byte becomes (0x42 + 1) ^ 0x13 = 0x50, ASCII P. At runtime the whole string becomes:
text codewrap
50 69 65 72 74 6f 74 75 6d 20 4c 6f 63 6f 6d 6f 74 6f 72
Piertotum Locomotor
Sol measured that runtime value at 23:15:29. Its earlier offline model had used the original disk literal:
text codewrap
Bxs]bv`^u)TpcnolVjN
That model couldn't produce the right answer even with the correct pipe key.
After both calls, the child restores the saved CRT bytes and keeps the second return value in r12:
x86asm codewrap
1400014ed call qword ptr [rip + 0x1d1d]
1400014f3 lea rcx, [rbp + 0x70]
1400014f7 call qword ptr [rip + 0x1d13]
1400014fd movups xmmword ptr [r14], xmm6
140001501 mov dword ptr [r14 + 0x10], ebx
140001505 lea r9, [rsp + 0x68]
14000150a mov word ptr [r14 + 0x14], di
14000150f mov edx, 0x17
140001514 mov byte ptr [r14 + 0x16], sil
140001518 mov rcx, r14
14000151b mov r8d, dword ptr [rsp + 0x68]
140001520 mov r12, rax
140001523 call qword ptr [rip + 0x1ae7]
At 23:19:28, Sol captured the decoded global and the bogus length together:
text codewrap
global b'Piertotum Locomotor\x00\x00\x00\x00\x00\x00' R12 19
The length changes two parts of the cipher
The ciphertext is constructed from immediates in main, rather than stored as one obvious string:
x86asm codewrap
; Partner_CTF.exe, original Capstone listing
140001211 mov dword ptr [rsp + 0x40], 0x35875c96
140001219 mov r8d, 0xda
14000121f mov dword ptr [rsp + 0x44], 0x8b397ad7
140001227 lea rcx, [rbp + 0xf6]
14000122e mov dword ptr [rsp + 0x48], 0x286d0475
140001236 mov dword ptr [rsp + 0x4c], 0xc85856bb
14000123e mov dword ptr [rsp + 0x50], 0xe1daee35
140001246 mov dword ptr [rsp + 0x54], 0x37a956a4
14000124e mov dword ptr [rsp + 0x58], 0xf9561581
140001256 mov dword ptr [rsp + 0x5c], 0xd340a27
14000125e mov dword ptr [rsp + 0x60], 0x262c82ee
140001266 mov word ptr [rsp + 0x64], 0xf8a5
Packed little-endian, that is 38 bytes:
text codewrap
965c8735d77a398b75046d28bb5658c835eedae1a456a937811556f9270a340dee822c26a5f8
The key schedule is the usual RC4 permutation:
text codewrap
S = [0, 1, ..., 255]
j = 0
for i = 0..255:
j = (j + S[i] + key[i % key_length]) & 255
swap S[i], S[j]
The output loop, or PRGA (pseudo-random generation algorithm), differs from ordinary RC4 in two places. It advances i by the measured string length, and adds that same length to each ciphertext byte before the XORs. So that extra byte breaks the cipher twice. I'd stop trying new keys at this point.
The child processes two bytes per iteration, for 19 iterations. This portion shows the first byte's cyclic spell lookup and addition:
x86asm codewrap
1400015b3 mov rax, rsi
1400015b6 mov byte ptr [rcx], bl
1400015b8 movzx ecx, byte ptr [rdx]
1400015bb xor edx, edx
1400015bd div r10
1400015c0 add rcx, rbx
1400015c3 movzx eax, dil
1400015c7 add al, byte ptr [r11 - 1]
1400015cb movzx ecx, cl
1400015ce movzx ecx, byte ptr [rsp + rcx + 0x70]
1400015d3 xor cl, byte ptr [rdx + r13]
1400015d7 xor cl, al
1400015d9 lea eax, [r9 + rdi]
1400015dd mov byte ptr [r11 - 1], cl
div r10 leaves the position modulo the spell length in rdx. r13 points to Piertotum Locomotor, so [rdx+r13] selects the repeating spell byte. The low byte of rdi supplies the length-dependent addition. The next state index also advances by rdi.
Using HarryPotter, the correctly decoded spell, and length 19 produced this exact garbage:
text codewrap
09f230b24368fe8669a87c38901441fb6542e940ff7ad113880492267f07d2947510fa206e8c
That matched the failing decode. Sol kept trying keys and spell variants anyway, including LordVoldemort, Expelliarmus, Alohomora, and Avada Kedavra. It also swept possible GUI XOR bytes. None of those repaired the length parameter.
I gave the subagents credit too soon
At 23:18:56, Sol started GhostCipher, the subagent handling the cipher and child, alongside GhostRuntime on the parent.
At 23:19:33, I wrote:
can you make subagents for RE sooner? We're already 9m 30s on this challenge
They had already been spawned about 37 seconds earlier. Sol told me they were running, then continued working in the main agent.
The decisive parameter sweep was the main agent's own call at 23:20:06. It tried length values 1 through 255 against HarryPotter, the ADS key, the ordinary text, and LordVoldemort. The result was:
text codewrap
MATCH HarryPotter 18 b'y0u_kn0w_n07h1n6_j0n_5n0w@flare-on.com'
HarryPotter best [(38, 18), (22, 234), (22, 206)]
ADS best [(24, 149), (22, 169), (22, 93)]
text best [(23, 211), (23, 49), (22, 170)]
Lord best [(24, 201), (23, 118), (22, 231)]
Only the HarryPotter/18 combination produced the flag domain. It also produced 38 printable bytes out of 38.
At 23:20:20, I said:
Yes I'm saying make it a rule to utilize the RE subagents sooner, look at how fast that was
I was wrong - the main agent had the flag 27 seconds before either subagent reported. GhostCipher's final report arrived at 23:20:33 and GhostRuntime's at 23:22:00.
GhostCipher pinned down the disk-string/runtime-string distinction and the two-byte output loop. GhostRuntime recovered the encoded ADS filename, explained the GUI transformation, and ruled out the network override as a required extra key source. Good work, just too late to deserve the credit I gave it for the solve.
Reproduce the 18-byte decode
I'd take this over waiting seven days. Sol's read-only Python decoder doesn't need to run a PE, change a system clock, or create a named pipe.
The ciphertext comes from the original child immediates. The pipe key comes from the forced-gate parent capture and the ADS transformation. The spell comes from the runtime decoding above.
python codewrap
# Read-only Python 3 reconstruction: cipher parts are immediates at
# original Partner_CTF.exe main VA 0x140001211-0x1400012A8.
parts = [
(0x8B397AD735875C96, 8), (0xC85856BB286D0475, 8),
(0x37A956A4E1DAEE35, 8), (0x0D340A27F9561581, 8),
(640451310, 4), ((-1883) & 0xffff, 2),
]
cipher = b"".join(n.to_bytes(width, "little") for n, width in parts)
assert len(cipher) == 38
pipe_key = b"HarryPotter" # observed diagnostic parent-to-child pipe message
spell = b"Piertotum Locomotor" # observed after child's runtime XOR decoding
def decode(str_len):
state = list(range(256))
j = 0
for i in range(256):
j = (j + state[i] + pipe_key[i % len(pipe_key)]) & 255
state[i], state[j] = state[j], state[i]
i = j = 0
result = bytearray()
for n in cipher:
i = (i + str_len) & 255 # unusual child PRGA stride
j = (j + state[i]) & 255
state[i], state[j] = state[j], state[i]
value = ((n + str_len) & 255) ^ spell[len(result) % len(spell)]
result.append(value ^ state[(state[i] + state[j]) & 255])
return bytes(result)
print("pipe/key/cipher:", len(pipe_key), len(cipher)) # 11 38
print("natural length:", len(b"Wingardium Leviosa")) # 18
print("18-byte model:", decode(18).decode("ascii"))
print("19-byte model:", decode(19).hex())
The two lengths give:
text codewrap
pipe/key/cipher: 11 38
natural length: 18
18-byte model: y0u_kn0w_n07h1n6_j0n_5n0w@flare-on.com
19-byte model: 09f230b24368fe8669a87c38901441fb6542e940ff7ad113880492267f07d2947510fa206e8c
Changing str_len from 19 to 18 repairs both the PRGA stride and the ciphertext-byte addition. The 19-byte Piertotum Locomotor cycle stays unchanged.
This uses the natural length of the original literal. The unmodified child uses the sabotaged result, 19.
The success print was patched too
Sol then made Partner_18.exe, fed it HarryPotter, and got:
text codewrap
[SUCCESS] Decrypted Flag: y0u_kn0w_n07h1n6_j0n_5n0w@flare-on.com
Sol called that executable confirmation. That copy had a second patch, though.
The original verifier searches the output for @flare-on.com at VA 0x14000167B, then checks the returned pointer:
x86asm codewrap
; Partner_CTF.exe, original
; Branch VA 0x1400016A4, RVA 0x16A4, file offset 0xAA4
140001699: mov rsi, [rsp+410h+arg_0]
1400016a1: test rax, rax
1400016a4: jz short loc_1400016F4
1400016a6: lea rdx, [rbp+310h+var_240]
1400016ad: lea rcx, aSuccessDecrypt; "\n[SUCCESS] Decrypted Flag: %s\n"
The saved diagnostic copy changes two places:
| Child RVA / file offset | Original bytes | Partner_18.exe bytes |
Effect |
|---|---|---|---|
0x14F7 / 0x8F7 |
ff 15 13 1d 00 00 |
b8 12 00 00 00 90 |
Replace call strlen with mov eax,18; nop. |
0x16A4 / 0xAA4 |
74 4e |
90 90 |
Remove the conditional jump after strstr, allowing the success print even when the domain check fails. |
Its SHA-256 is:
text codewrap
dcb2fd5c446348bd726b5a1a859907dc2c8d35279a695d786838ab2b8f6fa3ce
The patch history explains the second change. At 23:13:27, Sol created Partner_Debug.exe by removing the success-check branch so it could see failed plaintext. At 23:13:56, it based Partner_19.exe on that already-patched copy and forced the length to 19. At 23:20:10, it based Partner_18.exe on Partner_19.exe and changed the immediate to 18.
The visibility patch survived every copy.
An earlier [SUCCESS] print even contains the 19-byte-model garbage. Removing the branch made it print success for that too.
For a clean native check, start from a fresh original child, change only the call at file offset 0x8F7, leave 74 4e intact at 0xAA4, and feed the captured pipe key. Sol didn't run that one-patch version.
Sol submitted the decoded flag at 23:20:27. The next browser read showed [*] Correct with challenge three unlocked.
All those spells, and the answer is "you know nothing, Jon Snow." Wrong franchise, but after my subagent victory lap, fair enough.
The flag: