Skip to content

Forensics

Flag: SAS{1_d1d_my_f1rst_add_5_when_1_w4s_7} (submit verbatim — the drawn image has no _ between a and dd_5)

Created

Updated

3 min read

Reading time

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

Source-engine VTF alpha-mask multi-layer stego (.bsp decals) → hidden multiline flag

Event NameSAS CTF 2026
GitHub URL-
Challenge NameEscape from Guba II (game, TF2/FlagFortress2, id=16)
Attachments
  • Manual upload: solver.py, dirtmap_00.vtfdirtmap_09.vtf (from the bsp pakfile)
  • References

    Flag: SAS{1_d1d_my_f1rst_add_5_when_1_w4s_7} (submit verbatim — the drawn image has no _ between a and dd_5)

  • Root cause: the TF2 map ctf_guba.bsp embeds (in its pakfile = a zip inside the .bsp) 10 custom decal textures materials/concrete/dirtmap_00.vtf … dirtmap_09.vtf (VTF v7.2, 1024×1024, BGRA8888, ONEBITALPHA, 6 mips). The base color looks like dirt; the payload is in the alpha channel of mip level 2 (256×256). Each alpha mask alone is ~50% random dither (noise).
  • Exploit chain: each dirtmap is placed in-map as an infodecal on the same wall plane (x=484) at slightly different (y,z) origins (from bsp entity lump 0). Align the 10 masks by their decal origins and keep pixels whose overlap count ∈ [2..8] → the carved region renders a multiline message; concatenating the drawn lines gives the flag.
  • Decal origins (y,z), all at x=484: 00(-274,111) 01(-253,101) 02(-304,109) 03(-247,108) 04(-302,106) 05(-240,77) 06(-247,139) 07(-270,134) 08(-247,130) 09(-286,89).
  • Caveat: naive same-coordinate overlay is just noise — alignment by the in-map decal origins is required. VTF mips are stored smallest→largest; this VTF has no low-res thumbnail so high-res data starts at offset 80. Exact pixel registration uses the in-engine decal world-scale (player read the [2..8] overlay directly in-engine on the x=484 wall); flag is player-validated.
  • why it is vulnerable
    # 10 decals look like ordinary dirt, but the SECRET is in alpha @ the 256x256 mip,
    # and is only legible when the masks are stacked at their in-map decal origins:
    #   - VTF: BGRA8888 + ONEBITALPHA  -> alpha channel carries a 1-bit mask
    #   - each mask ~50% coverage (noise) so a single texture leaks nothing
    #   - overlap COUNT in [2..8] (i.e. "covered by some-but-not-all") draws the glyphs
    # i.e. the message is encoded in the *agreement pattern* across 10 aligned noise masks.
    exploit payload
    # extract the 256x256 mip alpha of each dirtmap, then composite count in [2..8]
    off = hdr_size(=80) + sum(smaller-mip byte sizes)      # mips stored smallest->largest
    alpha = frombuffer(vtf[off:off+256*256*4]).reshape(256,256,4)[:,:,3]   # BGRA -> A is index 3
    bin = alpha > 127
    # place each bin mask at its (y,z) decal origin (x=484 plane), sum, keep 2<=count<=8 -> flag
    solver
    import struct, zipfile, numpy as np
    from PIL import Image
    z = zipfile.ZipFile("ctf_guba.bsp")            # Source bsp pakfile is a trailing zip
    def mip256_alpha(d):
        hdr = struct.unpack_from("<I", d, 12)[0]; w,h = struct.unpack_from("<HH", d, 16)
        assert struct.unpack_from("<I", d, 0x34)[0] == 12   # BGRA8888
        mips = d[0x38]; off = hdr                            # no low-res thumbnail here
        for (mw, mh) in reversed([(w>>i, h>>i) for i in range(mips)]):   # smallest first
            if (mw, mh) == (256, 256):
                a = np.frombuffer(d[off:off+mw*mh*4], np.uint8).reshape(mh, mw, 4)[:, :, 3]
                return a
            off += mw*mh*4
    masks = [mip256_alpha(z.read(f"materials/concrete/dirtmap_{i:02d}.vtf")) > 127 for i in range(10)]
    ORIG = {0:(-274,111),1:(-253,101),2:(-304,109),3:(-247,108),4:(-302,106),
            5:(-240,77),6:(-247,139),7:(-270,134),8:(-247,130),9:(-286,89)}   # (y,z) @ x=484
    cy = (min(o[0] for o in ORIG.values())+max(o[0] for o in ORIG.values()))/2
    cz = (min(o[1] for o in ORIG.values())+max(o[1] for o in ORIG.values()))/2
    SCALE = 1.0; pad = 64; H = W = 256 + 2*pad; acc = np.zeros((H, W), int)
    for i in range(10):
        y, z = ORIG[i]; dx = round((y-cy)*SCALE); dy = round(-(z-cz)*SCALE)
        c = np.zeros((H, W), int); c[pad+dy:pad+dy+256, pad+dx:pad+dx+256] = masks[i]; acc += c
    Image.fromarray((((acc>=2)&(acc<=8))*255).astype("uint8")).save("reveal_aligned.png")  # read flag; tune SCALE
    # -> SAS{1_d1d_my_f1rst_add_5_when_1_w4s_7}

    Share this note

    Share:

    Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.