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
- Manual upload:
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)
- Root cause: the TF2 map
ctf_guba.bspembeds (in its pakfile = a zip inside the .bsp) 10 custom decal texturesmaterials/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
infodecalon 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 -> 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}Eye of the Witness — phone-video POV geolocation and validator-format trap
| Event Name | RTR II Cyber Competition - 2026 |
| Source URL | Challenge page |
| Challenge Name | Eye of the Witness (OSINT, 300 points) |
Attachments
- Original filename:
solver.py - Rename
solver.txttosolver.pyafter download - Corrected solver SHA-256:
4ce4fee4da4b0e9bb30a310320f728a398ee0f93117cc87c8b0698c151ab8ee8 - Challenge artifact
Drone.mp4SHA-256:1d8d606687e584e176a700ca426b237dc3077ea075acec3d8f8114f307690273
References
Flag: RTRTNI26{43.431, 39.933} — server-validated First Blood, 300 base points + 20 bonus.
- Triage: the MP4 is a re-encoded vertical phone video with H.264/AAC only. It contains no GPS, telemetry, useful creation timestamp, subtitle, hidden data stream, or recoverable sign text.
- Source match: Exilenova+ 11112 contains the exact sequence. Post 11115 replies with the same two-coach frame inset on satellite imagery and prints the operator point as Lat,Long
43.431717894702786, 39.93346527459446. - Physical corroboration: the Yandex panorama reproduces the unusual E97 acoustic barrier, railway catenary, service lane, and coach yard. Independent registration of the yellow POV marker lands at about
43.431756, 39.933403. - Range check: the new flash appears around 24.333 s and its report begins around 25.618 s. The 1.28–1.32 s light-to-sound delay implies roughly 441–452 m, agreeing with the approximately 445 m POV-to-tank geometry.
- Validator trap: the description says
RTRTNI26{Long, Lat}, but the live checker expects Lat, Long, truncation rather than rounding, the wrapper, and a literal space after the comma:- latitude
43.431717...→43.431 - longitude
39.933465...→39.933 - exact accepted string →
RTRTNI26{43.431, 39.933}
- latitude
why it is vulnerable
The attachment leaks no direct coordinates. The solve pivots from a distinctive source-frame fingerprint (two coaches, transparent E97 barrier, catenary, blue-roofed yard) to a public reply that discloses the witness/operator point. The final obstacle is a contradictory validator: obeying the displayed longitude-first order or normal rounding produces a wrong flag even with the correct physical location.
exploit payload
published point (Lat,Long): 43.431717894702786, 39.93346527459446
truncate to 3 dp: 43.431, 39.933
wrap exactly: RTRTNI26{43.431, 39.933}
server: CHALLENGE SOLVED! +320 Ptssolver
#!/usr/bin/env python3
"""Verify the supplied artifact and reproduce the server-accepted CTF flag."""
from __future__ import annotations
import argparse
import hashlib
from decimal import Decimal, ROUND_DOWN
from pathlib import Path
EXPECTED_SHA256 = "1d8d606687e584e176a700ca426b237dc3077ea075acec3d8f8114f307690273"
# Exilenova+ post 11115 labels these as the phone operator's Lat,Long.
OPERATOR_LAT = Decimal("43.431717894702786")
OPERATOR_LON = Decimal("39.93346527459446")
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def truncate_three_decimals(value: Decimal) -> str:
"""Match the challenge validator: truncate positive coordinates to 3 dp."""
return str(value.quantize(Decimal("0.001"), rounding=ROUND_DOWN))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"video",
nargs="?",
type=Path,
default=Path(__file__).with_name("Drone.mp4"),
)
args = parser.parse_args()
actual = sha256(args.video)
if actual != EXPECTED_SHA256:
raise SystemExit(
f"unexpected attachment SHA-256: {actual}\nexpected: {EXPECTED_SHA256}"
)
# The live validator contradicts the displayed Long,Lat instruction. It
# accepts Lat, Long, truncated to three decimals, including this spacing.
print(
"RTRTNI26{"
f"{truncate_three_decimals(OPERATOR_LAT)}, "
f"{truncate_three_decimals(OPERATOR_LON)}"
"}"
)
if __name__ == "__main__":
main()