Module: PWN::SDR::Decoder::ZigBee
- Defined in:
- lib/pwn/sdr/decoder/zigbee.rb
Overview
IEEE 802.15.4 O-QPSK (2.4 GHz ZigBee/Thread) true-air decoder.
2 Mchip/s half-sine O-QPSK ≡ MSK: continuous Ruby FM/NRZ recovery produces differential frequency signs, not raw chips. Each symbol maps to a 32-chip PN sequence (Table 73, IEEE 802.15.4-2011); soft- correlate every 32 chips against the 16 sequences → symbols → nibbles → bytes. Hunt SHR (4×0x00 preamble + SFD 0xA7) → PHR len → MHR (FCF/seq/PAN/addr) → FCS (CRC-16-KERMIT). Emits per-frame src:, dst:, frame_type:, len:, fcs_ok:.
Defined Under Namespace
Constant Summary collapse
- CHIP_RATE =
2_000_000- PN32 =
16 × 32-chip PN sequences (symbol 0..15). Each row is one 32-bit word; chips are MSB-first (c0 = bit31).
[ 0xD9C3522E, 0xED9C3522, 0x2ED9C352, 0x22ED9C35, 0x522ED9C3, 0x3522ED9C, 0xC3522ED9, 0x9C3522ED, 0x8C96077B, 0xB8C96077, 0x7B8C9607, 0x77B8C960, 0x077B8C96, 0x6077B8C9, 0x96077B8C, 0xC96077B8 ].freeze
- PN_CHIPS =
PN32.map { |w| Array.new(32) { |i| (w >> (31 - i)) & 1 } }.freeze
- MSK_CHIPS =
Half-sine O-QPSK frequency signs are differential, not raw PN chips. c0 depends on the preceding symbol and is excluded from correlation.
PN_CHIPS.map do |chips| Array.new(32) { |i| (chips[i] ^ chips[(i - 1) % 32]) ^ (i.odd? ? 1 : 0) } end.freeze
- SFD =
0xA7
- FRAME_TYPE =
{ 0 => 'Beacon', 1 => 'Data', 2 => 'ACK', 3 => 'MAC-Cmd' }.freeze
Class Method Summary collapse
-
.authors ⇒ Object
- Author(s)
0day Inc.
-
.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).
-
.decrypt_ccm(opts = {}) ⇒ Object
AES-CCM authenticated decryption; never release unauthenticated plaintext.
-
.detect(opts = {}) ⇒ Object
Energy observations only; never substituted for protocol decoding.
- .help ⇒ Object
- .parse_aps(opts = {}) ⇒ Object
- .parse_line(opts = {}) ⇒ Object
-
.parse_mpdu(opts = {}) ⇒ Object
IEEE 802.15.4-2006 MAC frames (versions 0/1), with nested ZigBee NWK/APS.
- .parse_nwk(opts = {}) ⇒ Object
-
.parse_security(opts = {}) ⇒ Object
ZigBee auxiliary security, extended nonce, AES-CCM levels 5/6/7.
Class Method Details
.authors ⇒ Object
- Author(s)
0day Inc. [email protected]
421 422 423 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 421 public_class_method def self. "AUTHOR(S):\n 0day Inc. <[email protected]>\n" 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).
380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 380 public_class_method def self.decode(opts = {}) raise ArgumentError, 'only 2.4 GHz O-QPSK is supported' unless opts.fetch(:phy, :oqpsk).to_sym == :oqpsk freq_obj = opts[:freq_obj] hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq]) ch = ((hz - 2_405_000_000) / 5_000_000).round + 11 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: 'ZigBee', sample_rate: rate, source: opts[:source], file: opts[:file], demod: DemodIQ.new(rate: rate, channel: ch, network_keys: opts.fetch(:network_keys, {}), link_key: opts[:link_key], security_level: opts.fetch(:security_level, 5)), note: 'O-QPSK 2 Mcps ≡ MSK — I/Q→gmskdem→32-chip PN correlate→SHR/SFD→PHR/MHR/FCS.', describe: proc { |b| { modulation: 'O-QPSK', channel: ch, classification: b[:duration_ms] < 5 ? 'ACK' : 'MAC-frame' } } ) end |
.decrypt_ccm(opts = {}) ⇒ Object
AES-CCM authenticated decryption; never release unauthenticated plaintext.
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 240 public_class_method def self.decrypt_ccm(opts = {}) require 'openssl' raise ArgumentError, 'key must be 16 binary octets' unless opts[:key].is_a?(String) && opts[:key].bytesize == 16 cipher = OpenSSL::Cipher.new('aes-128-ccm') cipher.decrypt cipher.iv_len = opts[:nonce].bytesize cipher.auth_tag = opts[:mic] cipher.key = opts[:key] cipher.iv = opts[:nonce] cipher.ccm_data_len = opts[:ciphertext].bytesize cipher.auth_data = opts[:aad] cipher.update(opts[:ciphertext]) + cipher.final rescue OpenSSL::Cipher::CipherError nil end |
.detect(opts = {}) ⇒ Object
Energy observations only; never substituted for protocol decoding.
403 404 405 406 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 403 public_class_method def self.detect(opts = {}) Base.run_detector(opts.merge(protocol: 'ZigBee', note: 'Energy detector only; use .decode for supported protocol frames.')) end |
.help ⇒ Object
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 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 425 public_class_method def self.help puts "USAGE: # Supported: 2.4 GHz O-QPSK (phy: :oqpsk), MAC frame versions 0/1. # Correct differential MSK templates, FCS, PAN/address and payload parsing. # MAC security v1 auxiliary header/MIC are parsed; encrypted payload stays opaque. # NWK v2 addresses/routes, APS unicast/broadcast/group data, command and ACK headers. # NWK/APS AES-CCM levels 5/6/7 supported; level 0 uses security_level: 5 by default. # network_keys: { sequence_number => 16-byte binary key }; link_key: binary APS data key. # Authentication is nested per layer; a MAC FCS is NOT NWK/APS authentication. # No MAC CCM, key transport/load derivation, replay policy, APS fragmentation/reassembly, # ZCL/ZDP semantic payload decoding, Inter-PAN, MAC version 2 or sub-GHz PHY. # Parse ZigBee network headers and supported APS payloads. #{self}.parse_nwk(bytes: 'required - NWK octets', network_keys: {}, link_key: nil) # Parse APS addressing and authenticated payloads with caller-supplied keys. #{self}.parse_aps(bytes: 'required - APS octets', link_key: 'optional - 16-byte binary key') # Parse an auxiliary security header and verify supported CCM protection. #{self}.parse_security(bytes: 'required - frame octets', offset: 'required - auxiliary header position', security_source: 'optional - eight source address octets, wire order', link_key: 'optional - 16-byte binary APS data key') # Authenticate and decrypt an AES-CCM message without releasing failed plaintext. #{self}.decrypt_ccm(key: 'required - binary key', nonce: 'required - binary nonce', aad: 'required - binary header', ciphertext: 'required - binary ciphertext', mic: 'required - binary tag') # Normal spectral polarity and centered signals; no CFO acquisition. #{self}.parse_mpdu(bytes: 'required - Array of MPDU octets including FCS') # Detect signal energy without decoding protocol payloads. #{self}.detect(freq_obj: 'required', on_frame: 'optional callback') # 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', sample_rate: 'optional - sample rate value consumed by #decode', source: 'optional - source value consumed by #decode', link_key: 'optional - 16-byte binary APS data key', file: 'optional - filesystem path' ) # 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_aps(opts = {}) ⇒ Object
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 342 public_class_method def self.parse_aps(opts = {}) cursor = Octets.new(opts[:bytes]) control = cursor.number type = control & 3 delivery = (control >> 2) & 3 return nil if type == 3 || delivery == 1 || control.anybits?(0x80) result = { frame_type: type, delivery_mode: delivery, security_enabled: control.anybits?(0x20), authentication_verified: false, ack_requested: control.anybits?(0x40) } if type.zero? || (type == 2 && control.nobits?(0x10)) if type.zero? && delivery == 3 result[:group_address] = cursor.address(2) else result[:destination_endpoint] = cursor.number end result[:cluster_id] = cursor.address(2) result[:profile_id] = cursor.address(2) result[:source_endpoint] = cursor.number end result[:counter] = cursor.number if result[:security_enabled] security = parse_security(opts.merge(offset: cursor.offset)) return security ? result.merge(security) : nil end payload = cursor.rest result.merge(payload_hex: payload.pack('C*').unpack1('H*').upcase) rescue IndexError nil end |
.parse_line(opts = {}) ⇒ Object
408 409 410 411 412 413 414 415 416 417 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 408 public_class_method def self.parse_line(opts = {}) line = opts[:line].to_s out = { protocol: 'ZigBee' } out[:pan] = ::Regexp.last_match(1) if line =~ /PAN[:= ]+([0-9A-Fa-f]+)/i out[:src] = ::Regexp.last_match(1) if line =~ /src[:= ]+([0-9A-Fa-f:]+)/i out[:dst] = ::Regexp.last_match(1) if line =~ /dst[:= ]+([0-9A-Fa-f:]+)/i out[:cmd] = ::Regexp.last_match(1) if line =~ /\b(Beacon|Data|ACK|Cmd)\b/i out[:summary] = "ZigBee #{out[:cmd]} PAN=#{out[:pan]} #{out[:src]}→#{out[:dst]}" out.compact end |
.parse_mpdu(opts = {}) ⇒ Object
IEEE 802.15.4-2006 MAC frames (versions 0/1), with nested ZigBee NWK/APS. The FCS is transport integrity, never proof of CCM authentication.
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 128 public_class_method def self.parse_mpdu(opts = {}) bytes = opts[:bytes] || [] return nil unless (5..127).cover?(bytes.length) && bytes.all? { |b| b.is_a?(Integer) && (0..255).cover?(b) } return nil unless DSP.crc16(bytes: bytes[0...-2], init: 0, refin: true, refout: true) == (bytes[-2] | (bytes[-1] << 8)) fcf = bytes[0] | (bytes[1] << 8) version = (fcf >> 12) & 3 dst_mode = (fcf >> 10) & 3 src_mode = (fcf >> 14) & 3 return nil if version > 1 || dst_mode == 1 || src_mode == 1 || fcf.anybits?(0x0380) || (fcf & 7) > 3 compressed = fcf.anybits?(0x40) return nil if compressed && (dst_mode.zero? || src_mode.zero?) data = bytes[3...-2].dup take = lambda do |length| raise IndexError if data.length < length data.shift(length) end hex = ->(b) { b.pack('C*').unpack1('H*').upcase } pan = dst = src_pan = src = nil if dst_mode.positive? pan = hex.call(take.call(2).reverse) dst = hex.call(take.call(dst_mode == 3 ? 8 : 2).reverse) end if src_mode.positive? src_pan = compressed ? pan : hex.call(take.call(2).reverse) src = hex.call(take.call(src_mode == 3 ? 8 : 2).reverse) end security = fcf.anybits?(8) encrypted = false mic = [] security_fields = {} if security return nil unless version == 1 # legacy 2003 security suites differ control = take.call(1).first return nil unless control.nobits?(0xE0) level = control & 7 key_mode = (control >> 3) & 3 counter = take.call(4).pack('C*').unpack1('V') key_id = take.call([0, 1, 5, 9][key_mode]) mic_len = [0, 4, 8, 16, 0, 4, 8, 16][level] raise IndexError if data.length < mic_len mic = mic_len.zero? ? [] : data.pop(mic_len) encrypted = level >= 4 security_fields = { security_level: level, frame_counter: counter, key_id_mode: key_mode, key_id_hex: hex.call(key_id) } end frame = { protocol: 'ZigBee', layer: 'IEEE802.15.4-MAC', event: 'mpdu', decoded: true, capability: '802.15.4-2003/2006-mac', fcs_ok: true, checksum_verified: true, len: bytes.length, seq: bytes[2], fcf: format('%04X', fcf), frame_version: version, frame_type: FRAME_TYPE[fcf & 7], pan_id: pan, src_pan_id: src_pan, src: src, dst: dst, security_enabled: security, encrypted: encrypted, authentication_verified: false, payload_hex: encrypted ? nil : hex.call(data), ciphertext_hex: encrypted ? hex.call(data) : nil, mic_hex: hex.call(mic), raw_hex: hex.call(bytes) }.merge(security_fields) if !security && (fcf & 7) == 1 frame[:nwk] = parse_nwk(opts.merge(bytes: data)) if frame[:nwk] frame[:capability] = if frame[:nwk][:aps] 'zigbee-nwk-aps' elsif frame[:nwk][:payload_hex].nil? 'zigbee-nwk-secured' else 'zigbee-nwk-command' end else frame[:nwk_status] = 'invalid-or-unsupported' end end frame rescue IndexError nil end |
.parse_nwk(opts = {}) ⇒ Object
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 299 public_class_method def self.parse_nwk(opts = {}) cursor = Octets.new(opts[:bytes]) control = cursor.number(2) return nil unless ((control >> 2) & 15) == 2 && (control & 3) <= 1 && control.nobits?(0xE000) result = { frame_type: control & 3, protocol_version: 2, destination: cursor.address(2), source: cursor.address(2), radius: cursor.number, sequence: cursor.number, security_enabled: control.anybits?(0x200), authentication_verified: false } result[:extended_destination] = cursor.address(8) if control.anybits?(0x800) result[:extended_source] = cursor.address(8) if control.anybits?(0x1000) result[:multicast_control] = cursor.number if control.anybits?(0x100) if control.anybits?(0x400) count = cursor.number result[:relay_index] = cursor.number return nil if count.zero? || result[:relay_index] >= count result[:relays] = Array.new(count) { cursor.address(2) } end header_size = cursor.offset payload = cursor.rest if result[:security_enabled] security = parse_security(opts.merge(offset: header_size)) return nil unless security result.merge!(security) return result unless security[:authentication_verified] payload = [security[:payload_hex]].pack('H*').bytes end result[:payload_hex] = payload.pack('C*').unpack1('H*').upcase if result[:frame_type].zero? result[:aps] = parse_aps(opts.merge(bytes: payload)) return nil unless result[:aps] else return nil if payload.empty? result[:command_id] = payload.first end result rescue IndexError nil end |
.parse_security(opts = {}) ⇒ Object
ZigBee auxiliary security, extended nonce, AES-CCM levels 5/6/7. Level zero on the wire uses the configured network level (default 5).
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/pwn/sdr/decoder/zigbee.rb', line 259 public_class_method def self.parse_security(opts = {}) bytes = opts[:bytes] offset = opts[:offset] cursor = Octets.new(bytes[offset..]) control = cursor.number return nil if control.anybits?(0xC0) level = control & 7 level = opts.fetch(:security_level, 5) if level.zero? return nil unless [5, 6, 7].include?(level) effective_control = (control & 0xF8) | level counter_bytes = cursor.take(4) source = control.anybits?(0x20) ? cursor.take(8) : opts[:security_source] key_id = (control >> 3) & 3 sequence = key_id == 1 ? cursor.number : nil key = key_id == 1 ? opts.fetch(:network_keys, {})[sequence] : (opts[:link_key] if key_id.zero?) header_size = offset + cursor.offset body = cursor.rest mic_length = { 5 => 4, 6 => 8, 7 => 16 }.fetch(level) return nil if body.length < mic_length mic = body.pop(mic_length) result = { security_level: level, key_id: key_id, key_sequence: sequence, frame_counter: counter_bytes.pack('C*').unpack1('V'), authentication_verified: false, ciphertext_hex: body.pack('C*').unpack1('H*').upcase, mic_hex: mic.pack('C*').unpack1('H*').upcase, payload_hex: nil } return result unless key && source.is_a?(Array) && source.length == 8 aad = bytes[0, header_size].dup aad[offset] = effective_control plain = decrypt_ccm(key: key, nonce: (source + counter_bytes + [effective_control]).pack('C*'), aad: aad.pack('C*'), ciphertext: body.pack('C*'), mic: mic.pack('C*')) return nil unless plain result.merge(authentication_verified: true, payload_hex: plain.unpack1('H*').upcase) rescue IndexError nil end |