We are given a packet capture from a Nintendo Switch local wireless session. The challenge description mentions Super Mario Maker 2, so the first suspicion is that the interesting data is not plain HTTP or normal network traffic, but Nintendo local wireless traffic from the game.
Opening the capture in Wireshark shows 802.11 frames. That means we first have to get through the wireless layer before we can look at the actual game traffic. The full stack looks roughly like this:
802.11 monitor capture
-> Nintendo LDN advertisement
-> WLAN CCMP-encrypted data frames
-> Pia encrypted game packets
-> reliable stream fragments
-> UTF-16 level data
The useful part is a shared level description. Unfortunately, it is wrapped in several encryption and framing layers.
Nintendo Switch local wireless uses LDN. The capture contains an LDN advertisement action frame, which gives us the values needed for the next layers. Using the
server_random
application_data
The
The relevant solver code searches for the first decodable advertisement frame:
def extract_ldn_context(pcap_path: str, key_derivation: KeyDerivation):
with PcapNgReader(pcap_path) as reader:
for pkt in reader:
if "Raw" not in pkt:
continue
ad = AdvertisementFrame(key_derivation, 1)
try:
ad.decode(b"\x7f" + bytes(pkt["Raw"].load))
except Exception:
continue
if len(ad.payload.application_data) >= 16:
return ad.payload.server_random, ad.payload.application_data
For Super Mario Maker 2 nearby play, the LDN passphrase is known:
LunchPack2DefaultPhrase
Combining the passphrase with the
PASS_PHRASE = b"LunchPack2DefaultPhrase"
server_random, app_data = extract_ldn_context(args.pcap, kd)
wlan_key = kd.derive_data_key(server_random, PASS_PHRASE)
After decrypting a data frame, the payload starts to look like a normal LLC/IP/UDP packet. The game packets we care about contain the Pia magic:
32 ab 98 64
The next layer is Pia, Nintendo's peer-to-peer networking library. The Pia session seed is stored in the LDN application data:
session_id_le = app_data[0:4]
session_seed = int.from_bytes(app_data[0x0C:0x10], "little")
The session key is derived with the documented Pia / SEAD RNG flow and then encrypted with the Super Mario Maker 2 game key:
GAME_KEY = bytes.fromhex("667c18475889faab61f93ef1da180971")
def derive_pia_session_key(seed: int) -> bytes:
m = 0x6C078965
t = seed
state = []
for i in range(1, 5):
t ^= t >> 30
t = (t * m + i) & 0xFFFFFFFF
state.append(t)
def rng_u32() -> int:
nonlocal state
x = state[0]
x = (x ^ (x << 11)) & 0xFFFFFFFF
x ^= x >> 8
x ^= state[3]
x ^= state[3] >> 19
state = [state[1], state[2], state[3], x]
return x
rand_block = b"".join(struct.pack("<I", rng_u32()) for _ in range(4))
return AES.new(GAME_KEY, AES.MODE_ECB).encrypt(rand_block)
Pia packets are AES-GCM encrypted. The nonce is built from the Pia session id, source MAC address, connection id, and packet nonce:
src_mac = bytes(int(x, 16) for x in dot11.addr2.split(":"))
crc = zlib.crc32(session_id_le + src_mac) & 0xFFFFFFFF
nonce = crc.to_bytes(4, "big")[:3] + bytes([conn_id]) + nonce8
Once that is correct, the UDP payloads decrypt cleanly and we can parse the inner Pia messages.
The interesting Pia packet type is
The solver collects these fragments, deduplicates them by sequence id, and reassembles complete messages using the begin/end flags. Some reliable messages can also be zlib-compressed, so the reassembler handles that flag too.
After reassembling the reliable messages, we only keep stream packets for requested stream id
if packet_type in (1, 2) and requested_stream_id == 1:
chunks.append((seq, body))
Concatenating those chunks produces the shared level data. It is mostly binary, but the interesting strings are UTF-16LE.
text = data.decode("utf-16le", errors="ignore")
lines = [line.strip("\x00 ") for line in text.split("\x00")]
Running the solver gives:
Reconstructed stream id 1: 350732 bytes -> /tmp/slipperyslope-stream1.bin
UTF-16 lines found:
CSCG Challenge
dach2026{plEa$e_NO_mOrE_crypTo_laYers}
FLAG: dach2026{plEa$e_NO_mOrE_crypTo_laYers}
So the flag is:
dach2026{plEa$e_NO_mOrE_crypTo_laYers}
The protocol details are documented in the NintendoClients wiki: