RemoBLE Control

⭐⭐
April 12, 2026 FCSC 2026 #hardware #bluetooth

Disclaimer

Solving the challenge was done using AI probably over the limit of what was acceptable by the official rules. I reported it to the organizers and was not disqualified despite my usage being qualified as borderline by them. Thank you for having let me finish competing.

LLM abuse

Anyway here is the full writeup in which each step where I used LLM is explicitly mentionned.

More importantly: thanks again to the challmakers for a really interesting challenge in which I learnt LOADS !

Analysis of the instructions

We have two Bluetooth Low Energy (BLE) captures (in hex format in a text file). We know they were captured using wsniff --interface uart0 --format=raw --no-metadata ble -f.

When we check the product mentionned in the instructions it a Nordic SemiConductors nRF52840.

Then we finally know that these packets should contain encoded keystrokes.

First step read the packets using scapy.

from scapy.layers.bluetooth4LE import BTLE
from scapy.packet import Packet

with open('session1.txt', 'r') as f:
    data_lines = f.readlines()


def get_packet_layers(packet: Packet) -> list[Packet]:
    layers = []
    counter = 0
    while True:
        layer: Packet | None = packet.getlayer(counter)
        if layer is None:
            break
        layers.append(type(layer))
        counter += 1
    return layers

for line in data_lines:
    pkt_bytes = bytes.fromhex(line)

    pkt = BTLE(pkt_bytes)

    pkt.show()
    layers = get_packet_layers(pkt)
    print(f'Layers: {layers}\n')

The packets are decoded as BLE without any issues But something immediately scares me, and I understand why this challenge is two stars.

meme1

###[ BT4LE ]###
  access_addr= 0xa2f991d6
  crc       = 0x8dec27
###[ BTLE data header ]###
     RFU       = 0
     MD        = 0
     SN        = 1
     NESN      = 1
     LLID      = start
     len       = 21
###[ L2CAP header ]###
        len       = 17
        cid       = 6
###[ SM header ]###
           sm_command= 4
###[ Pairing Random ]###
              random    = b'!K\x8a\x88V#\x0f\xd41\xae\xa5\x9cU\xdd\xb6\xe5'

###[ BT4LE ]###
  access_addr= 0xa2f991d6
  crc       = 0x930702
###[ BTLE data header ]###
     RFU       = 0
     MD        = 0
     SN        = 0
     NESN      = 1
     LLID      = start
     len       = 21
###[ L2CAP header ]###
        len       = 17
        cid       = 6
###[ SM header ]###
           sm_command= 4
###[ Pairing Random ]###
              random    = b'\x1e\xe2<\xde/EX\x00C\xea\x0e\r\xd5R\xa9\xd6'

###[ BT4LE ]###
  access_addr= 0xa2f991d6
  crc       = 0x6798bf
###[ BTLE data header ]###
     RFU       = 0
     MD        = 0
     SN        = 0
     NESN      = 0
     LLID      = control
     len       = 23
###[ BTLE_CTRL ]###
        opcode    = LL_ENC_REQ
###[ LL_ENC_REQ ]###
           rand      = 0x0
           ediv      = 0x0
           skdm      = 0xc54051567d56b2b1
           ivm       = 0xbcb0817c

###[ BT4LE ]###
  access_addr= 0xa2f991d6
  crc       = 0x29b0e3
###[ BTLE data header ]###
     RFU       = 0
     MD        = 1
     SN        = 1
     NESN      = 0
     LLID      = control
     len       = 13
###[ BTLE_CTRL ]###
        opcode    = LL_ENC_RSP
###[ LL_ENC_RSP ]###
           skds      = 0xf1ed03e1a43da783
           ivs       = 0x4bbe5d67

###[ BT4LE ]###
  access_addr= 0xa2f991d6
  crc       = 0x5d1be7
###[ BTLE data header ]###
     RFU       = 0
     MD        = 0
     SN        = 0
     NESN      = 0
     LLID      = control
     len       = 5
###[ BTLE_CTRL ]###
        opcode    = 136
###[ Raw ]###
           load      = b'\xef\xe1\x0b\x0b'

###[ BT4LE ]###
  access_addr= 0xa2f991d6
  crc       = 0x2e0611
###[ BTLE data header ]###
     RFU       = 0
     MD        = 1
     SN        = 1
     NESN      = 0
     LLID      = control
     len       = 5
###[ BTLE_CTRL ]###
        opcode    = 234
###[ Raw ]###
           load      = b'l\xd6C\x1c'

Without being a bluetooth expert it is immediate that things are turning awry.

With a quick search on internet we find mikeryan/crackle, which is supposed to decypher it.

Long story short it does not. We must

  • reformat the txt file,
  • convert it to pcap (with text2pcap),
  • realize that it does not support LE
  • find a PR of someone who adds this feature
  • recompile
  • execute
  • get
$ crackle -i session1.pcap -o session1_decrackled.pcap
PCAP contains [BLUETOOTH_LE_LL] frames
Found 1 connection

Analyzing connection 0:
  d4:ab:61:4f:f6:81 (public) -> 8c:08:8b:00:03:c8 (public)
  Found 0 encrypted packets
  Cracking with strategy 0, 20 bits of entropy

  !!!
  TK found: 000000
  ding ding ding, using a TK of 0! Just Cracks(tm)
  !!!

  Decrypted 0 packets

Did not decrypt any packets, not writing a new PCAP
Done, processed 0 total packets, decrypted 0

What we learned is that the connection is using a TK of zeroes whatever that means, and that enables us to “break” the key exchange.

RTFM

As in nearly all the hardware challenges, we must find the document that describes the protocol to be able to understand what is happening. Luckily for us it is easily available and only 3085 pages long.

meme2

Understanding the key/pairing exchange

After some research we search for “Pairing methods” in the pdf and we get to volume 3, part H section 2.3 of the Bluetooth core specification (I am reading version 5.3).

Here we learn that

All of the LE legacy pairing methods use and generate 2 keys:

  1. Temporary Key (TK): a 128-bit temporary key used in the pairing process which is used to generate STK (see Section 2.3.5.5).
  2. Short Term Key (STK): a 128-bit temporary key used to encrypt a connection following pairing.

The LE Secure Connections pairing methods use and generate 1 key:

  1. Long Term Key (LTK): a 128-bit key used to encrypt the connection following pairing and subsequent connections.

I will go fast through the OOB. But there is the possibility of doing the key exchange over a second “more secure” channel than the channel the BLE, this is called out-of-band (oob) examples of such channels include:

  • QR codes
  • NFC (very short range (cm) compared to bluetooth (m))
  • manual input

In our packets it is not negotiated as we have oob = not present for both packets issues by master and slave.

In the whole paragraph they distinguish between “Legacy pairing” and “Secure pairing”, to know in which case we are we need to go to the section 3.5 - Pairing methods where they explain how to read the “authentication” value:

  • slave : authentication = 1 : 00 0 0 0 0 01 -> bonding requested, no MITM , SC not supp , …
  • master: authentication = 45: 00 1 0 1 1 01 -> bonding requested, MITM requested, SC supported, …

Going back to section 2.3 - Pairing methods we understand that we will be in Legacy pairing, with Just works/“Use IO Capabilities”

Next step is then to check iocap (input output capabilities) are:

  • slave : NoInputNoOutput
  • master: KeyboardDisplay

Once again the most limiting decides that the key exchange will not be able to be interactive. We will be using Just Works (Unauthenticated) as key generation method.

And we arrive to paragraph 2.3.5.2 - LE Legacy pairing - Just Works

The Just Works STK generation method provides no protection against eavesdroppers or man in the middle attacks during the pairing process. If the attacker is not present during the pairing process then confidentiality can be established by using encryption on a future connection. Both devices set the TK value used in the authentication mechanism defined in Section 2.3.5.5 to zero

This means we should be able to decrypt the communication.

Extracting the keys to the kingdom

