Module: PWN::SDR::Decoder::Bluetooth

Defined in:
lib/pwn/sdr/decoder/bluetooth.rb

Overview

Bluetooth LE 1M single-channel advertising/connected PDU decoder.

I/Q → continuous Ruby FM/NRZ fallback at 1 Mbit/s → hunt LSB-first Access Address (adv = 0x8E89BED6) → dewhiten (7-bit LFSR seeded ch|0x40) → PDU header (type/len) → AdvA (6 bytes) → CRC-24 (poly 0x65B, init 0x555555). Emits per-PDU pdu_type:, adv_addr:, crc_ok:. CRC failures are rejected. Connected packets require explicit AA, CRCInit and encryption state. CTE header is parsed; no direction finding, hopping, L2CAP assembly, 2M/coded PHY or decryption.

Defined Under Namespace

Classes: DemodIQ, ExtendedHeader, LinkLayer, SymbolStream

Constant Summary collapse

BLE_ADV_AA =
0x8E89BED6
BLE_CRC_POLY =
0x65B
BLE_CRC_INIT =
0x555555
BLE_PDU_TYPE =
{
  0 => 'ADV_IND', 1 => 'ADV_DIRECT_IND', 2 => 'ADV_NONCONN_IND',
  3 => 'SCAN_REQ', 4 => 'SCAN_RSP', 5 => 'CONNECT_IND',
  6 => 'ADV_SCAN_IND', 7 => 'ADV_EXT_IND'
}.freeze
BLE_ADV_CHANNELS =
{ 37 => 2_402_000_000, 38 => 2_426_000_000, 39 => 2_480_000_000 }.freeze
GIAC_LAP =

BR/EDR: 64-bit sync word derived from LAP; general-inquiry LAP=0x9E8B33.

0x9E8B33

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. [email protected]



394
395
396
# File 'lib/pwn/sdr/decoder/bluetooth.rb', line 394

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <[email protected]>\n"
end

.ble_crc24(opts = {}) ⇒ Object

Supported Method Parameters

crc = PWN::SDR::Decoder::Bluetooth.ble_crc24(bytes: Array) BLE CRC-24 (LSB-first LFSR, poly 0x65B, init 0x555555).



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/pwn/sdr/decoder/bluetooth.rb', line 322

public_class_method def self.ble_crc24(opts = {})
  bytes = opts[:bytes] || []
  # Reflect the specification's MSB-oriented CRCInit for a right-shift LFSR.
  reg = opts.fetch(:init, BLE_CRC_INIT).to_s(2).rjust(24, '0').reverse.to_i(2)
  bytes.each do |byte|
    8.times do |i|
      b = (byte >> i) & 1
      fb = (reg ^ b) & 1
      reg >>= 1
      reg ^= 0xDA6000 if fb == 1 # reflected x^24+x^10+x^9+x^6+x^4+x^3+x+1
      reg &= 0xFFFFFF
    end
  end
  # Register holds CRC LSB-first — return as-is (matched LSB-first on air)
  reg
end

.decode(opts = {}) ⇒ Object

Realtime options forwarded to Base: on_frame (Hash callback), output (writable IO), interactive (default true), duration (seconds), stop (callable), queue_size (bounded chunks), log_file (path or false).

Raises:

  • (ArgumentError)


347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/pwn/sdr/decoder/bluetooth.rb', line 347

public_class_method def self.decode(opts = {})
  freq_obj = opts[:freq_obj]
  hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
  raise ArgumentError, 'only BLE 1M PHY is supported' unless opts.fetch(:phy, :le1m).to_sym == :le1m

  ble = opts.fetch(:ble, freq_obj.fetch(:ble, true))
  # Nearest BLE advertising channel unless caller forces one.
  ch = opts[:channel] ||
       BLE_ADV_CHANNELS.min_by { |_, f| (f - hz).abs }&.first || 37
  rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 4_000_000).to_i
  PWN::SDR::Decoder::Base.run_iq(
    **opts,
    fallback: :raise,
    freq_obj: freq_obj,
    protocol: ble ? 'BLE' : 'BT-BR/EDR',
    sample_rate: rate,
    source: opts[:source],
    file: opts[:file],
    demod: DemodIQ.new(rate: rate, channel: ch, ble: ble,
                       access_address: opts.fetch(:access_address, BLE_ADV_AA),
                       crc_init: opts[:crc_init], encrypted: opts[:encrypted], extended: opts.fetch(:extended, false)),
    note: 'LE 1M single-channel GFSK: AA, dewhitening, PDU fields and CRC-24.',
    describe: proc { |b| { modulation: 'GFSK', channel: ch, hop_slots: (b[:duration_ms] / 0.625).round } }
  )
