GhostStream

  • Category: rev

“Passing the baseline test was clearly a statistical fluke, so we’ve locked you in with a haunted executable and a botched vial of PolyjuicePotion.txt. It refuses to talk to the naked eye, and you aren’t getting your nutrient paste until your debugger forces a confession out of it.”

GhostStream.exe is a GUI parent that hides its payload in an NTFS alternate data stream and only decodes it after a week of uptime, then pipes it to a dropped child that does the actual flag decryption. Nothing runs usefully “as-is” — the whole challenge is spotting the three gates that keep the payload dark and stepping over them. The theme is Harry Potter spells; the flag, fittingly, is not.

Solution

1. The payload lives in an alternate data stream

The parent builds its input path with a format string, "%sPolyjuicePotion.txt", but the branch that actually opens the file appends :LordVoldemort — an NTFS alternate data stream on PolyjuicePotion.txt. That branch is gated by dat_140006668, a global that is never written anywhere in the binary, so it is always 0 and the ADS is never read. Forcing it to 1 (or just reading PolyjuicePotion.txt:LordVoldemort directly) is gate one. The stream holds AlbusDumbledore \r\n.

2. The WM_CREATE handler is time-locked to 7 days

Decoding happens inside the window procedure’s WM_CREATE case, behind an uptime check:

uptime_minutes > len("GhostStream.exe") * 0x29f + 0xf
             = 15 * 671 + 15 = 10080 minutes = 7 days

So the program must believe it has been running for a week before it will touch the stream. In a debugger you patch the comparison (or fake GetTickCount); the point of the check is just to keep casual execution from ever reaching the decode.

3. Recovering the named-pipe key

The decode XORs the ADS bytes against a value derived from the previous window message. WM_CREATE (0x1) is preceded by WM_NCCALCSIZE (0x83), and each byte is XORed with 0x01 ^ 0x83, then the first 15 bytes are XORed with a hardcoded 15-byte table. AlbusDumbledore \r\n collapses to the string HarryPotter, which is the key the parent uses to talk to the child over \\.\pipe\FlareChallenge.

ads = b"AlbusDumbledore \r\n"
K15 = bytes.fromhex("8b8f92858896989b948b95e6edf0e7")
buf = bytearray(ads)
for i in range(len(buf)):
    buf[i] ^= 0x01 ^ 0x83            # WM_CREATE ^ preceding WM_NCCALCSIZE
for i in range(15):
    buf[i] ^= K15[i]
key = bytes(buf).split(b"\0")[0]     # -> b"HarryPotter"
4. The child hides its second key inside a hooked strlen

The child (Partner_CTF.exe, dropped from resource BIN/101) hot-patches ucrtbase!strlen. The hook rewrites its argument in place — s[i] = ((s[i] + i + 1) & 0xff) ^ 0x13 — and then always returns 0x13 regardless of the real length. Running that transform on the stored blob Bxs]bv^u)TpcnolVjN yields the second key, Piertotum Locomotor`:

def hooked_strlen_transform(s):
    return bytes(((c + i + 1) & 0xFF) ^ 0x13 for i, c in enumerate(s))

key2 = hooked_strlen_transform(b"Bxs]bv`^u)TpcnolVjN")   # -> b"Piertotum Locomotor"

The poisoned 0x13 return value is the “confession” the prompt wants: the decryption needs the true strlen("Wingardium Leviosa") = 18, which the hook has hidden. Supplying L = 18 is what a debugger (or careful static reading) forces out.

5. The RC4 variant

The final cipher is RC4 keyed with HarryPotter, but with three tweaks driven by the values above: the PRGA advances i by L (18) instead of 1, and each output byte is additionally XORed with key2 (cycled) and with (L + ciphertext_byte) & 0xff. Standard KSA, modified PRGA:

S = list(range(256)); j = 0
for i in range(256):
    j = (j + key[i % len(key)] + S[i]) & 0xFF
    S[i], S[j] = S[j], S[i]
i = j = 0; out = bytearray()
for k, c in enumerate(ct):
    i = (i + L) & 0xFF; x = S[i]; j = (j + x) & 0xFF
    S[i], S[j] = S[j], x
    out.append(S[(S[i] + x) & 0xFF] ^ key2[k % len(key2)] ^ ((L + c) & 0xFF))

Full script: solve.py.

Flag: y0u_kn0w_n07h1n6_j0n_5n0w@flare-on.com