mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-11-12 17:19:28 +01:00
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:
1 parent
d8ff2bd62e
commit
b7852eace2
4 files changed
+584
No files matched your search
@@ -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 |
|
||||
|
||||
Executable
+361
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user