Night Shift — Athena Trace 2026 Writeup
A bare 56-character string turns out to be an address. Behind it: ten million records across twenty shards, one lookup, one count, and a couple of things in the data that aren't records at all.
The challenge
Call records from a mobile operator were leaked. Seven days of calls, SMS, data and registrations. The records are not in order. Find record
7625.Flag format:
trace{MSISDN_COUNT}— the phone number in digits only (no+), then_, then the count. No spaces. COUNT is the total number of records belonging to that phone number across the entire dataset, every record type, all seven days, all files.
Plus one artifact: a bare 56-character string, with no explanation of what it is.
Stage 1 — Recognising the string
s7i4bljs4wqao3pe...[redacted]
56 characters, base32 alphabet (lowercase letters and digits 2–7), ending in d.
That’s the signature of a Tor v3 onion address. The length is fixed and
derives from the format: a 32-byte ed25519 public key plus a checksum and version
byte, base32-encoded.
Recognising it is the entire first stage. There’s nothing to decode and nothing to
brute-force — appending .onion and loading it over Tor is the whole move.
The address itself is redacted here. The infrastructure may still be live, and a writeup doesn’t need to hand out a working address to explain the method.
Pattern worth memorising: an unexplained 56-character base32 string is almost always a v3 onion. Its predecessor, the v2 address, was 16 characters. Neither looks like a hash, and neither decodes to anything readable — which is exactly the property that makes people waste time running them through cipher tools.
Stage 2 — The forum, and the noise
The address serves a forum. The relevant category is Leaks, and it is deliberately cluttered: CSV datasets, image dumps, ZIPs, PDFs — a realistic volume of material that has nothing to do with the task.

The forum behind the 56 characters, browsed read-only. Address redacted for the same reason as in the text.
This is a search problem, not a puzzle. The useful find is in leak thread A11: a Google Drive link.
Worth noting because it’s realistic: the actual data isn’t hosted on the hidden service. The onion is the index; the payload sits on mainstream cloud storage. That’s how a lot of real leak distribution works, and it means “I found the forum” is a waypoint rather than the destination.
Stage 3 — The Drive folder
_manifest.json
_answer_key.json
README.txt
leak_part_00.jsonl.gz
...
leak_part_19.jsonl.gz
_manifest.json confirms the dataset is the real one:
- 20 gzip shards
- 10,000,000 records
- 7 days
- record types: call / SMS / data / registrations
Read the manifest first. It costs one file and tells you the scale before you commit to an approach — and ten million records across twenty compressed shards rules out several approaches immediately.
The stale artifact
_answer_key.json is present and is not the answer to this task. It contains an
answer-key structure for a different, neighbouring challenge — a tower/location
count — with a flag in flag{...} form rather than trace{...}.
Nothing more to make of it than that: a cross-challenge artifact left in a shared
folder. The trace{...} versus flag{...} mismatch is the tell, and it’s a useful
habit to check format consistency before getting excited about a file called
_answer_key.json.
Stage 4 — Schema inspection, and the rows that aren’t records
Before writing the real script: decompress the head of one shard and look at it.
Doing that on leak_part_00.jsonl.gz shows that the first line is not a record
— it’s an object with type: "BANNER", followed by normal records.
That matters directly for the count. The task asks for records belonging to that phone number; a banner row is not a subscriber event and carries no meaningful MSISDN association. Including them inflates the count, and an off-by-N count fails just as hard as a wrong number.
The robust handling is to filter on the type rather than on position:
if obj.get("type") == "BANNER":
continue
Filtering by line index would assume banners only ever appear at the head of a file. Filtering by type holds regardless of where they sit.
Habit: always eyeball the first and last few lines of a large data file before processing it. Headers, banners, footers, and trailing partial lines are common, and they’re invisible once your script is chewing through ten million rows.
zcat leak_part_00.jsonl.gz | head -n 3
zcat leak_part_00.jsonl.gz | tail -n 3
Stage 5 — Two passes, streaming
The task decomposes cleanly:
- Find the record with
id == 7625and read itsmsisdn. - Count every non-banner record with that
msisdn, across all shards.
Two passes, because you can’t count for a target you haven’t identified yet — and the target could be in shard 19 while its other records are in shard 00. The prompt’s “the records are not in order” is a warning about exactly this: no shard-level locality, no early exit on the counting pass.
The important implementation detail is streaming. Ten million JSON records
should never be materialised in memory at once. gzip.open in text mode yields
line by line, decompressing on the fly, so memory stays flat regardless of dataset
size.
import gzip, json, glob
files = sorted(glob.glob("night-shift/leak_part_*.jsonl.gz"))
# Pass 1 — locate the target record
target = None
for path in files:
with gzip.open(path, "rt", encoding="utf-8") as f:
for line in f:
obj = json.loads(line)
if obj.get("id") == 7625:
target = obj["msisdn"]
break
if target:
break
# Pass 2 — count every record for that MSISDN
count = 0
for path in files:
with gzip.open(path, "rt", encoding="utf-8") as f:
for line in f:
obj = json.loads(line)
if obj.get("type") == "BANNER":
continue
if obj.get("msisdn") == target:
count += 1
print(f"trace{{{target.lstrip('+')}_{count}}}")
Pass 1 can break early — there’s exactly one record with that id. Pass 2 cannot, and must touch every line in every shard.
Record 7625 resolves to:
id : 7625
msisdn : +923245556534
type : DATA
And the full count for that MSISDN across all twenty shards, all record types, all seven days: 224.
Stage 6 — Formatting
The spec says digits only, no +. The dataset stores the MSISDN in E.164 with the
leading plus, so it has to be stripped — lstrip('+') in the snippet above.
+92 is Pakistan, consistent with the rest of the board’s regional focus, though
that’s colour rather than evidence.
Flag
trace{923245556534_224}
Takeaways
- Learn the shapes of common identifiers. 56 characters of base32 is a v3 onion. Recognising a format instantly is worth more than any amount of clever decoding.
- The hidden service is often an index, not a host. Expect the payload to sit on mainstream infrastructure.
- Read the manifest before choosing an approach. Ten million records rules out load-it-all-in-pandas before you waste time discovering that yourself.
- Inspect the schema before you process it. The banner rows were visible in the first three lines of the first shard and invisible thereafter.
- Filter on semantics, not position.
type == "BANNER"is robust; “skip line 1” is an assumption. - Stream large compressed datasets. Line-by-line iteration over
gzip.openkeeps memory flat regardless of size. - “Records are not in order” is an instruction. It means no locality, no early exit, and a full scan on the counting pass.
Sources
- Tor Rendezvous Specification v3 — Encoding onion addresses — the v3 format: base32(PUBKEY | CHECKSUM | VERSION), 32-byte ed25519 key, 2-byte checksum, 1-byte version, 56 characters
- Tor Rendezvous Specification v3 (rendered mirror) — same spec, single-page version
- Tor Browser — the only tool the first stage needs
- Python
gzipdocumentation —gzip.openin"rt"mode wraps aTextIOWrapper; line-by-line iteration is what keeps memory flat - Telephone numbers in Pakistan —
+92is Pakistan’s country calling code - Challenge: Athena Trace 2026 — “Night Shift”. The dataset is synthetic challenge material; the onion address and Drive folder are deliberately not linked
Notes
The onion address is redacted in this writeup. All data referenced is synthetic challenge material generated for the CTF; the phone numbers in it are not real subscribers.