$ cat writeup.md…
$ cat writeup.md…
securinets2026
Task: raw BLE link-layer hex dump of six connections (decoys plus a keyboard) using legacy pairing and a reused LTK. Solution: group frames by access address, brute-force the 6-digit TK from the SMP confirm, derive STK/session key, decrypt AES-CCM, reassemble HID reports, base64-decode.
A crowded channel carries voices, most of them harmless noise. One conversation slips between identities, guarded by a promise that should have been stronger. Follow the fragments, separate the real exchange from its decoys, and recover the message hidden in plain sight.
English summary: we are given a capture of Bluetooth Low Energy traffic on a "crowded" channel. Several devices talk at the same time; most traffic is decoy noise. One device (a keyboard) pairs with flawed legacy pairing and later reconnects using a distributed key. The goal is to recover what the keyboard typed, which is the flag.
| Clue | Meaning |
|---|---|
| "A Whisper in the Air" | Bluetooth Low Energy (over-the-air radio) |
| "dead keys" (archive name) | keyboard key codes |
| "crowded channel … harmless noise" | many connections; most are decoys |
| "one conversation slips between identities" | the target device reconnects with a random (resolvable/random) address using a bonded LTK |
| "a promise that should have been stronger" | legacy pairing — the temporary key is only a brute-forceable 6-digit passkey |
| "Follow the fragments, separate the real exchange from its decoys" | frames are shuffled in the file; file order is not the packet order |
frames.hex holds one hex line per BLE LE link-layer PDU:
[access address: 4 bytes, little-endian][PDU header: 2 bytes][payload: N bytes] (no CRC, no preamble)
aa = raw[0:4][::-1].d6be898e → on-air value 0x8E89BED6. Everything else is a data connection identified by its own access address.LLID(2) | NESN | SN | MD; byte 1 = payload length. LLID==2 is an L2CAP start/incomplete fragment and LLID==3 is an LL control PDU.Advertising packets carry the AD structure Complete Local Name (AD type 0x09) and the HID service UUID 0x1812, identifying KBD-ANSI (keyboard), M-SC4 (mouse), T-LITE (environment sensor). CONNECT_IND advertising packets (PDU type 5) map each connection's access address to the initiator/responder addresses and their address-type bits (TxAdd/RxAdd).
Six connections are present:
| Access address | Device | Role |
|---|---|---|
9156ba3f | KBD-ANSI | keyboard pairing (has key distribution) |
a1ef5256 | T-LITE | sensor — decoy pairing |
9c2d7907 | M-SC4 | mouse — decoy pairing |
3dec343c | KBD-ANSI | keyboard reconnection → keystrokes live here |
6454bf58 | T-LITE | sensor reconnection — noise |
d6bb6258 | M-SC4 | mouse reconnection — noise |
All three reconnections present the same EDIV=0x8c53 / Rand=de67263953ddc568, i.e. the devices reuse the same key identifiers — this is the "slips between identities" hint.
⚠️ The lines in frames.hex are deliberately shuffled (e.g. LL_ENC_REQ can appear before its SMP Pairing Request). Never trust file order for the packet counter — treat each packet independently.
During LE legacy pairing the two sides exchange an 11-byte SMP Pairing Request/Response and 16-byte Pairing Confirm/Pairing Random values over L2CAP CID 0x0006. The confirm is the AES-based function c1:
c1(TK, R, p1, p2) = e(TK, e(TK, R XOR p1) XOR p2)
key(TK) = 12 zero bytes || TK (4 bytes, big-endian)
p1 = pres || preq || rat || iat (passkey response, passkey request, responder/initiator address types)
p2 = padding(4 zero bytes) || ia(6) || ra(6)
For each connection, brute-force the 6-digit passkey TK ∈ [0, 999999] and recompute c1(TK, R, p1, p2) for each observed random R, comparing to the observed confirm. The three pairing connections yield TKs 627341, 483209, 190458. This is exactly what crackle automates; here it was re-implemented in Python because of the next pitfall.
STK = s1(TK, Srand, Mrand) = AES( key(TK), Srand[8:16] || Mrand[8:16] )
SK = AES( STK, SKDs || SKDm ) # session key = AES(STK, concatenated master/slave SKDs)
IV = IVm || IVs # on-air order
⚠️ Because frames are shuffled, the two 16-byte Pairing Random values cannot be trusted to be in the "master then slave" order. Try both assignments of Srand/Mrand and keep the one that decrypts. The keyboard connection needs the reversed assignment.
BLE link-layer encryption is AES-CCM with a very specific framing:
L = 2 (two length bytes), M = 4 (4-byte MIC/tag).5-byte packet counter (little-endian) with a direction bit || 8-byte IV.
0x80 = master → slave, 0x00 = slave → master.header_byte0 & 0xe3 (the payload length is not in the AAD). Using two bytes (e.g. appending the length byte) silently breaks every MIC and yields "0 decrypted packets".SKDs/SKDm/IVm/IVs come from the SMP Encryption Information / LL_ENC_REQ / LL_START_ENC_REQ LL control PDUs (opcodes 0x03/0x04 in the encrypted-connection parse below).
Because the file is shuffled, do not track the packet counter sequentially (as crackle does). For every packet, search a small counter window (0..~4000) and both directions, and accept the one whose MIC verifies.
Decrypt the three pairing connections. The SMP Encryption Information key-distribution packet (SMP opcode 0x06) carries the device's LTK (16 bytes), and SMP Master Identification (opcode 0x07) carries EDIV/Rand. The recovered LTKs:
| Connection | Device | LTK |
|---|---|---|
a1ef5256 | T-LITE | 6b08328c043e60a5732b1979161c0ae0 |
9c2d7907 | M-SC4 | dd262e86870693ad3a56d1d23a4deb8d |
9156ba3f | KBD-ANSI (keyboard) | 11eff36e379750644113081596a433c1 |
Then decrypt the reconnection connections using the LTK in place of the STK:
SK = AES( LTK, SKDs || SKDm )
The keyboard reconnection 3dec343c decrypts and carries ATT Handle Value Notifications (CID 0x0004, ATT opcode 0x1B) on handle 0x0014 with HID input reports.
Each HID input report is 9 bytes: [report_id=0x02][modifier][reserved][keycode][...][seq]. The last byte is a monotonically increasing sequence number, which lets you reorder the notifications despite the shuffled capture.
keycode != 0), keyed by seq.seq.modifier == 0x02 (Left Shift), use the shifted symbol.Securinets{...}).#!/usr/bin/env python3 # A Whisper in the Air (securinets2026) — BLE legacy pairing + LTK reuse # pip install pycryptodome import base64 from collections import defaultdict from Crypto.Cipher import AES FRAMES = "frames.hex" ADV_AA = "8e89bed6" # 0x8E89BED6 on air def aes_ecb(k, d): return AES.new(k, AES.MODE_ECB).encrypt(d) # ---------- 1. parse the raw link-layer dump ---------- conns = defaultdict(list) # aa -> [(file_idx, header0, payload)] adverts = [] for idx, line in enumerate(open(FRAMES)): line = line.strip() if not line: continue raw = bytes.fromhex(line) aa = raw[:4][::-1].hex() # access address stored LSB-first ll = raw[4:] # 16-bit PDU header + payload (no CRC) hdr, plen = ll[0], ll[1] payload = ll[2:2 + plen] if aa == ADV_AA: adverts.append((idx, hdr, payload)) else: conns[aa].append((idx, hdr, payload)) # ---------- 2. map AA -> addresses from CONNECT_IND (PDU type 5) ---------- meta = {} for idx, hdr, payload in adverts: if (hdr & 0x0f) == 5 and len(payload) >= 34: aa = payload[12:16][::-1].hex() # the AA announced in the connect if aa in conns: meta[aa] = dict( ia=payload[0:6][::-1].hex(), ra=payload[6:12][::-1].hex(), iat=1 if hdr & 0x40 else 0, # TxAdd rat=1 if hdr & 0x80 else 0, # RxAdd ) # ---------- 3. collect SMP pairing material per connection ---------- smp = defaultdict(lambda: dict(preq=None, pres=None, confirms=[], randoms=[])) for aa, pkts in conns.items(): for idx, hdr, payload in pkts: if (hdr & 3) == 2 and len(payload) >= 5: # L2CAP fragment l2len = payload[0] | (payload[1] << 8) cid = payload[2] | (payload[3] << 8) cmd = payload[4] if cid == 0x0006: # SMP body = payload[4:4 + l2len - 4] if cmd == 0x01: smp[aa]['preq'] = body[1:8][::-1] # Pairing Request if cmd == 0x02: smp[aa]['pres'] = body[1:8][::-1] # Pairing Response if cmd == 0x03: smp[aa]['confirms'].append(body[1:17][::-1]) if cmd == 0x04: smp[aa]['randoms'].append(body[1:17][::-1]) # ---------- 4. brute-force the 6-digit TK ---------- def c1(key, rand, p1, p2): x = aes_ecb(key, bytes(a ^ b for a, b in zip(rand, p1))) return aes_ecb(key, bytes(a ^ b for a, b in zip(x, p2))) def find_tk(aa): s = smp[aa] p1 = s['pres'] + s['preq'] + bytes([s.get('rat', 0), s.get('iat', 0)]) p2 = bytes(4) + bytes.fromhex(meta[aa]['ia']) + bytes.fromhex(meta[aa]['ra']) confirms = set(s['confirms']) for tk in range(1000000): key = bytes(12) + tk.to_bytes(4, 'big') if any(c1(key, r, p1, p2) in confirms for r in s['randoms']): return tk return None tks = {aa: find_tk(aa) for aa in smp if smp[aa]['confirms']} tks = {aa: tk for aa, tk in tks.items() if tk is not None} print("TKs:", tks) # ---------- 5. pull SKD/IV material from LL control PDUs (opcode 3/4) ---------- encinfo = defaultdict(dict) for aa, pkts in conns.items(): for idx, hdr, payload in pkts: if (hdr & 3) == 3 and payload: # LL control PDU if payload[0] == 0x03 and len(payload) == 23: # LL_ENC_REQ encinfo[aa]['skdm'] = payload[11:19][::-1] encinfo[aa]['ivm'] = payload[19:23][::-1] elif payload[0] == 0x04 and len(payload) == 13: encinfo[aa]['skds'] = payload[1:9][::-1] encinfo[aa]['ivs'] = payload[9:13][::-1] # ---------- 6. AES-CCM decryption with a per-packet counter search ---------- def decrypt_conn(aa, key, search=4000): e = encinfo[aa] sk = aes_ecb(key, e['skds'] + e['skdm']) iv = e['ivm'][::-1] + e['ivs'][::-1] out = [] for idx, hdr, pl in conns[aa]: if (hdr & 3) not in (1, 2, 3) or len(pl) < 5: continue ct, mic, ad = pl[:-4], pl[-4:], bytes([hdr & 0xe3]) # AAD is ONE byte hit = None for direction in (0, 1): for ctr in range(search): nonce = bytearray(ctr.to_bytes(5, 'little')) if direction == 0: nonce[4] |= 0x80 # master -> slave nonce = bytes(nonce) + iv try: ci = AES.new(sk, AES.MODE_CCM, nonce=nonce, mac_len=4) ci.update(ad) pt = ci.decrypt_and_verify(ct, mic) hit = (direction, ctr, pt) break except Exception: pass if hit: break if hit: out.append((idx, hdr, hit[0], hit[1], hit[2])) return out # ---------- 7. decrypt pairing conns, harvest distributed LTKs ---------- LTKs = {} for aa, tk in tks.items(): r = smp[aa]['randoms'] for m, s in ((r[0], r[1]), (r[1], r[0])): # shuffled: try both stk = aes_ecb(bytes(12) + tk.to_bytes(4, 'big'), s[8:16] + m[8:16]) out = decrypt_conn(aa, stk) if not out: continue print(f"[{aa}] STK order m={m.hex()[:8]} decrypts {len(out)} packets") for idx, hdr, direction, ctr, pt in out: if (hdr & 3) == 2 and len(pt) >= 5 and pt[2] == 6: if pt[4] == 0x06: # Encryption Information LTKs[aa] = pt[5:21][::-1] print(f" [{aa}] LTK = {LTKs[aa].hex()}") if pt[4] == 0x07: # Master Identification print(f" [{aa}] EDIV={pt[5:7][::-1].hex()} RAND={pt[7:15][::-1].hex()}") break print() # ---------- 8. decrypt the keyboard reconnection and reassemble keystrokes ---------- KBD_RECONNECT = '3dec343c' kbd_ltk = LTKs.get('9156ba3f') # keyboard pairing conn assert kbd_ltk, "keyboard LTK not recovered" events = {} for idx, hdr, direction, ctr, pt in decrypt_conn(KBD_RECONNECT, kbd_ltk): # ATT Handle Value Notification: len(2) cid=0x0004 opcode=0x1b handle(2) value if len(pt) >= 9 and pt[2] == 0x04 and pt[3] == 0x00 and pt[4] == 0x1b: val = pt[7:7 + 9] if len(val) < 9: continue seq, mod, key = val[8], val[1], val[3] if key != 0: # press event events[seq] = (mod, key) KEYMAP = {0x04:'a',0x05:'b',0x06:'c',0x07:'d',0x08:'e',0x09:'f',0x0a:'g',0x0b:'h',0x0c:'i', 0x0d:'j',0x0e:'k',0x0f:'l',0x10:'m',0x11:'n',0x12:'o',0x13:'p',0x14:'q',0x15:'r', 0x16:'s',0x17:'t',0x18:'u',0x19:'v',0x1a:'w',0x1b:'x',0x1c:'y',0x1d:'z',0x1e:'1', 0x1f:'2',0x20:'3',0x21:'4',0x22:'5',0x23:'6',0x24:'7',0x25:'8',0x26:'9',0x27:'0', 0x28:'\n',0x2d:'-',0x2e:'=',0x2f:'[',0x30:']',0x31:'\\',0x33:';',0x34:"'",0x35:'`', 0x36:',',0x37:'.',0x38:'/',0x2c:' '} SHIFT = {0x1e:'!',0x1f:'@',0x20:'#',0x21:'$',0x22:'%',0x23:'^',0x24:'&',0x25:'*',0x26:'(', 0x27:')',0x2d:'_',0x2e:'+',0x2f:'{',0x30:'}',0x31:'|',0x33:':',0x34:'"',0x35:'~', 0x36:'<',0x37:'>',0x38:'?'} typed = '' for seq in sorted(events): mod, key = events[seq] if mod == 0x02: # Left Shift typed += SHIFT.get(key) or (KEYMAP.get(key, '?').upper()) else: typed += KEYMAP.get(key, '?') print("typed (base64):", typed) print("decoded:", base64.b64decode(typed + '=' * (-len(typed) % 4)).decode('utf-8', 'replace'))
The script prints the recovered keystrokes as a base64 string and its decoded value — the flag (Securinets{REDACTED}).
crackle (github.com/mikeryan/crackle) implements the TK brute force and the BLE crypto correctly, but it starts collecting encrypted packets only after it sees LL_START_ENC_REQ in the clear. In this challenge that PDU is itself encrypted, so crackle stops at 0 encrypted packets. Two options:
crackle/test.c, tests/) so the AES-CCM parameters are proven correct.Use this technique when:
AA + 2-byte header + payload, no CRC) or a packet capture whose link-layer PDUs are not decrypted.d6be898e / 0x8E89BED6 appears — it marks advertising; every other AA is a data connection.SMP frames over L2CAP CID 0x0006 contain Pairing Confirm (0x03) / Pairing Random (0x04) → legacy pairing with a brute-forceable 6-digit TK.SMP Encryption Information (0x06) / Master Identification (0x07) appear after pairing → an LTK is being distributed and can be reused on reconnect connections.header_byte0 & 0xe3 (one byte!). A missing/incorrect AAD length makes every MIC fail silently.Srand/Mrand assignments; never rely on sequential counters.0x0014, HID report id 0x02) → reassemble keyboard reports by sequence byte and map HID usage codes with Shift handling.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar