Home > Writeups > Raptor Weekly 13 - Night Traffic

Raptor Weekly 13 - Night Traffic

Night Traffic: layer-2 MITM in a noisy office capture; flag is XOR-encrypted under a SHA-256 of the attackers identity chain. Flag recovered.

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 decoder
  • pcap-summary, pcap-follow-stream - census and stream reassembly
  • dns-exfil, pcap-extract-files, icmp-exfil - channel exclusion
  • hashlib - 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:

  1. Single-byte XOR, all 256 keys -> nothing printable.
  2. Repeating-key XOR with ~20 thematic guesses (void, neon, mirror, glass, 0217, NORTHGLASS, wkstn-44, ...) -> nothing.
  3. 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 hint 0x0601 from flavour text into an instruction.
  4. 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 arpwatch line 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 plaintext murder{...} here is a decoy, exactly as 0x0600 warns.

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}
< Back to All Writeups