Long story short the next things we see in the exchange are the fact that

  • both devices calculate a seed, and a “commitment” hash.
  • they exchange their hashes
  • then they exchange their keys

This is to prevent them from cheating on the values of their keys as they were obliged to send the hash before having any information from the “adversary”.

What interest us is these seeds are LP_RAND_R and LP_RAND_I These values are used to derive STK = s1(TK, LP_RAND_R, LP_RAND_I) which is the second key which was mentionned above (the short term key). s1 is defined shortly above in the paper with examples to be able to test if we implemented it correctly.

With this we nearly have all we need to decypher session 1.

What suprises us is that we still have not used the ivs ivm skds skdm, when probing my favorite LLM I learn that these are used to derive the session key SK… We will come back to this in a bit.

Session keys

The paragraph is simply

2.4 SESSION KEYS The session key for an ACL connection shall be generated as specified in [Vol 6] Part B, Section 5.1.3.1. The session key for a CIS shall be the same as that for the associated ACL. The session key for a BIS shall be the Group Session Key derived in Section 1.1.2.

What does this mean ?

  • ACL = Asynchronous Connection Oriented Link The standard in nearly all BLE for data transfer
  • CIS = Connected Isochronous Stream For Audio needing to be sent at specific timestamps
  • BIS = Broadcast Isochronous Stream Same but in a one to many logic (e.g. a TV connected to 5 loud speakers)

We are in the ACL then !

tl;dr We see a formula at the bottom of the page

SKD = SKD_P || SKD_C
IV = IV_P || IV_C
...
SK = Encrypt(STK, SKD) # from the examples, I got bored of reading the verbose norm and skipped

Back to crypto

Now the details of the algorithm used for encryption and decryption are given in Volume 6 - part E - LE LL Security.

The algorithm used needs precise understanding it is as described:

