Night Traffic
Challenge Description
A single artifact: 04_night_traffic_VOID_HACKER.pcap - 159,933 bytes, libpcap little-endian
(d4c3b2a1), linktype 1 (Ethernet), 1,369 packets spanning 78.3 seconds of a flat
10.7.0.0/24 office LAN at night. No brief, no service, no credentials. Find the flag.
Flag format is murder{...}, which the capture goes out of its way to teach you by planting
five fake ones.
Overview
A man-in-the-middle is sitting on the LAN. The real flag is a 31-byte blob split across six
records of one TCP stream, XOR-encrypted with the SHA-256 of the attacker's identity chain -
a Name|actor|face string you can only assemble by answering three independent forensic
questions: the operation name (from shuffled syslog audit fragments), the attacker's IP (from
ARP), and the attacker's MAC (from an Ethernet-vs-IP contradiction in a DNS reply).
Every individual technique here is textbook. The difficulty is selection: the capture
contains four plausible covert channels and five decoy flags, and the entire challenge is
about discarding things confidently. Nothing you can grep for is real.
A note on the stage ordering below. The five runs that follow were verification, performed after the solve was already complete. Read them as how each conclusion was independently confirmed, rather than as a discovery narrative. Where a run changed or hardened a conclusion, that is called out explicitly.
Tooling
parse.py- ~90-line pure-Python pcap + Ethernet/ARP/IPv4/TCP/UDP/ICMP decoderpcap-summary,pcap-follow-stream- census and stream reassemblydns-exfil,pcap-extract-files,icmp-exfil- channel exclusionhashlib- the final SHA-256
Writing the parser by hand turned out to be an advantage rather than a tax. This challenge hinges on Ethernet-layer and IP-layer disagreeing with each other, and a hand-rolled decoder keeps both in view simultaneously instead of hiding one behind a friendly summary.
Stage 1: Ground Truth, and Validating the Parser
I ran pcap-summary (/pcap/summary) for an independent census of the capture, and to
validate the hand-written parser before trusting anything built on top of it.
{
"path": "Night Traffic/04_night_traffic_VOID_HACKER.pcap"
}
Result: 200 conversations
The census came back byte-for-byte identical to parse.py: packet_count 1369,
{"ARP":145,"ICMP":113,"TCP":430,"UDP":681}, link_types [1], 568 conversations, 78.3 s.
That match is the reason every later hand-parsed claim in this write-up is trustworthy.
It also surfaced something I had missed by reading packets rather than aggregates - the endpoint ranking:
10.7.0.10 384 10.7.0.44 202 10.7.0.20 105
10.7.0.1 272 10.7.0.23 157 10.7.0.66 57 <- attacker
10.7.0.30 269 10.7.0.45 155 10.7.0.217 56 <- narration channel
10.7.0.53 239 10.7.0.21 123
10.7.0.66 is the quietest real host in the capture: 57 packets, one conversation. A MITM
does not need volume, it needs position. Any triage that starts with top talkers discards the
attacker in step one. Combined with the address plan - every legitimate host is
02:00:00:00:00:<last octet>, and 02:66:66:66:66:66 fits nothing - this is where the
investigation should point.
Stage 2: Reassemble the Attacker's Only Conversation
I ran pcap-follow-stream (/pcap/follow-stream) to reassemble, independently of my own
parser, the single conversation the attacker takes part in.
{
"path": "Night Traffic/04_night_traffic_VOID_HACKER.pcap",
"ip_a": "10.7.0.44",
"port_a": 49888,
"ip_b": "10.7.0.66",
"port_b": 8443
}
Result: 2 packets
That summary is misleading - it counts reassembled directions, not packets. The detail behind
detail_ref reports the true figures, 29 up / 28 down, matching parse.py exactly, and
returns the full reassembled byte stream. (Lesson: fetch the detail before believing a
surprising summary.)
The stream is a fake "mirror cache" served by the attacker after it had already hijacked the victim's traffic. Content is line-oriented records:
MIRROR|sid=0217|seq=03|total=06|data=8ca6cdf4a1f0|src=wkstn-44|mode=cache|status=verified|channel=primary
Four session IDs are interleaved, and picking the right one is the whole puzzle of § Stage 2:
| sid | Marking | Verdict |
|---|---|---|
0216 |
status=ok/retry, checksum=unknown |
junk (deadbeef, aabbccdd, 010203...) |
0218 |
status=ok/retry |
hex-decodes to ASCII NOTHEFLAG_JUST_DECOY_DATA_HERE |
DEAD |
tombstone | junk (cafebabe, baadf00d, 13371337) |
0217 |
status=verified + channel=primary |
the real payload |
0218 even ships a duplicate seq=02 marked status=retransmit to punish naive dedup.
Stage 3: Rule Out DNS as the Channel
I ran dns-exfil (/pcap/dns-exfil). Four suspicious mirror.* lookups needed an answer
before DNS could be set aside.
{
"path": "Night Traffic/04_night_traffic_VOID_HACKER.pcap"
}
Result: 8 sample labels
Negative - and that is the useful part. 13 queries across 5 domains; the best decode
strategy produced 7 bytes of unrecognizable garbage. The sample labels are intranet, files,
ad01, time, telemetry, cdn, status, updates. Nothing is encoded. The signal for DNS
exfil is well defined: hex-looking subdomains under one unusual domain, and a normal client
does not emit hundreds of them. This capture emits thirteen ordinary ones.
But the domain breakdown is where DNS earns its place in the solve:
| Domain | Queries |
|---|---|
office.local |
9 |
evidence.local |
1 |
backup.local |
1 |
mirror-evidence.local |
1 |
evidense.local |
1 |
Four near-miss names, each queried exactly once. They are not a data channel; they exist to
be confused with one another - mirror.evidence.local vs mirror-evidence.local vs
mirror.evidense.local (note the typo). This reframed DNS from "exfil" to "misdirection", and
sent me back to look at the one query that mattered at the Ethernet layer, which is where the
attacker's MAC finally fell out (§ Full Solve, piece 3).
Stage 4: Rule Out HTTP
I ran pcap-extract-files (/pcap/extract-files). 211 packets of port-80 traffic was the
largest surface still unexamined.
{
"path": "Night Traffic/04_night_traffic_VOID_HACKER.pcap"
}
Result: 42 files
Negative. All 42 carved bodies are the identical 18-byte string policy-cache-valid,
collapsing to a single sha256 (b6602ea2...). Every HTTP response in the capture is the same
filler, served by 10.7.0.30 to every workstation. There is no transferred file, no staged
payload, nothing embedded.
That closes the second of four candidate channels - and it does so in one call, versus reading 211 packets by hand.
Stage 5: Rule Out ICMP
I ran icmp-exfil (/pcap/icmp-exfil). 113 echo packets carrying diag- and health-
payloads looked like a classic tunnel.
{
"path": "Night Traffic/04_night_traffic_VOID_HACKER.pcap",
"data_offset": 0
}
Result: 45 ids seens
Negative, with a tell. 113 echo requests and zero echo replies. That asymmetry is itself
the finding: real ping traffic is a conversation, so 113 unanswered requests are synthesized
background, not a tunnel. Payloads are 13 or 19 bytes - a diag- or health- prefix plus
random filler - spread across 45 ICMP IDs, and reassembly by sequence yields noise.
Third channel closed. With DNS, HTTP and ICMP all mechanically excluded, the 8443 MIRROR
stream is the only remaining carrier, which is what makes the narrow focus in Stage 2
defensible rather than lucky.
Full Solve
The narration channel
10.7.0.217:31337 broadcasts 56 UDP frames of Unicode ASCII-art - the challenge's own hint
track. 38 are filler (void://noise); the other 18 are the walkthrough:
| ID | Text | Meaning |
|---|---|---|
0x0211 |
two mouths may claim one gate; believe the frame, not the name | ARP spoof; trust L2 over L3 |
0x0212 |
a reflection that answers first can still be a lie | DNS race; first answer is forged |
0x0213 |
arrival is noise. embedded sequence is memory | sort by in-payload seq |
0x0300 to 0x0302 |
audit residue is the bone that matters / order the shards | syslog auditd n/3 fragments |
0x0500 |
SID is a lock. many keys look almost right | near-miss session IDs |
0x0502 |
hex is skin, bytes are blood. join first, transform second** | concat hex -> decode -> then transform |
0x0601 |
hash the identity chain, not the pretty banner. separators matter** | key is a digest; delimiter literal |
0x0602 |
Name | actor | face - three pieces, one digest | exact key format, pipe included |
0x0604 |
the cleanest indicator is validation, not visibility | trust status=verified |
The decoys
Five plaintext murder{...} strings, all fake - plus 15 carrier::<40 hex> syslog lines that
mimic SHA-1 digests and are pure chaff:
| Location | Decoy |
|---|---|
| pkt 549, syslog analyst note | murder{th1s_1s_just_a_s0c_d3c0y} |
pkt 550, GET /debug?token= (URL-encoded) |
murder{packet_list_ba1t} |
pkt 551, syslog from .20 |
murder{wrong_stream_keep_hunting} |
pkt 683, void://ghost |
murder{n30n_gh0st_w4s_h3r3} |
MIRROR sid=0218 |
NOTHEFLAG_JUST_DECOY_DATA_HERE |
Hint 0x0600 telegraphs the rule: strings traffic | grep truth -> [permission denied by
noise].
Piece 1 - Name: NORTHGLASS
sentinel-01 emits three auditd lines to syslog, out of order, buried in ~30
telemetry event id=... action=allow filler lines:
pkt 555 21:13:02 audit fragment 2/3 = THG
pkt 566 21:13:05 audit fragment 1/3 = NOR
pkt 577 21:13:09 audit fragment 3/3 = LASS
Ordered by fraction, not arrival: NOR + THG + LASS = NORTHGLASS.
Piece 2 - actor: 10.7.0.66
Pkts 590 to 599: ten unsolicited ARP replies (op=2) with no preceding request, all from
02:66:66:66:66:66:
pkt 590 10.7.0.1 is-at 02:66:66:66:66:66 -> 10.7.0.44
pkt 591 10.7.0.44 is-at 02:66:66:66:66:66 -> 10.7.0.1
... alternating, ×5
Bidirectional poisoning - gateway and victim each told the other lives at the attacker's MAC.
Compare pkts 1 to 19, the clean baseline, where 10.7.0.1 correctly answers as
02:00:00:00:00:01. Two mouths, one gate.
Piece 3 - face: 02:66:66:66:66:66
Pkts 611 to 613: 10.7.0.44 resolves mirror.evidence.local and gets two answers.
| pkt | IP source | Ethernet source | Answer | TTL |
|---|---|---|---|---|
| 612 | 10.7.0.53 |
02:66:66:66:66:66 |
10.7.0.66 |
300 |
| 613 | 10.7.0.53 |
02:00:00:00:00:53 |
10.7.0.90 |
60 |
The first response claims to come from the DNS server but was physically transmitted by the
attacker's NIC. To any view that shows only 10.7.0.53 -> 10.7.0.44, these two packets are
indistinguishable.
Corroborating: at pkt 611 the victim's query leaves with destination MAC
02:66:66:66:66:66 - proof the ARP poison had already taken hold, so the attacker never
needed to win a race. It simply held the wire.
There is also a convenient arpwatch syslog line (pkt 600) naming both values. Given that four
decoy flags live in the same log stream, it is not trusted on its own - both values above
were derived from the wire independently, and the log merely agrees.
Piece 4 - the ciphertext
sid=0217 records, sorted by embedded seq (wire order was 03,00,05,02,04,01):
seq 00 6d74b22bac91 seq 03 8ca6cdf4a1f0
seq 01 9ab4ab060efb seq 04 5a5c2ae6ffb8
seq 02 2066da012259 seq 05 d4 <- 1 byte, making the total an odd 31
Join first, transform second - concatenate the hex, then decode once:
6d74b22bac919ab4ab060efb2066da0122598ca6cdf4a1f05a5c2ae6ffb8d4 (31 bytes)
The transform - and four dead ends
The 31 bytes are not text. What failed, in order:
- Single-byte XOR, all 256 keys -> nothing printable.
- Repeating-key XOR with ~20 thematic guesses (
void,neon,mirror,glass,0217,NORTHGLASS,wkstn-44, ...) -> nothing. - Exhaustive printable-ASCII key search, lengths 1 to 16: for each length, solve each key
position independently for the byte set mapping that column entirely into
0x20..0x7e, then intersect. No solution at any length. This is the pivotal negative result - it proves the key is ≥17 bytes and not a word, which is exactly what "digest" means, and turns hint0x0601from flavour text into an instruction. - Is the blob itself a digest of the chain? Compared against MD5/SHA-1/SHA-256/SHA-512 of many spellings -> no.
The key is not in the capture. You compute it. 0x0602 gives the format and "separators
matter" means the pipe is literal:
key = sha256("NORTHGLASS|10.7.0.66|02:66:66:66:66:66")
= 0001c04fc9e3e1da9b747a93470aee725106fb92beabd598690347d78bd5a904
A lovely detail: the key begins 00 01, so the ciphertext opens with 6d 74 = "mu". The
first two characters of the flag sit in the clear in the capture - visible the entire time,
meaningless without the other 29.
Reproducible solve
Everything below runs against the raw pcap; only sid=0217 and the gateway IP are hardcoded.
Verified end-to-end.
import hashlib, re
from parse import parsed # pure-Python pcap decoder
# 1. Name - syslog auditd fragments, ordered by fraction not arrival
frags = {}
for d in parsed:
m = re.search(rb'audit fragment (\d)/3 = (\w+)', d.get('data', b''))
if m:
frags[int(m.group(1))] = m.group(2).decode()
name = ''.join(frags[i] for i in sorted(frags)) # NORTHGLASS
# 2. face - sender of unsolicited ARP replies claiming the gateway
face = next(d['arp_sha'] for d in parsed
if d.get('arp_op') == 2 and d.get('arp_spa') == '10.7.0.1'
and d['arp_sha'] != '02:00:00:00:00:01') # 02:66:66:66:66:66
# 3. actor - the host it serves the fake mirror cache from
actor = next(d['dst'] for d in parsed
if d.get('proto') == 'TCP' and d.get('dport') == 8443) # 10.7.0.66
# 4. ciphertext - sid=0217 only, ordered by embedded seq, joined then decoded
frag = {}
for d in parsed:
m = re.match(rb'MIRROR\|sid=0217\|seq=(\d+)\|total=\d+\|data=([0-9a-f]*)\|',
d.get('data', b''))
if m:
frag[int(m.group(1))] = m.group(2).decode()
ct = bytes.fromhex(''.join(frag[i] for i in sorted(frag)))
# 5. transform
key = hashlib.sha256(f"{name}|{actor}|{face}".encode()).digest()
print(bytes(c ^ key[i % len(key)] for i, c in enumerate(ct)).decode())
# murder{n0rthgl4ss_w4s_th3_m1tm}
Key Takeaways
- Ethernet source is evidence. The single most load-bearing fact - the spoofed DNS reply -
is invisible at the IP layer. Two packets, identical
10.7.0.53 -> 10.7.0.44, one forged. Any tooling or habit that summarizes away L2 loses this challenge. - Volume is not importance. The attacker is the quietest real host in the capture. Top- talker triage discards it immediately.
- Unsolicited ARP replies are the tell - replies with no matching request, especially bidirectional pairs, are MITM insertion.
- Trust the transport's own sequencing, not capture order. Both the audit fragments and the MIRROR records arrive shuffled, and both punish time-sorting with a silent wrong answer.
- Don't trust your own SIEM during IR. The
arpwatchline was correct, but sat in the same log stream as three fake flags. Corroborate from raw evidence, then let the log agree. - A thorough negative result is progress. Proving no XOR key of length ≤16 exists is what identified the key as a digest. Likewise, excluding DNS/HTTP/ICMP is what made focusing on one TCP stream defensible rather than lucky.
grep-able is bait. Every plaintextmurder{...}here is a decoy, exactly as0x0600warns.
What would make this faster next time
Run the channel-exclusion sweep (dns-exfil, extract-files, icmp-exfil) first, before
reading any packets by hand. Three calls, a few seconds, and it eliminates three of four
candidate carriers - turning an open-ended capture into a single 57-packet conversation. I did
it in the wrong order and read a lot of filler to reach the same place.
Second: when a payload resists XOR, don't keep guessing keywords. Run the exhaustive printable-key search across lengths 1 to 16 early. It either hands you the key or proves the key is a digest - and either outcome is decisive in under a second.
Flag
murder{n0rthgl4ss_w4s_th3_m1tm}