Back to writeups
FlagYardrevHardsolved

Winter is coming

12 Jul 2026 · by 0xIDA

Solvers

Winter is coming

Author: 0xIDA
Platform: FlagYard · Category: rev · Difficulty: Hard

Solvers

Summary

32-bit ELF password checker that mmaps seven XOR-encrypted shellcode stages and runs each against a slice of the 39-byte input. Recover each stage's transform, invert it, then resolve stage-4's incomplete custom-base64 underconstraint among hex candidates against the platform.

Solution

Step 1: Triage and stage decrypt

Binary is a 32-bit PIE ELF. Main:

  1. Banner via system/puts
  2. fgets password (need strlen > 0x26 → 39+ chars with newline)
  3. Loop stage s = 0..6: mmap RWX → copy encrypted blob from .data → decrypt stub XOR each byte with (0x40 + s) → call shellcode (chunk, len, expected, extra)
StageInput offsetLenTransform
006plaintext FlagY{
165custom 32-bit tribonacci/fib G(ch) == expected dword
2116CRC32 (zlib poly, init/final XOR 0xffffffff)
3175RC4 encrypt, key Fl4gY4rD_Sup3r_K3y (18 bytes)
4226incomplete custom base64 (alphabet in .rodata)
5285input[i] + 0x0d == expected[i]
6336input[i] ^ 0x2a == expected[i]

Expected dwords live in .data (GOT-relative ebx+off).

Step 2: Invert stages 0–3, 5–6 (unique)

stage0: FlagY{
stage1: b5187          # unique preimages of five fib dwords
stage2: dbe8b6          # unique 6-hex CRC32 == 0x7fa4e767
stage3: 3fa27           # RC4 decrypt of five expected bytes
stage5: 1c493           # expected - 0x0d
stage6: 6d53c}          # expected ^ 0x2a

Step 3: Stage 4 — incomplete base64

Custom alphabet (64 chars):

SbIj8G4oDi2MVKcUX3kdW9xpAN1FPeEn7lrtwCqv5JugBLO0Qy\+H6TzRmhZsfaY

Shellcode emits only one base64 index per input byte (standard base64's 4th char of each 3-byte group is never checked). Expected six chars: VjVKxi.

Bit recovery (unique high bits only):

part4 = 03 + b2 + 5b + b5
  b2 high2 = 00  → b2 ∈ 0x00..0x3f
  b5 high2 = 01  → b5 ∈ 0x40..0x7f

Live bit-flip oracle on the binary confirms only positions 24 and 27 are free (low 6 bits each). Everything else is fixed.

Orphan dword 0x72 (r) sits immediately after the six expected chars; it equals standard-base64 c7 when b5 = 'b' (b5 & 0x3f == index('r') == 34). Hex style on the rest of the flag narrows free bits to 60 candidates 03[0-9]5b[a-f].

Platform string-match (not the buggy local checker alone) selects:

part4 = 0345bb

Full password / flag:

FlagY{b5187dbe8b63fa270345bb1c4936d53c}

Step 4: One-shot solver

#!/usr/bin/env python3
"""Winter is coming — recover flag from winter_is_coming stages."""
import struct
import zlib
from pathlib import Path

ALPH = b"SbIj8G4oDi2MVKcUX3kdW9xpAN1FPeEn7lrtwCqv5JugBLO0Qy\\+H6TzRmhZsfaY"
DATA = Path(__file__).with_name("data_section.bin").read_bytes()


def u32(off):
    return struct.unpack_from("<I", DATA, off)[0]


def fib_preimage(target):
    t0, t1, t2 = 0, 1, 0
    for n in range(256):
        if n and t0 == target:
            return n
        t0 = (t1 + t2) & 0xFFFFFFFF
        t2, t1 = t1, t0
    raise ValueError(hex(target))


def rc4_ks(key, n):
    S = list(range(256))
    j = 0
    for i in range(256):
        j = (j + S[i] + key[i % len(key)]) % 256
        S[i], S[j] = S[j], S[i]
    i = j = 0
    out = []
    for _ in range(n):
        i = (i + 1) % 256
        j = (j + S[i]) % 256
        S[i], S[j] = S[j], S[i]
        out.append(S[(S[i] + S[j]) % 256])
    return out


def stage4_fixed():
    exp = bytes(u32(0x6F8 + 4 * i) for i in range(6))  # VjVKxi
    idxs = [ALPH.index(c) for c in exp]
    i0, i1, i2, i3, i4, i5 = idxs
    b0 = ((i0 << 2) | (i1 >> 4)) & 0xFF
    b1 = (((i1 & 15) << 4) | (i2 >> 2)) & 0xFF
    b2_hi = i2 & 3
    b3 = ((i3 << 2) | (i4 >> 4)) & 0xFF
    b4 = (((i4 & 15) << 4) | (i5 >> 2)) & 0xFF
    b5_hi = i5 & 3
    return b0, b1, b2_hi, b3, b4, b5_hi


def main():
    s0 = bytes(u32(0x6A0 + 4 * i) for i in range(6))
    s1 = bytes(fib_preimage(u32(0x6B8 + 4 * i)) for i in range(5))
    crc_t = u32(0x6CC)
    # unique hex CRC among 16^6
    s2 = None
    for n in range(16**6):
        cand = f"{n:06x}".encode()
        if (zlib.crc32(cand) & 0xFFFFFFFF) == crc_t:
            s2 = cand
            break
    assert s2
    key = b"Fl4gY4rD_Sup3r_K3y"
    ct = [u32(0x6E4 + 4 * i) for i in range(5)]
    ks = rc4_ks(key, 5)
    s3 = bytes(ct[i] ^ ks[i] for i in range(5))
    b0, b1, b2_hi, b3, b4, b5_hi = stage4_fixed()
    # intended free bits (platform): b2='4', b5='b'
    s4 = bytes([b0, b1, ord("4"), b3, b4, ord("b")])
    assert (s4[2] >> 6) == b2_hi and (s4[5] >> 6) == b5_hi
    s5 = bytes(u32(0x714 + 4 * i) - 0x0D for i in range(5))
    s6 = bytes(u32(0x728 + 4 * i) ^ 0x2A for i in range(6))
    flag = s0 + s1 + s2 + s3 + s4 + s5 + s6
    print(flag.decode())


if __name__ == "__main__":
    main()

Flag

FlagY{b5187dbe8b63fa270345bb1c4936d53c}

Submitted live to Flagyard lab 6 (already solved / 300 pts). Local binary prints The flag is: Right.

Notes / pitfalls

  • Stage decrypt key is 0x40 + stage_index, constant per stage.
  • Stage 4 is underconstrained by design (missing 4th base64 digit per group → 12 free bits). Local checker accepts many passwords; platform stores one. Hex + b5='b' (orphan r as std c7) cuts to 10; platform match is 0345bb.
  • Prefer matching glibc/ctypes only when rand is involved (not here).
  • Rebuild expected offsets from runtime ebx (GOT) or dump .data after rebase; static VMA alone is fine for file-backed .data contents.