Tools: adhoc_decrypt.py / adhoc_recover.c for debugging real-PSP ad hoc captures

A real PSP encrypts its ad hoc link, so a sniffed capture of two consoles is
ciphertext. These decrypt and decode it (PDP/PTP/matching framing), to compare
what a game does on hardware against what PPSSPP sends at the sceNetAdhoc HLE
level - a debugging aid for hardware-vs-emulator divergence. Not needed to run
multiplayer (PPSSPP works at HLE and never encrypts) and unrelated to cross-play
(the link is console-authenticated).

The session key is ephemeral and not in the capture, but the data cipher has a
known-plaintext weakness, so a capture with some guessable plaintext is enough
to recover it - no console memory needed, and not a break of the key exchange.
The two fixed KIRK keys used already ship in ext/libkirk. docs/adhoc-packet-
decryption.md covers capturing, decrypting and recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 4.8 committed 2026-10-30 10:24:15 +01:00
1 parent d8ff2bd62e
commit b7852eace2
4 files changed
+584

No files matched your search

+1
View File
@@ -24,6 +24,7 @@ for it:
| [docs/sceAudio.md](docs/sceAudio.md) | How the audio output calls block, how deep they buffer, and what each error means |
| [docs/WebSocketDebugger.md](docs/WebSocketDebugger.md) | WebSocket debugger protocol reference |
| [docs/reverse-engineering.md](docs/reverse-engineering.md) | Disassembling a firmware PRX with `--re-module`, to find out what the hardware actually does |
| [docs/adhoc-packet-decryption.md](docs/adhoc-packet-decryption.md) | Decrypting a sniffed capture of real-PSP ad hoc traffic (`Tools/adhoc_decrypt.py`, `adhoc_recover.c`) to compare hardware framing against PPSSPP's HLE-level ad hoc |
| [docs/command-line.md](docs/command-line.md) | Adding a command-line option - the `g_autoParams` table, per-mode options, `ApplyToConfig()` |
| [docs/patching-files.md](docs/patching-files.md) | Editing files from a script without wrecking the diff: line endings, and heredoc backslash escaping |
| [docs/VSHBootInvestigation.md](docs/VSHBootInvestigation.md) | Booting the PSP's Visual Shell with `--vsh` against a real firmware dump |
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Decrypt and decode PSP ad hoc Wi-Fi traffic from a radiotap pcap capture.
A real PSP encrypts its ad hoc link traffic, so a sniffed capture of two consoles playing is
ciphertext. This tool decrypts it and decodes the PDP/PTP/matching framing, which is handy for
comparing what a game does on hardware against what PPSSPP sends at the sceNetAdhoc HLE level.
adhoc_decrypt.py CAPTURE.pcap # handshake + list the data frames
adhoc_decrypt.py CAPTURE.pcap --key HEX # decrypt (16-byte key) or decrypt+verify (20-byte)
adhoc_decrypt.py CAPTURE.pcap --known HEX16@CTR --known HEX16@CTR # recover the key, then decrypt
adhoc_decrypt.py --selftest
The key is ephemeral (negotiated per ad hoc group membership by an ECDH handshake), so it is not in
the capture. But the data cipher has a known-plaintext weakness: with one known 16-byte plaintext
block for an even-counter frame and one for an odd-counter frame, --known recovers the 16-byte key
that decrypts everything (one AES op each, validated by the frame's own counter). The last 4 bytes of
the 20-byte key only feed the integrity tag; recover them with adhoc_recover.c if you want tags
verified. This is not a break of the key exchange - it just needs a capture that contains some known
plaintext (the ad hoc envelope's constant fields plus, e.g., a game's constant packet header or a
nickname you set). Capturing needs a Wi-Fi adapter in monitor mode on the consoles' ad hoc channel.
The two fixed KIRK keys below ship in PPSSPP already (ext/libkirk/kirk_engine.c). Pure Python.
"""
import argparse
import hashlib
import struct
import sys
import zlib
# ---- AES-128, pure Python ------------------------------------------------------------------
def _xt(a):
a <<= 1
return (a ^ 0x11B) if a & 0x100 else a
def _mul(a, b):
r = 0
while b:
if b & 1:
r ^= a
a = _xt(a)
b >>= 1
return r
SBOX = [0] * 256
INV = [0] * 256
def _init_sbox():
for i in range(256):
inv = 0
if i:
for j in range(1, 256):
if _mul(i, j) == 1:
inv = j
break
x = inv
s = x ^ ((x << 1 | x >> 7) & 0xFF) ^ ((x << 2 | x >> 6) & 0xFF) ^ ((x << 3 | x >> 5) & 0xFF) ^ ((x << 4 | x >> 4) & 0xFF) ^ 0x63
SBOX[i] = s
INV[s] = i
_init_sbox()
def aes_expand(key):
w = [list(key[i:i + 4]) for i in range(0, 16, 4)]
rc = 1
for i in range(4, 44):
t = list(w[i - 1])
if i % 4 == 0:
t = [SBOX[b] for b in t[1:] + t[:1]]
t[0] ^= rc
rc = _xt(rc)
w.append([w[i - 4][j] ^ t[j] for j in range(4)])
return [sum(w[r * 4:r * 4 + 4], []) for r in range(11)]
def aes_enc(rk, b):
s = [b[i] ^ rk[0][i] for i in range(16)]
for r in range(1, 11):
s = [SBOX[x] for x in s]
s = [s[(i + 4 * (i % 4)) % 16] for i in range(16)]
if r != 10:
n = []
for c in range(4):
a = s[c * 4:c * 4 + 4]
n += [_mul(a[0], 2) ^ _mul(a[1], 3) ^ a[2] ^ a[3], a[0] ^ _mul(a[1], 2) ^ _mul(a[2], 3) ^ a[3],
a[0] ^ a[1] ^ _mul(a[2], 2) ^ _mul(a[3], 3), _mul(a[0], 3) ^ a[1] ^ a[2] ^ _mul(a[3], 2)]
s = n
s = [s[i] ^ rk[r][i] for i in range(16)]
return bytes(s)
def aes_dec(rk, b):
s = [b[i] ^ rk[10][i] for i in range(16)]
for r in range(9, -1, -1):
s = [s[(i - 4 * (i % 4)) % 16] for i in range(16)]
s = [INV[x] for x in s]
s = [s[i] ^ rk[r][i] for i in range(16)]
if r:
n = []
for c in range(4):
a = s[c * 4:c * 4 + 4]
n += [_mul(a[0], 14) ^ _mul(a[1], 11) ^ _mul(a[2], 13) ^ _mul(a[3], 9),
_mul(a[0], 9) ^ _mul(a[1], 14) ^ _mul(a[2], 11) ^ _mul(a[3], 13),
_mul(a[0], 13) ^ _mul(a[1], 9) ^ _mul(a[2], 14) ^ _mul(a[3], 11),
_mul(a[0], 11) ^ _mul(a[1], 13) ^ _mul(a[2], 9) ^ _mul(a[3], 14)]
s = n
return bytes(s)
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def kirk7(key, data):
"""KIRK command 7: AES-128-CBC decrypt, IV 0."""
rk = aes_expand(key)
out, prev = b'', bytes(16)
for i in range(0, len(data) - len(data) % 16, 16):
c = data[i:i + 16]
out += xor(aes_dec(rk, c), prev)
prev = c
return out
# Fixed KIRK 4/7 keys (already in ext/libkirk/kirk_engine.c keyvault).
KIRK_KEY_02 = bytes.fromhex('B813C35EC64441E3DC3C16F5B45E6484') # auth handshake messages
KIRK_KEY_44 = bytes.fromhex('7DF49265E3FAD678D6FE78ADBB3DFB63') # data keystream
# The handshake's ECDH curve (same as KIRK's ec_p/ec_a/ec_b).
EC_P = 0xFFFFFFFFFFFFFFFF00000001FFFFFFFFFFFFFFFF
EC_A = EC_P - 3
EC_B = 0xA68BEDC33418029C1D3CE33B9A321FCCBB9E0F0B
def on_curve(pt):
x = int.from_bytes(pt[:20], 'big')
y = int.from_bytes(pt[20:40], 'big')
return (y * y - (x * x * x + EC_A * x + EC_B)) % EC_P == 0
# ---- data cipher ---------------------------------------------------------------------------
#
# A data frame is: 00 02 | counter(8, LE) | tag(16) | ciphertext.
# The counter counts 16-byte blocks sent in that direction, cumulative, from 0.
# Keystream: KIRK-7 (AES-CBC, IV 0, key 0x44) of consecutive blocks [C||half, C+1||half', ...],
# where half = key[0:8] on even block counters and key[8:16] on odd. Ciphertext = plaintext XOR it.
# Tag: first 16 bytes of SHA1(key20 || counter_header(16) || ciphertext).
def keystream(key16, counter, nblocks):
blocks = b''
for i in range(nblocks):
c = counter + i
blocks += struct.pack('<Q', c) + (key16[0:8] if c & 1 == 0 else key16[8:16])
return kirk7(KIRK_KEY_44, blocks)
def data_tag(key20, hdr16, ct):
return hashlib.sha1(key20 + hdr16 + ct).digest()[:16]
def data_decrypt(key, body):
"""body = the 0x88C8 payload starting at 00 02. Returns (counter, plaintext, tag_ok).
A 16-byte key decrypts only (tag_ok=None); a 20-byte key also verifies the tag."""
hdr = body[2:18]
tag = body[18:34]
ct = body[34:]
ct = ct[:len(ct) - len(ct) % 16]
counter = struct.unpack('<Q', hdr[:8])[0]
ok = (data_tag(key, hdr, ct) == tag) if len(key) == 20 else None
pt = xor(ct, keystream(key[:16], counter, len(ct) // 16))
return counter, pt, ok
def data_encrypt(key20, counter, pt):
hdr = struct.pack('<Q', counter) + bytes(8)
ct = xor(pt, keystream(key20, counter, len(pt) // 16))
return b'\x00\x02' + hdr + data_tag(key20, hdr, ct) + ct
# ---- key recovery from known plaintext -----------------------------------------------------
#
# The first block's keystream = AES_dec(key44, C||half), so AES_enc(key44, ct[0:16] ^ pt[0:16])
# equals counter(LE64) || half. One AES_enc over a known first-block plaintext yields 8 key bytes,
# and the counter in bytes 0-7 confirming the guess is a 64-bit check. Two frames of opposite
# counter parity give the 16-byte key. The last 4 of the 20-byte key only feed the tag.
_RK44 = aes_expand(KIRK_KEY_44)
def half_from_known(counter, ct16, pt16):
blk = aes_enc(_RK44, xor(ct16, pt16))
return blk[8:16] if struct.unpack('<Q', blk[:8])[0] == counter else None
def recover16(knowns):
"""knowns: list of (counter, ct16, pt16). Returns the 16-byte key or None."""
halves = {}
for counter, ct16, pt16 in knowns:
h = half_from_known(counter, ct16, pt16)
if h:
halves[counter & 1] = h
return halves[0] + halves[1] if len(halves) == 2 else None
# ---- framing -------------------------------------------------------------------------------
#
# Decrypted payload is a PDP or PTP frame (same first 8 bytes: type, flags/00, length BE, src/dst
# port BE). PDP (type 01) then has a 16-bit sequence + 0000; a matching message rides in its
# payload as 01 opcode len16 data. PTP (type 02) has a flags byte and a 32-bit sequence, plus a
# 32-bit ack/window on control frames.
_PTP_FLAGS = {1: 'SYN', 2: 'SYN-ACK', 3: 'ACK', 4: 'DATA', 5: 'WND-ACK'}
def describe(pt):
if len(pt) < 8:
return 'short'
typ = pt[0]
ln = struct.unpack('>H', pt[2:4])[0]
sp, dp = struct.unpack('>H', pt[4:6])[0], struct.unpack('>H', pt[6:8])[0]
if typ == 1: # PDP
seq = struct.unpack('>H', pt[8:10])[0] if len(pt) >= 10 else 0
pay = pt[12:ln] if 12 < ln <= len(pt) else pt[12:]
s = 'PDP %04x->%04x seq %04x len %d' % (sp, dp, seq, ln)
if len(pay) >= 4 and pay[0] == 1: # matching message
op = pay[1]
mln = struct.unpack('>H', pay[2:4])[0]
s += ' | match op %d len %d %r' % (op, mln, bytes(pay[4:4 + min(mln, 16)]))
return s
if typ == 2: # PTP
flags = pt[1]
seq = struct.unpack('>I', pt[8:12])[0] if len(pt) >= 12 else 0
name = _PTP_FLAGS.get(flags, '?')
s = 'PTP %04x->%04x %-7s seq %d len %d' % (sp, dp, name, seq, ln)
if flags in (1, 2, 3, 5) and len(pt) >= 16:
s += ' ack/wnd %d' % struct.unpack('>I', pt[12:16])[0]
return s
return 'type %02x len %d' % (typ, ln)
# ---- pcap ----------------------------------------------------------------------------------
def read_frames(path):
f = open(path, 'rb').read()
magic = struct.unpack('<I', f[:4])[0]
if magic not in (0xA1B2C3D4, 0xA1B23C4D):
sys.exit('not a little-endian pcap')
off = 24
t0 = None
while off + 16 <= len(f):
ts, tu, cl, ol = struct.unpack('<IIII', f[off:off + 16])
off += 16
p = f[off:off + cl]
off += cl
t = ts + (tu / 1e9 if magic == 0xA1B23C4D else tu / 1e6)
if len(p) < 8:
continue
rl = struct.unpack('<H', p[2:4])[0]
if len(p) < rl + 24:
continue
present = struct.unpack('<I', p[4:8])[0]
w = p[rl:]
# radiotap flags field: bit 0 TSFT (8 bytes, 8-aligned) precedes it
fcs = False
if present & 2:
o = 8
pp = present
while pp & 0x80000000: # extended bitmaps
pp = struct.unpack('<I', p[o:o + 4])[0]
o += 4
if present & 1:
o = (o + 7) & ~7
o += 8
fcs = bool(p[o] & 0x10)
if fcs:
if len(w) >= 4 and zlib.crc32(w[:-4]) & 0xFFFFFFFF != struct.unpack('<I', w[-4:])[0]:
continue # bad FCS
w = w[:-4]
if len(w) < 24 or (w[0] >> 2) & 3 != 2:
continue
hdr = 24 + (2 if (w[0] >> 4) & 8 else 0)
body = w[hdr:]
if body[:8] != bytes.fromhex('AAAA0300000088C8'):
continue
if t0 is None:
t0 = t
yield t - t0, w[10:16].hex(':'), w[4:10].hex(':'), body[8:]
# ---- main ----------------------------------------------------------------------------------
def show_handshake(frames):
pubs = {}
for t, src, dst, b in frames:
if b[:2] != b'\x00\x01':
continue
kind, mtype, ln = b[2], b[3], struct.unpack('>H', b[4:6])[0]
print('%8.3f %s -> %s auth msg %02x/%02x len 0x%x' % (t, src, dst, kind, mtype, ln))
if mtype == 2:
print(' (broadcast, plain) %s' % b[6:6 + ln].rstrip(b'\xff').hex())
continue
pt = kirk7(KIRK_KEY_02, b[6:6 + ln])
for i in range(0, len(pt), 32):
print(' %03x %s' % (i, pt[i:i + 32].hex()))
pubs.setdefault(src, pt[0x10:0x38])
for src, pub in pubs.items():
print('ephemeral public key of %s (msg+0x10): %s on curve: %s' % (src, pub.hex(), on_curve(pub)))
def main():
ap = argparse.ArgumentParser(description='Decrypt and decode PSP ad hoc traffic from a pcap.')
ap.add_argument('pcap', nargs='?')
ap.add_argument('--key', help='session key (hex): 16 bytes decrypts, 20 bytes also verifies tags')
ap.add_argument('--known', action='append', default=[], metavar='HEX16@COUNTER',
help='a known first-block plaintext (16 bytes) for the data frame at COUNTER; '
'give two of opposite counter parity to recover the 16-byte key')
ap.add_argument('--raw', action='store_true', help='show raw plaintext hex instead of decoded framing')
ap.add_argument('--selftest', action='store_true')
a = ap.parse_args()
if a.selftest:
k = bytes(range(20))
pt = b'\x01\x00\x00\x10\x77\x00\x77\x00ABCDSYN\x01'
fr = data_encrypt(k, 0x12, pt)
c, back, ok = data_decrypt(k, fr)
assert back == pt and ok and c == 0x12
assert recover16([(c, fr[34:50], pt[:16]),
(c + 1, data_encrypt(k, c + 1, pt)[34:50], pt[:16])]) == k[:16]
print('selftest ok')
return
if not a.pcap:
ap.error('a pcap is required (or --selftest)')
frames = list(read_frames(a.pcap))
print('%d 0x88C8 frames' % len(frames))
show_handshake(frames)
data = [f for f in frames if f[3][:2] == b'\x00\x02']
print('\n%d data frames' % len(data))
key = bytes.fromhex(a.key) if a.key else None
if a.known:
byc = {}
for _, _, _, b in data:
byc.setdefault(struct.unpack('<Q', b[2:10])[0], b[34:50])
knowns = []
for item in a.known:
pthex, _, ctr = item.partition('@')
c = int(ctr)
if c not in byc:
sys.exit('no data frame at counter %d' % c)
knowns.append((c, byc[c], bytes.fromhex(pthex)))
key = recover16(knowns)
if not key:
sys.exit('could not recover the key: need one even and one odd counter, '
'and the known plaintext must be correct')
print('recovered 16-byte key: %s' % key.hex())
print('(decrypts; for tag verification recover the full 20-byte key with adhoc_recover.c)')
bycounter = {}
for t, src, dst, b in data:
counter = struct.unpack('<Q', b[2:10])[0]
bycounter.setdefault(counter, []).append((src, b[34:]))
if not key:
continue
counter, pt, ok = data_decrypt(key, b)
tagstr = '- ' if ok is None else ('ok ' if ok else 'BAD')
info = pt[:48].hex() if a.raw else describe(pt)
print('%8.3f %s -> %s ctr %5d tag %s %s' % (t, src[-5:], dst[-5:], counter, tagstr, info))
if not key:
# Without a key, show which counters both directions used (same keystream), as a hint for
# where to look for known plaintext: XOR of two such ciphertexts is XOR of their plaintexts.
same = [(c, v) for c, v in sorted(bycounter.items()) if len({s for s, _ in v}) > 1]
for t, src, dst, b in data[:30]:
print('%8.3f %s -> %s ctr %5d len %4d' % (t, src[-5:], dst[-5:], struct.unpack('<Q', b[2:10])[0], len(b) - 34))
print('\n%d counters used by both directions (keystream reuse); XOR of the two is the plaintext XOR' % len(same))
if __name__ == '__main__':
main()
+112
View File
@@ -0,0 +1,112 @@
// Fast helper for adhoc_decrypt.py's key recovery, for the cases that are too slow in pure Python:
// bruting a few unknown plaintext bytes, and bruting the 4 tag bytes of the 20-byte session key.
// See docs/adhoc-packet-decryption.md for the workflow. This recovers an ephemeral per-session key from
// a capture that contains known plaintext; it is not a break of the key exchange.
//
// cc -O3 adhoc_recover.c -o adhoc_recover (uses macOS CommonCrypto)
// or: cc -O3 adhoc_recover.c -lcrypto -o adhoc_recover with -DUSE_OPENSSL on Linux
//
// ./adhoc_recover half TEMPLATE CT16 COUNTER
// Recover 8 key bytes. TEMPLATE is the 16-byte first-block plaintext in hex, with "xx" for
// each unknown byte (at most ~5), which are brute-forced; the frame's counter validates the
// guess. Run once for an even-counter frame and once for an odd-counter frame; concatenate the
// even half then the odd half to get the 16-byte key that decrypts the capture.
//
// ./adhoc_recover tag KEY16 CT16 COUNTER TAG16
// Brute the last 4 bytes of the 20-byte session key (needed only to verify integrity tags),
// given the 16-byte key and any one frame's first 16 ciphertext bytes, its counter and its tag.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
#ifdef USE_OPENSSL
#include <openssl/aes.h>
#include <openssl/sha.h>
static void aes_ecb_enc(const uint8_t *k, const uint8_t *in, uint8_t *out) {
AES_KEY ek; AES_set_encrypt_key(k, 128, &ek); AES_encrypt(in, out, &ek);
}
static void sha1_16(const uint8_t *in, size_t n, uint8_t *out20) { SHA1(in, n, out20); }
#else
#include <CommonCrypto/CommonCryptor.h>
#include <CommonCrypto/CommonDigest.h>
static void aes_ecb_enc(const uint8_t *k, const uint8_t *in, uint8_t *out) {
size_t m; CCCrypt(kCCEncrypt, kCCAlgorithmAES, kCCOptionECBMode, k, 16, NULL, in, 16, out, 16, &m);
}
static void sha1_16(const uint8_t *in, size_t n, uint8_t *out20) { CC_SHA1(in, n, out20); }
#endif
static const uint8_t KEY44[16] = {0x7D,0xF4,0x92,0x65,0xE3,0xFA,0xD6,0x78,0xD6,0xFE,0x78,0xAD,0xBB,0x3D,0xFB,0x63};
static int hx(const char *s, uint8_t *o, int n) { for (int i = 0; i < n; i++) if (sscanf(s + 2 * i, "%2hhx", &o[i]) != 1) return 0; return 1; }
// Parse a 16-byte template "aabbxx..." into base bytes + the indices of the unknown ("xx") bytes.
static int parse_template(const char *s, uint8_t *base, int *unk, int *nunk) {
if (strlen(s) != 32) return 0;
*nunk = 0;
for (int i = 0; i < 16; i++) {
if (s[2 * i] == 'x' && s[2 * i + 1] == 'x') { base[i] = 0; unk[(*nunk)++] = i; }
else if (sscanf(s + 2 * i, "%2hhx", &base[i]) != 1) return 0;
}
return 1;
}
static int do_half(const char *tmpl, const char *cths, const char *ctrs) {
uint8_t base[16], ct[16]; int unk[16], nunk;
if (!parse_template(tmpl, base, unk, &nunk) || !hx(cths, ct, 16)) { fprintf(stderr, "bad template/ct\n"); return 2; }
uint64_t counter = strtoull(ctrs, 0, 10);
if (nunk > 6) { fprintf(stderr, "too many unknown bytes (%d); brute space too large\n", nunk); return 2; }
uint64_t total = 1; for (int i = 0; i < nunk; i++) total <<= 8;
for (uint64_t v = 0; v < total; v++) {
uint8_t pt[16]; memcpy(pt, base, 16);
uint64_t t = v; for (int i = 0; i < nunk; i++) { pt[unk[i]] = t & 0xff; t >>= 8; }
uint8_t x[16], blk[16];
for (int i = 0; i < 16; i++) x[i] = ct[i] ^ pt[i];
aes_ecb_enc(KEY44, x, blk);
uint64_t c; memcpy(&c, blk, 8);
if (c == counter) {
printf("half: "); for (int i = 8; i < 16; i++) printf("%02x", blk[i]);
printf(" (plaintext "); for (int i = 0; i < 16; i++) printf("%02x", pt[i]); printf(")\n");
return 0;
}
}
printf("no match (wrong template or ciphertext)\n");
return 1;
}
static uint8_t g_key[20], g_ct[16], g_want[16]; static uint64_t g_ctr;
static volatile int g_found; static uint32_t g_tail;
static void *tagworker(void *arg) {
long id = (long)arg;
uint8_t buf[52]; memcpy(buf, g_key, 16);
uint8_t hdr[16] = {0}; memcpy(hdr, &g_ctr, 8); memcpy(buf + 20, hdr, 16); memcpy(buf + 36, g_ct, 16);
for (uint64_t x = id; x < 0x100000000ULL && !g_found; x += 8) {
buf[16] = x; buf[17] = x >> 8; buf[18] = x >> 16; buf[19] = x >> 24;
uint8_t h[20]; sha1_16(buf, 52, h);
if (!memcmp(h, g_want, 16)) { g_tail = (uint32_t)x; g_found = 1; return 0; }
}
return 0;
}
static int do_tag(const char *k16, const char *cths, const char *ctrs, const char *tags) {
if (!hx(k16, g_key, 16) || !hx(cths, g_ct, 16) || !hx(tags, g_want, 16)) { fprintf(stderr, "bad hex\n"); return 2; }
g_ctr = strtoull(ctrs, 0, 10);
pthread_t t[8]; for (long i = 0; i < 8; i++) pthread_create(&t[i], 0, tagworker, (void *)i);
for (int i = 0; i < 8; i++) pthread_join(t[i], 0);
if (!g_found) { printf("tag tail not found (wrong key or frame)\n"); return 1; }
printf("key20: "); for (int i = 0; i < 16; i++) printf("%02x", g_key[i]);
printf("%02x%02x%02x%02x\n", g_tail & 0xff, (g_tail >> 8) & 0xff, (g_tail >> 16) & 0xff, (g_tail >> 24) & 0xff);
return 0;
}
int main(int argc, char **argv) {
if (argc == 5 && !strcmp(argv[1], "half")) return do_half(argv[2], argv[3], argv[4]);
if (argc == 6 && !strcmp(argv[1], "tag")) return do_tag(argv[2], argv[3], argv[4], argv[5]);
fprintf(stderr,
"usage:\n"
" %s half TEMPLATE16hex(xx=unknown) CT16hex COUNTER\n"
" %s tag KEY16hex CT16hex COUNTER TAG16hex\n", argv[0], argv[0]);
return 2;
}
+110
View File
@@ -0,0 +1,110 @@
# Decrypting captured PSP ad hoc traffic
A real PSP encrypts its ad hoc Wi-Fi link, so a sniffed capture of two consoles playing is
ciphertext. `Tools/adhoc_decrypt.py` decrypts it and decodes the PDP/PTP/matching framing. This is a
debugging aid: it lets you compare, byte for byte and in timing, what a game actually sends on
hardware against what PPSSPP sends for the same game at the `sceNetAdhoc` HLE level. It is **not**
needed to run ad hoc multiplayer in PPSSPP (PPSSPP never encrypts; it works at the HLE layer), and it
has nothing to do with cross-play - a real PSP will not accept an emulator as a peer, because the
handshake is signed with a per-console key.
The two fixed keys the tool uses already ship in PPSSPP (`ext/libkirk/kirk_engine.c`). The per-session
key is not one of them (see below).
## Capturing
Ad hoc is peer-to-peer, so there is no access point to join - you need a Wi-Fi interface in **monitor
mode** on the consoles' ad hoc channel.
- Set both PSPs to a fixed ad hoc channel (Settings > Network Settings), so you know where to listen.
- Capture raw 802.11 on that channel to a pcap. On macOS, Wireless Diagnostics > Sniffer writes one to
`/var/tmp`; on Linux, `iw`/`airmon-ng` to put a card in monitor mode, then `tcpdump`/Wireshark. The
capture must include the radiotap header.
- Not every Wi-Fi chip captures other devices' ad hoc frames in monitor mode; if a capture of a known
exchange has no `0x88C8` frames between the two PSP MACs, the adapter can't see them.
PPSSPP's ad hoc traffic is **not** encrypted (it is plain UDP/TCP on the relay or P2P), so this tool is
only for captures of real hardware.
## Decrypting with a known key
Tools/adhoc_decrypt.py CAPTURE.pcap --key HEX
A 16-byte key decrypts; a 20-byte key also verifies the integrity tags. Output is one line per data
frame with the decoded envelope, e.g.
1.334 57:3f -> e6:7c ctr 7 tag - PTP 2300->2300 SYN seq 493060044 len 16 ack/wnd 8192
1.474 57:3f -> e6:7c ctr 16 tag - PTP 2300->2300 DATA seq 0 len 1012
1.481 e6:7c -> 57:3f ctr 21 tag - PTP 2300->2300 WND-ACK seq 0 len 16 ack/wnd 7192
Pass `--raw` for plaintext hex instead of the decode. With no `--key`, it decodes the auth handshake,
lists the data frames, and reports which counters both directions used (a hint for key recovery).
## Recovering the key from the capture
The session key is ephemeral - negotiated per ad hoc group membership by an ECDH handshake - so it is
not in the capture, and it is not one of the fixed KIRK keys. But the data cipher has a known-plaintext
weakness, so a capture that contains some guessable plaintext is enough to recover the key, no console
memory needed. (This is not a break of the key exchange.)
Each keystream block is `AES(counter || half-of-key)`, so given a known 16-byte first-block plaintext,
one AES operation yields 8 key bytes, and the frame's own counter (in its cleartext header) confirming
the guess is a 64-bit check. One even-counter frame and one odd-counter frame give the 16-byte key.
**If you know a full 16-byte plaintext block** (pure Python, instant):
Tools/adhoc_decrypt.py CAPTURE.pcap --known HEX16@COUNTER --known HEX16@COUNTER
Give one `--known` for an even counter and one for an odd counter. It prints the 16-byte key and
decrypts.
**If part of that block is unknown** (e.g. the sequence number), brute the unknown bytes with the C
helper, which is much faster than Python:
cc -O3 Tools/adhoc_recover.c -o adhoc_recover # macOS; Linux: add -DUSE_OPENSSL -lcrypto
./adhoc_recover half TEMPLATE16 CT16 COUNTER # "xx" marks each unknown byte (<= ~5)
`TEMPLATE16` is the 32-hex-char first-block plaintext with `xx` for each unknown byte; `CT16` is that
frame's first 16 ciphertext bytes (from the tool's `--raw`/listing). Run it for an even- and an
odd-counter frame and concatenate the two `half:` outputs (even first) to get the 16-byte key.
**What you can usually know.** The ad hoc envelope has constant fields in every frame regardless of
game: byte 0 is the type, byte 1 is `00` (PDP) or a flags byte (PTP), and for PDP bytes 10-11 are
`0000`; the length field is pinned to a 16-byte range by the ciphertext size. The remaining unknowns
are the **ports**, the **sequence**, and the first payload bytes. The cheap way to learn the ports (and
any constant packet header) for a specific game is to run that game in PPSSPP once and note which ports
it opens (`sceNetAdhocPdpCreate` / `sceNetAdhocPtpOpen` in the log) - they are the same on hardware.
With the ports known, only the small sequence field is left to brute, so any past or future capture of
that game then cracks at once.
**Tag verification** (optional) needs the last 4 bytes of the 20-byte key, which only feed the tag.
Brute them once you have the 16-byte key:
./adhoc_recover tag KEY16 CT16 COUNTER TAG16 # ~15 s; prints the 20-byte key
## Frame format reference
802.11 data frames, LLC/SNAP, ethertype `0x88C8`. The `0x88C8` payload starts with a 2-byte kind:
- `00 01` **auth handshake** - AES-CBC under a fixed KIRK key, so it always decodes. Carries each
side's ephemeral ECDH public key and its console certificate; this is what establishes the session
key and what makes the link console-authenticated.
- `00 02` **data** - `00 02 | counter(8, little-endian) | tag(16) | ciphertext`. The counter counts
16-byte blocks sent in that direction, cumulative from 0. The tag is the first 16 bytes of
`SHA1(key20 || counter-header(16) || ciphertext)`.
The decrypted data payload is a PDP or PTP frame. Both start the same: `type(1) | flags-or-00(1) |
length(2, big-endian) | source port(2, BE) | dest port(2, BE)`.
- **PDP** (type `01`): then a 16-bit sequence and `00 00`, then the datagram. A matching-library
message rides in that payload as `01 | opcode(1) | length(2, BE) | data`.
- **PTP** (type `02`): byte 1 is a flags byte (SYN / SYN-ACK / ACK / DATA / window-ACK), then a 32-bit
sequence; control frames carry a 32-bit acknowledgement / advertised receive window after it, data
frames carry the payload. It is a TCP-like reliable stream, so a capture shows the three-way
handshake and the receive window changing as a buffer fills or drains.
## See also
- [adhoc-multiplayer-testing.md](adhoc-multiplayer-testing.md) - playing a game's ad hoc multiplayer
with several headless PPSSPP instances, to produce the HLE-level side of a hardware-vs-emulator
comparison.