Yamaha YAS-207's Bluetooth protocol: first real progress
Introduction
As explained in the Introductory post, I’m reversing the control interfaces of the Yamaha YAS-207 soundbar.
In this installment I’m finally having some success reversing the Serial-over-Bluetooth protocol, after encountering some misfortune earlier, and then having to work around missing btsnoop net in Android x86 in order to get Wireshark streaming.
Setup
I have “Home Theater Controller” Android app connected to the Yamaha YAS-207, and they are happily talking.
I can now see that they’re indeed talking via Serial-over-Bluetooth (SPP).
But I have no clear idea what they’re saying.
And furthermore, I still have the second problem from the “Basic recon” article: how do I parse this programmatically?
First try: Ruby the hell out of it
If you’ve read some of the articles in this series, and you see the “First try” heading… you might be thinking: “uh-oh”. Exactly.
I did spend a fair chunk of time coming up with 118 lines of dense Ruby to parse the SPP data; using mostly Wireshark as the guide as to what bitfields in the incoming packets mean what1.
The whole parse-btsnoop.rb is yours for the taking:
parse-btsnoop.rb (click to expand)
#!/usr/bin/env ruby
# Parse btsnoop_hci.log [BTSnoop version 1, HCI UART (H4)] file,
# typically coming from Android.
#
# Author: Michal Jirku
# Link: https://wejn.org/2021/04/yas-207-bluetooth-protocol-first-steps/
# License: GPL2? I don't know. If you're crazy enough to want to re-use this,
# let's talk?
#
# ============================================================================
# WARNING:
#
# This is not a production-ready code. By running it you risk that it will:
#
# - crash your brand spanking new Tesla
# - eat your cat
# - spend all your credit card balance on a new iPhone 17XXL
# - sell all your NFTs and give the proceeds to Gates foundation
#
# Use wireshark (tshark) instead. Seriously. See the link above.
# ============================================================================
f = File.open(ARGV.first)
# header is 8B string, 4B version, 4B type
raise "unsupported file format" unless f.read(8) == "btsnoop\0"
raise "only v1 supported" unless 1 == f.read(4).unpack('N').first
raise "only HCI UART (H4) supported" unless 1002 == f.read(4).unpack('N').first
STATUSES = {
0 => [:sent, :data],
1 => [:rcvd, :data],
2 => [:sent, :cmd],
3 => [:rcvd, :evt],
}
def parse_flags(pf)
STATUSES[pf] || [:invalid]
end
def next_record(f, ots)
# record is: original_length/4B, included_length/4B, packet_flags/4B, cumulative_drops/4B, timestamp/8B, data/?B
header = f.read(4+4+4+4+8)
return nil if header.nil?
raise "encountered packet too short" if header.size != 24
ol, il, pf, _, ts = header.unpack('NNNNQ>')
[ots||ts, [ts-(ots||ts), parse_flags(pf), f.read(il)]]
end
ts = nil
num = 1
active_rfcomm = {}
$active_pkt = nil
def flush_pkts
if $active_pkt
puts "#{$active_pkt[0]} #{$active_pkt[1].to_s.capitalize} #{$active_pkt[2][0..-2].scan(/./m).map { |x| "%02x" % x.ord }.join}"
$active_pkt = nil
end
nil
end
def add_pkt(num, dir, payload, ch)
if $active_pkt
if $active_pkt[3] == ch
$active_pkt[0] = num
$active_pkt[2] = $active_pkt[2] + payload
else
flush_pkts
end
else
$active_pkt = [num, dir, payload, ch]
end
nil
end
loop do
ts, r = next_record(f, ts)
break if ts.nil?
if r[2][0].ord == 2 # HCI ACL data
_hci_meta = r[2][1,2].unpack('S<').first
hci_bc = _hci_meta >> 14
hci_pb = (_hci_meta >> 12) & 0x3
hci_ch = _hci_meta & 0b111111111111
#p [num, "%04x" % hci_meta, "%016b" % hci_meta, hci_pb, hci_bc, "%04x" % hci_ch]
if (hci_pb & 1).zero? # first in line -> not a fragment
flush_pkts
hci_acl_len = r[2][3,2].unpack('S<').first
l2cap_len = r[2][5,2].unpack('S<').first
l2cap_cid = r[2][7,2].unpack('S<').first
if l2cap_cid == 1
sig_code = r[2][9].ord
case sig_code
when 2 # connection request
cr_ident = r[2][10].ord
cr_psm = r[2][13,2].unpack('S<').first
cr_cid = r[2][15,2].unpack('S<').first
if cr_psm == 3
p [num, :rfcomm_conn, "%04x" % cr_cid] if $VERBOSE
active_rfcomm[cr_cid] = true
end
when 3 # connection response
cr_ident = r[2][10].ord
cr_dst = r[2][13,2].unpack('S<').first
cr_src = r[2][15,2].unpack('S<').first
if active_rfcomm[cr_src]
active_rfcomm[cr_dst] = true
p [num, :rfcomm_conn_resp, "%04x" % cr_dst, "%04x" % cr_src] if $VERBOSE
end
when 6 # disconnection request
cr_ident = r[2][10].ord
cr_dst = r[2][13,2].unpack('S<').first
cr_src = r[2][15,2].unpack('S<').first
if active_rfcomm[cr_dst]
p [num, :rfcomm_disconn, "%04x" % cr_dst] if $VERBOSE
active_rfcomm.delete(cr_src)
active_rfcomm.delete(cr_dst)
end
end
end
cft = r[2][10].ord
if r[2].size > 10 && [0xff, 0xef].include?(cft) # UIH
chan = (r[2][9].ord >> 3)
if !chan.zero? && active_rfcomm[l2cap_cid] # not zero chan, and active channel
if r[2][11].ord > 1 # payload > 1 (because one byte crc)
dir = r[1].include?(:sent) ? :sent : :rcvd
pload = r[2][(cft == 0xff ? 13 : 12)..-1]
add_pkt(num, dir, pload, hci_ch)
end
end
end
else
add_pkt(num, dir, r[2][5..-1], hci_ch) if $active_pkt
end
end
# and now do something with the data... wireshark can help.
num += 1
end
flush_pkts
But you know you have a winner when you see code like this:
# ... snip ...
loop do
ts, r = next_record(f, ts)
break if ts.nil?
if r[2][0].ord == 2 # HCI ACL data
_hci_meta = r[2][1,2].unpack('S<').first
hci_bc = _hci_meta >> 14
hci_pb = (_hci_meta >> 12) & 0x3
hci_ch = _hci_meta & 0b111111111111
#p [num, "%04x" % hci_meta, "%016b" % hci_meta, hci_pb, hci_bc, "%04x" % hci_ch]
if (hci_pb & 1).zero? # first in line -> not a fragment
flush_pkts
hci_acl_len = r[2][3,2].unpack('S<').first
l2cap_len = r[2][5,2].unpack('S<').first
l2cap_cid = r[2][7,2].unpack('S<').first
if l2cap_cid == 1
sig_code = r[2][9].ord
case sig_code
when 2 # connection request
cr_ident = r[2][10].ord
cr_psm = r[2][13,2].unpack('S<').first
cr_cid = r[2][15,2].unpack('S<').first
if cr_psm == 3
p [num, :rfcomm_conn, "%04x" % cr_cid] if $VERBOSE
active_rfcomm[cr_cid] = true
end
# ... snip ...
So do yourself a favor. Don’t use the code. Read on.
I mean, it works. For me. For the particular btsnoop_hci.log
I used it on.
I just should have stopped writing the damn thing much earlier. Which brings
me to…
Second try: Don’t reinvent the wheel
After thinking that there has to be a better way, fumbling with the Wireshark plugins2… I discovered the salvation. The holy grail of parsing the Bluetooth HCI Snoop log3.
Here it is… right here:
#!/bin/bash
# this is the "parse-btsnoop.sh" mentioned later
tshark -Y btspp -O hci_h4,btspp -r ${1-./b.log} | \
awk -F'[: ]+' '$1~/Frame/ { printf "%s ", $2}; $2~/Direct/ {printf "%s ", $3};
$2~/Data/{printf "%s\n", $3}'
And you might be going: wait, what?
Yeah, allow me to play you the song of my people:
$ tshark -Y btspp -O hci_h4,btspp -r b.log | head -n 34 | tail -n 12
Frame 1761: 30 bytes on wire (240 bits), 30 bytes captured (240 bits)
Bluetooth
Bluetooth HCI H4
[Direction: Rcvd (0x01)]
HCI Packet Type: ACL Data (0x02)
Bluetooth HCI ACL Packet
Bluetooth L2CAP Protocol
Bluetooth RFCOMM Protocol
Bluetooth SPP Packet
Data: ccaa0d050001050009102020000003206c
Frame 1763: 21 bytes on wire (168 bits), 21 bytes captured (168 bits)
So the heavy lifting? tshark
4. The formatting? The fugly awk
three-clausuler I hacked together.
Either way: Done.
So in the end, you get:
$ bash parse-btsnoop.sh b.log | head -n 12 | tail -n 10
1761 Rcvd ccaa0d050001050009102020000003206c
1763 Sent ccaa03020001fa
1778 Rcvd ccaa03000200fb
1983 Sent ccaa020305f6
2003 Rcvd ccaa0d050001050009102020000003206c
2236 Sent ccaa020305f6
2263 Rcvd ccaa0d050001050009102020000003206c
2299 Sent ccaa0340781e27
2323 Sent ccaa020312e9
2341 Rcvd ccaa0312000ae1
And perhaps a mildly interesting aside:
$ diff -u <(ruby parse-btsnoop.rb b.log) <(bash parse-btsnoop.sh b.log)
$ # Hence me saying that it works. Yes, even the f-ing packet fragmentation.
Closing words
So now I have the data finally dumped in textual form. And I can immediately spot some patterns.
Let’s explore the protocol in the next part. For now – as usual – watch the yas 207 tag (if interested).