Source-engine VTF alpha-mask multi-layer stego (.bsp decals) → hidden multiline flag
| Event Name | SAS CTF 2026 |
| GitHub URL | - |
| Challenge Name | Escape from Guba II (game, TF2/FlagFortress2, id=16) |
Attachments
solver.py, dirtmap_00.vtf … dirtmap_09.vtf (from the bsp pakfile)Flag: SAS{1_d1d_my_f1rst_add_5_when_1_w4s_7} (submit verbatim — the drawn image has no _ between a and dd_5)
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).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.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 -> flagsolver
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}