The Link Layer provides encryption and authentication using Counter with Cipher Block Chaining-Message Authentication Code (CCM) Mode, which shall be implemented consistent with the algorithm as defined in IETF RFC 3610 (http://www.ietf.org/rfc/rfc3610.txt) in conjunction with the AES-128 block cipher as defined in NIST Publication FIPS-197 (http://csrc.nist.gov/ publications/fips/fips197/fips-197.pdf). A description of the CCM algorithm can also be found in the NIST Special Publication 800-38C (http://csrc.nist.gov/publications/PubsSPs.html)

Luckily pycryptodome has implemented all these algorithms for us.

Nevertheless a few blocks are missing

  • a CCM Nonce: it is generated from a 39-bit packetCounter, 1-bit directionBit and 8-octet IV (funny to note that when seeing the iv in the exchange I thought AES but it is not used the same way as in AES-CBC… which was what I expected… erroneously ofc)

For each ACL connection, the packetCounter shall be set to zero for the first encrypted Data Physical Channel PDU sent during the encryption start procedure. The packetCounter shall then be incremented by one for each new Data Physical Channel PDU that is encrypted. The packetCounter shall not be incremented for retransmissions.

  • AAD: we are in AES-CCM and the use of an AAD ensures that the clear part of the message is not modified either (it’s construction is described in the norm)

Recovering session 1

Integration hell

When trying to implement I made quite a few mistakes here are some that could be overlooked when “just” reading this wu…

  1. endianness / concatenations / padding The details of the implementation were far more tedious than reading the norm as questions of bit masking, endianness are always a pain to implement in python: I admit I used a LLM to fix this by providing him with the test sets of the norm and the implementation of crackle as I do not know all the options of pack library.

  2. Getting the AAD (and a correct implementation of AES-CCM), I had overlooked this part and kept getting ValueError: MAC check failed, luckily a LLM told me that my understanding of the norm was crappy at best.

  3. Directions and packet count… This took waaaay to long to figure out. Even when all these steps were correct because I was not keeping a correct packet count on the master’s and on the slave’s side (and their directions are 1 and 0 in the nonce generation). And as we can have packets lost in the middle I opted for a “brute approach” which tested the 5 next packet counter values until it found one that worked, for master and same for slave.

  4. Scapy does not understand encypted packets It would try to parse the encrypted data and would give weird lengths… So I resorted to reading the lines again.

  5. Keep alive packets Packets that empty (“Empty PDU”) also count for the incrementation of the packet counter

Content of session 1

When we decypher it we see that what happens is the exchange of the Long Term Keys (LTK) which are in clear. (SMP packet with opcode 0x06: meaning “Encryption information”)

This long term key is to prevent future eavesdroppers of decyphering the communication. The rest we don’t care about for the challenge.

From what a LLM told me it’s

  • exchanging information to recognize themselves even if their MAC address changes
  • exchanging info to know which keys to reuse when they meet again
  • what can each device do and who they are

Btw one of them is the most notorious “Télécommande”

Session 2

When reading the file we see that most packets are encypted, but some are well known, we see an exchange of skdm, skds, ivm, ivs. These are used to derive the new session key from the LTK that they negotiated before. This is to prevent reusing the same key, and having a fundamentaly flawed AES implementation.

The packets above the LL_ENC seem to be the packets mentionned in the end of the previous part saying: remember me ?

Once this is done we can decypher the session 2.

Encapsulated and decrypted we find the Attribute Protocol (ATT) packets (Volume 3 - Part F - Attribute Protocol(ATT))

ATT

In this the two objects have a client / server relationship between them. One exposes his attributes, and can see them be queried by the other.

Attribute have three elements:

  • attribute type/UUID
  • attribute handle: identifies the attribute uniquely on the server
  • a set of permissions… (these cannot be accessed using the ATT protocol)

In LE, there is a single ATT bearer that uses a fixed channel that is available as soon as the ACL connection is established. Additional ATT bearers can be established using L2CAP

Here we have two beares over L2CAP:

  • the security manager (CID: 0x0006)
  • the attribute bearer (CID: 0x0004) -> which interest us more

Moreover Attributes have a PDU type

  • CMD: PDUs sent to a server by a client that do not invoke a response
  • REQ: PDUs sent to a server by a client that invoke a response.
  • RSP: PDUs sent to a client by a server in response to a request.
  • NTF: Unsolicited PDUs sent to a client by a server that do not invoke a confirmation.
  • IND: Unsolicited PDUs sent to a client by a server that invoke a confirmation
  • CFM: PDUs sent to a server by a client to confirm receipt of an indication.

We can guess from this that our keyboard (server) will be sending NTF or IND to the computer it is paired to (client).

When we check the opcodes of all our packets many are of type NTF (opcode 0x1B), and the 8-byte payload (after the len|cid|opcode|handle) after corresponds to the Boot Keyboard format. This is also known as HOGP: HID Over GATT Profile BLE So decoding the PDU is

LLEN CCID OP HNDL 00 _KEYCODE__ARRAY_

And the Keycode array is itself separated

  • 1-byte modifier (ctrl/maj/altgr…)
  • 1 null byte
  • 6 keypresses (at most (for very fast fishers))

The table is available USB HID Usage Tables for Universal Serail Bus

The only modification to be made to the table in section 0x07 is that we are azerty and not qwerty.

Once this is done we can decode all the keyboard presses and get the flag.

Hopefully you learnt things on bluetooth LE and you enjoyed this WU !!

Final script

This final script was partially vibe coded (this is especially visible with the tell-tale [*])

import argparse
import struct
from typing import Optional

from Crypto.Cipher import AES
from scapy.layers.bluetooth import SM_Random
from scapy.layers.bluetooth4LE import BTLE, LL_ENC_REQ, LL_ENC_RSP

TK = b'\x00' * 16
global_ltks: dict[str, bytes] = {}

FLAG = ''


def aes_e(key: bytes, data: bytes) -> bytes:
    return AES.new(key, AES.MODE_ECB).encrypt(data)


def build_nonce(iv: bytes, counter: int, direction: int) -> bytes:
    return struct.pack('<I', counter) + bytes([direction << 7]) + iv


azerty = {
    0x04: 'q',
    0x14: 'a',
    0x1D: 'w',
    0x1A: 'z',
    0x08: 'e',
    0x15: 'r',
    0x17: 't',
    0x1C: 'y',
    0x18: 'u',
    0x0C: 'i',
    0x12: 'o',
    0x13: 'p',
    0x07: 'd',
    0x09: 'f',
    0x0A: 'g',
    0x0B: 'h',
    0x0D: 'j',
    0x0E: 'k',
    0x0F: 'l',
    0x10: 'm',
    0x16: 's',
    0x1B: 'x',
    0x06: 'c',
    0x19: 'v',
    0x05: 'b',
    0x11: 'n',
    0x2C: ' ',
    0x28: '[ENTER]\n',
    0x2A: '[BKSP]',
    0x1E: '1',
    0x1F: '2',
    0x20: '3',
    0x21: '4',
    0x22: '5',
    0x23: '6',
    0x24: '7',
    0x25: '8',
    0x26: '9',
    0x27: '0',
    0x2D: '-',
    0x2E: '=',
}


def decode_hid(mod: int, codes: bytes) -> str:
    shift = bool(mod & 0x02) or bool(mod & 0x20)
    altgr = bool(mod & 0x40)
    res = ''
    for code in codes:
        if code == 0 or code not in azerty:
            continue
        c = azerty[code]
        if altgr:
            if code == 0x21:
                c = '{'
            elif code == 0x2E:
                c = '}'
        elif shift:
            if c.isalpha() and len(c) == 1:
                c = c.upper()
        else:
            if code == 0x1E:
                c = '&'
            elif code == 0x1F:
                c = 'é'
            elif code == 0x20:
                c = '"'
            elif code == 0x21:
                c = "'"
            elif code == 0x22:
                c = '('
            elif code == 0x23:
                c = '-'
            elif code == 0x24:
                c = 'è'
            elif code == 0x25:
                c = '_'
            elif code == 0x26:
                c = 'ç'
            elif code == 0x27:
                c = 'à'
            elif code == 0x2D:
                c = ')'
            elif code == 0x2E:
                c = '='
        res += c
    return res


def extract_text(data: bytes) -> Optional[str]:
    if len(data) < 4:
        return None
    try:
        s = data[4:].decode('utf-8')
        if any(c.isprintable() for c in s):
            return ''.join(c for c in s if c.isprintable() or c in ' \n\t')
    except UnicodeDecodeError:
        return None
    return None


def process_session(filename: str, verbose: bool) -> None:
    print(f"\n{'='*60}")
    print(f'[{filename}] Analyzing and Decrypting Traffic...')
    print(f"{'='*60}\n")
    try:
        lines = [bytes.fromhex(x.strip()) for x in open(filename) if x.strip()]
    except Exception as e:
        print(f'[-] Error loading {filename}: {e}')
        return

    m_rand, s_rand = None, None
    skdm, skds, ivm, ivs = None, None, None, None
    enc_idx = -1

    for i, raw in enumerate(lines):
        try:
            pkt = BTLE(raw)
        except Exception as e:
            print(f'[-] Error parsing line {i} in {filename}: {e}')
            continue
        if pkt.haslayer(SM_Random):
            if m_rand is None:
                m_rand = pkt[SM_Random].random
            else:
                s_rand = pkt[SM_Random].random
        if pkt.haslayer(LL_ENC_REQ):
            skdm = struct.pack('<Q', pkt[LL_ENC_REQ].skdm)
            ivm = struct.pack('<I', pkt[LL_ENC_REQ].ivm)
        if pkt.haslayer(LL_ENC_RSP):
            skds = struct.pack('<Q', pkt[LL_ENC_RSP].skds)
            ivs = struct.pack('<I', pkt[LL_ENC_RSP].ivs)
            enc_idx = i

    if not skdm or not skds or not ivm or not ivs:
        print('[-] Missing encryption parameters.')
        return

    iv = ivm + ivs
    sk = None

    if m_rand and s_rand:
        if verbose:
            print('[+] Pair attempt detected! Deriving Just Works STK.')
        stk = aes_e(TK, s_rand[:8][::-1] + m_rand[:8][::-1])
        sk = aes_e(stk, skds[::-1] + skdm[::-1])
    else:
        if verbose:
            print('[+] Reconnection session. Testing previously exchanged LTKs.')
        candidates: list[tuple[str, bytes]] = []
        for name, k in global_ltks.items():
            candidates.extend([(name, k), (name + '_rev', k[::-1])])

        for name, ltk in candidates:
            test_sk = aes_e(ltk, skds[::-1] + skdm[::-1])
            works = False
            for i in range(enc_idx + 1, min(enc_idx + 10, len(lines))):
                try:
                    hdr, length = lines[i][4], lines[i][5]
                    if length == 0:
                        continue
                    payload = lines[i][6 : 6 + length]
                    aad = struct.pack('B', hdr & 0xE3)
                    for d in [0, 1]:
                        try:
                            AES.new(test_sk, AES.MODE_CCM, nonce=build_nonce(iv, 0, d), mac_len=4).update(
                                aad,
                            ).decrypt_and_verify(payload[:-4], payload[-4:])
                            works = True
                            sk = test_sk
                            break
                        except ValueError:
                            pass
                except IndexError:
                    pass
                if works:
                    break
            if works:
                if verbose:
                    print(f'[+] Decryption successful using LTK: {name}')
                break
        if not sk:
            print('[-] Could not decrypt with known LTKs!')
            return

    m_c, s_c = 0, 0
    if verbose:
        print('\n--- Human-Readable Decrypted Stream ---')

    for raw in lines[enc_idx + 1 :]:
        header, length = raw[4], raw[5]
        if length == 0:
            continue
        payload = raw[6 : 6 + length]
        aad = struct.pack('B', header & 0xE3)

        found = False
        for c in range(max(0, s_c - 5), s_c + 5):
            try:
                dec = (
                    AES.new(sk, AES.MODE_CCM, nonce=build_nonce(iv, c, 1), mac_len=4)
                    .update(aad)
                    .decrypt_and_verify(payload[:-4], payload[-4:])
                )
                s_c = c + 1
                found = True
                process_decrypted('Slave', 'Master', dec, verbose)
                break
            except ValueError:
                pass
        if found:
            continue

        for c in range(max(0, m_c - 5), m_c + 5):
            try:
                dec = (
                    AES.new(sk, AES.MODE_CCM, nonce=build_nonce(iv, c, 0), mac_len=4)
                    .update(aad)
                    .decrypt_and_verify(payload[:-4], payload[-4:])
                )
                m_c = c + 1
                found = True
                process_decrypted('Master', 'Slave', dec, verbose)
                break
            except ValueError:
                pass
        if not found:
            m_c += 1
            s_c += 1


def process_decrypted(src: str, dst: str, PDU: bytes, verbose: bool) -> None:
    global FLAG
    if len(PDU) < 4:
        return
    l2cap_len, cid = struct.unpack('<HH', PDU[:4])

    if cid == 0x0006:
        opcode = PDU[4]
        smp_opcodes = {0x01: 'PairReq', 0x02: 'PairRsp', 0x03: 'PairConfirm', 0x04: 'PairRandom', 0x06: 'LTK'}
        op_name = smp_opcodes.get(opcode, f'Op:{opcode:02X}')

        if opcode == 0x06:
            ltk = PDU[5:21]
            global_ltks[f'{src}_LTK'.strip()] = ltk
            print(f'[*] {src:6s} -> {dst:6s}: [SMP] Exchanged LTK = {ltk.hex()}')
        elif verbose:
            if opcode == 0x07:  # EDIV/RAND
                ediv = struct.unpack('<H', PDU[5:7])[0]
                rand = PDU[7:15][::-1].hex()
                print(f'[*] {src:6s} -> {dst:6s}: [SMP] Master ID (EDIV: {ediv}, Rand: {rand})')
            else:
                print(f'[*] {src:6s} -> {dst:6s}: [SMP] {op_name}')

    elif cid == 0x0004:
        opcode = PDU[4]
        att_opcodes = {
            0x01: 'Error Rsp',
            0x02: 'MTU Req',
            0x03: 'MTU Rsp',
            0x04: 'Find Info Req',
            0x05: 'Find Info Rsp',
            0x08: 'Read By Type Req',
            0x09: 'Read By Type Rsp',
            0x0A: 'Read Req',
            0x0B: 'Read Rsp',
            0x10: 'Read By Group Req',
            0x11: 'Read By Group Rsp',
            0x1B: 'Notify',
            0x52: 'Write Cmd',
        }
        op_name = att_opcodes.get(opcode, f'Op:{opcode:02X}')

        if opcode == 0x1B and len(PDU) >= 10:
            handle = struct.unpack('<H', PDU[5:7])[0]
            mod = PDU[7] if len(PDU) > 7 else 0
            keys = PDU[9 : min(15, len(PDU))]
            stroke = decode_hid(mod, keys)
            if stroke:
                print(f'[*] {src:6s} -> {dst:6s}: [ATT Keystroke] {stroke}')
                if '[ENTER]' in stroke:
                    FLAG += '\n'
                elif '[BKSP]' in stroke:
                    if len(FLAG) > 0:
                        FLAG = FLAG[:-1]
                else:
                    FLAG += stroke
                return
            if verbose:
                if all(k == 0 for k in PDU[7 : min(15, len(PDU))]):
                    print(
                        f'[*] {src:6s} -> {dst:6s}: [ATT] {op_name} (Handle 0x{handle:04X}) -> Key Release (all keys up)',
                    )
                else:
                    print(
                        f'[*] {src:6s} -> {dst:6s}: [ATT] {op_name} (Handle 0x{handle:04X}) -> Mod: 0x{mod:02X} Keys: {keys.hex()}',
                    )
                return

        text = extract_text(PDU)
        if text:
            print(f'[*] {src:6s} -> {dst:6s}: [ATT Text] "{text}"')
            return

        if verbose:
            if opcode in (0x04, 0x08, 0x10):  # Requests specifying handle ranges
                if len(PDU) >= 9:
                    st_h, en_h = struct.unpack('<HH', PDU[5:9])
                    print(f'[*] {src:6s} -> {dst:6s}: [ATT] {op_name} (Range 0x{st_h:04X}-0x{en_h:04X})')
                else:
                    print(f'[*] {src:6s} -> {dst:6s}: [ATT] {op_name}')
            elif opcode == 0x0A:  # Read Request
                handle = struct.unpack('<H', PDU[5:7])[0]
                print(f'[*] {src:6s} -> {dst:6s}: [ATT] {op_name} (Handle 0x{handle:04X})')
            elif opcode in (0x0B, 0x1B):  # Read Response / Notify
                if len(PDU) >= 7:
                    handle = struct.unpack('<H', PDU[5:7])[0] if opcode == 0x1B else 'N/A'
                    h_str = f'Handle 0x{handle:04X}, ' if handle != 'N/A' else ''
                    val = PDU[7 : 5 + l2cap_len - 1].hex() if opcode == 0x1B else PDU[5 : 5 + l2cap_len - 1].hex()
                    print(f'[*] {src:6s} -> {dst:6s}: [ATT] {op_name} ({h_str}Val: {val})')
                else:
                    print(f'[*] {src:6s} -> {dst:6s}: [ATT] {op_name}')
            else:
                print(f'[*] {src:6s} -> {dst:6s}: [ATT] {op_name} (Hex: {PDU[5:5+l2cap_len-1].hex()})')

    elif verbose:
        print(f'[*] {src:6s} -> {dst:6s}: [L2CAP] CID 0x{cid:04X} Data:{PDU[4:4+l2cap_len].hex()}')


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Decrypt removeBLE traffic')
    parser.add_argument('-v', '--verbose', action='store_true', help='Print fully detailed BLE packet tracing')
    args = parser.parse_args()

    process_session('session1.txt', args.verbose)
    process_session('session2.txt', args.verbose)
    print(FLAG)