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 onion site over Tor: a red-branded forum called OpenForums with an open chat box, category listing for hacking, malware and red-team boards, and an anonymous read-only session — the address bar blacked out

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:

  1. Find the record with id == 7625 and read its msisdn.
  2. 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

  1. 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.
  2. The hidden service is often an index, not a host. Expect the payload to sit on mainstream infrastructure.
  3. Read the manifest before choosing an approach. Ten million records rules out load-it-all-in-pandas before you waste time discovering that yourself.
  4. Inspect the schema before you process it. The banner rows were visible in the first three lines of the first shard and invisible thereafter.
  5. Filter on semantics, not position. type == "BANNER" is robust; “skip line 1” is an assumption.
  6. Stream large compressed datasets. Line-by-line iteration over gzip.open keeps memory flat regardless of size.
  7. “Records are not in order” is an instruction. It means no locality, no early exit, and a full scan on the counting pass.

Sources


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.