end

.detect(opts = {}) ⇒ Object

Energy observations only; never substituted for protocol decoding.



374
375
376
377
# File 'lib/pwn/sdr/decoder/bluetooth.rb', line 374

public_class_method def self.detect(opts = {})
  Base.run_detector(opts.merge(protocol: 'Bluetooth',
                               note: 'Energy detector only; use .decode for supported protocol frames.'))
end

.helpObject



398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/pwn/sdr/decoder/bluetooth.rb', line 398

public_class_method def self.help
  puts "USAGE:
    # Supported: BLE 1M legacy advertising; extended: true permits type 7
    # ADV_EXT_IND/AUX_ADV_IND on an explicitly selected channel 0..39.
    # Connected: access_address:, crc_init:, encrypted: true/false required.
    # Encrypted bytes remain ciphertext; no key-based decryption implemented.
    # CTEInfo and LL_CHANNEL_MAP_IND fields are parsed (no direction finding).
    # No BR/EDR, coded/2M PHY, hopping, AUX chain or L2CAP assembly.
    # Invalid lengths/CRC are rejected. Repeated valid packets are preserved.
    # phy: :le1m; ble: true. No silent energy fallback from .decode.
    #{self}.detect(freq_obj: 'required', on_frame: 'optional callback')

    # Run ble crc24 and return its result
    #{self}.ble_crc24(
      bytes: 'optional - bytes value consumed by #ble_crc24 (defaults to [])'
    )

    # Run decode and return its result
    #{self}.decode(
      freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
      on_frame: 'optional - callback receiving each emitted Hash',
      output: 'optional - writable IO (default stdout)',
      interactive: 'optional - false disables ENTER input',
      duration: 'optional - finite seconds to run',
      stop: 'optional - callable returning true to stop',
      queue_size: 'optional - bounded pending chunks (default 8)',
      log_file: 'optional - JSONL path or false to disable logging',
      channel: 'optional - channel value consumed by #decode',
      access_address: 'optional - Integer connection access address; default advertising AA',
      crc_init: 'optional - Integer 24-bit CRCInit; required for connected decoding',
      encrypted: 'optional - required for connected decoding: true yields ciphertext only; false yields plaintext',
      extended: 'optional - enable extended advertising PDUs, default false',
      sample_rate: 'optional - sample rate value consumed by #decode',
      source: 'optional - source value consumed by #decode',
      file: 'optional - filesystem path'
    )

    # Parse CRC-verified connected header and payload bytes.
    #{self}::LinkLayer.parse(
      header: 'required - Array of two or three header bytes',
      payload: 'required - Array of payload bytes',
      encrypted: 'required - Boolean indicating ciphertext rather than plaintext'
    )

    # Parse length-bounded extended advertising header bytes.
    #{self}::ExtendedHeader.parse(payload: 'required - Array of extended advertising payload bytes')

    # Run parse line and return its result
    #{self}.parse_line(
      line: 'optional - line value consumed by #parse_line'
    )

    # Print the AUTHOR(S) string for this module.
    #{self}.authors
  "
  constants.sort
end

.parse_line(opts = {}) ⇒ Object



379
380
381
382
383
384
385
386
387
388
389
390
# File 'lib/pwn/sdr/decoder/bluetooth.rb', line 379

public_class_method def self.parse_line(opts = {})
  line = opts[:line].to_s
  out  = { protocol: 'Bluetooth' }
  out[:lap]      = ::Regexp.last_match(1) if line =~ /LAP[=: ]([0-9a-fA-F]{6})/
  out[:uap]      = ::Regexp.last_match(1) if line =~ /UAP[=: ]([0-9a-fA-F]{2})/
  out[:bd_addr]  = ::Regexp.last_match(1) if line =~ /(?:AdvA|BD_ADDR)[=: ]([0-9a-fA-F:]{12,17})/
  out[:pdu_type] = ::Regexp.last_match(1) if line =~ /\b(ADV_\w+|SCAN_\w+|CONNECT_REQ)\b/
  out[:channel]  = ::Regexp.last_match(1) if line =~ /ch[=: ]?(\d{1,2})\b/i
  out[:rssi]     = ::Regexp.last_match(1) if line =~ /rssi[=: ]?(-?\d+)/i
  out[:summary]  = "BT #{out.values_at(:pdu_type, :bd_addr, :lap).compact.join(' ')}".strip
  out.compact
end