untether-bt 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- untether_bt/__init__.py +153 -0
- untether_bt/advertising.py +104 -0
- untether_bt/android.py +159 -0
- untether_bt/apk.py +149 -0
- untether_bt/bluez.py +34 -0
- untether_bt/btsnoop.py +214 -0
- untether_bt/capture.py +142 -0
- untether_bt/connection.py +156 -0
- untether_bt/framing.py +188 -0
- untether_bt/frida.py +102 -0
- untether_bt/frida_hooks/android_bt.js +68 -0
- untether_bt/gatt.py +80 -0
- untether_bt/hci.py +148 -0
- untether_bt/numbers.py +118 -0
- untether_bt/py.typed +0 -0
- untether_bt/sdp.py +140 -0
- untether_bt/spp.py +164 -0
- untether_bt/uiauto.py +88 -0
- untether_bt-0.7.0.dist-info/METADATA +194 -0
- untether_bt-0.7.0.dist-info/RECORD +22 -0
- untether_bt-0.7.0.dist-info/WHEEL +4 -0
- untether_bt-0.7.0.dist-info/licenses/LICENSE +21 -0
untether_bt/__init__.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""untether-bt — a Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering.
|
|
2
|
+
|
|
3
|
+
First-class Bluetooth **Classic (RFCOMM/SPP)** support — reachable from any host or from Home
|
|
4
|
+
Assistant via the companion ``untether_spp`` ESP32 bridge — plus the protocol primitives the
|
|
5
|
+
BLE-only ecosystem leaves to you. Includes: the framing/codec engine, the SPP bridge client, the
|
|
6
|
+
advertisement decoder, and the full reverse-engineering pipeline: the live ADB/UIAutomator driver
|
|
7
|
+
(drive the vendor app, mark each action) → btsnoop capture → HCI/ATT extraction → UI-action↔
|
|
8
|
+
wire-byte correlation. jadx/Frida wrappers and SDP/GATT-over-bleak follow.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .advertising import (
|
|
14
|
+
ADStructure,
|
|
15
|
+
flags,
|
|
16
|
+
local_name,
|
|
17
|
+
manufacturer_data,
|
|
18
|
+
parse_ad,
|
|
19
|
+
service_data,
|
|
20
|
+
service_uuids16,
|
|
21
|
+
)
|
|
22
|
+
from .android import AdbError, AdbRunner, AndroidDriver, extract_btsnoop_from_zip
|
|
23
|
+
from .apk import (
|
|
24
|
+
ApkAnalysis,
|
|
25
|
+
Finding,
|
|
26
|
+
analyze_apk,
|
|
27
|
+
analyze_tree,
|
|
28
|
+
decompile_apk,
|
|
29
|
+
pull_apk,
|
|
30
|
+
)
|
|
31
|
+
from .frida import FridaSession, hook_script_path, parse_hook_message
|
|
32
|
+
from .gatt import GattClient, normalize_uuid
|
|
33
|
+
from .numbers import (
|
|
34
|
+
company_name,
|
|
35
|
+
describe_uuid,
|
|
36
|
+
gatt_name,
|
|
37
|
+
sdp_service_name,
|
|
38
|
+
uuid16_to_128,
|
|
39
|
+
uuid128_to_16,
|
|
40
|
+
)
|
|
41
|
+
from .sdp import (
|
|
42
|
+
find_rfcomm_channels,
|
|
43
|
+
parse_data_element,
|
|
44
|
+
parse_records,
|
|
45
|
+
parse_ssa_response,
|
|
46
|
+
rfcomm_channel,
|
|
47
|
+
spp_channel,
|
|
48
|
+
)
|
|
49
|
+
from .uiauto import UiNode, find_node, parse_ui_dump
|
|
50
|
+
from .btsnoop import (
|
|
51
|
+
Btsnoop,
|
|
52
|
+
BtsnoopRecord,
|
|
53
|
+
decompress_btsnooz,
|
|
54
|
+
is_btsnooz,
|
|
55
|
+
load_btsnoop,
|
|
56
|
+
make_record,
|
|
57
|
+
parse_btsnoop,
|
|
58
|
+
write_btsnoop,
|
|
59
|
+
)
|
|
60
|
+
from .capture import Capture, Correlation, Mark, Recorder, WireEvent, correlate
|
|
61
|
+
from .framing import (
|
|
62
|
+
DIVOOM_NEWMODE,
|
|
63
|
+
DIVOOM_STUFFED,
|
|
64
|
+
Frame,
|
|
65
|
+
Framing,
|
|
66
|
+
Stuffing,
|
|
67
|
+
crc_sum16,
|
|
68
|
+
)
|
|
69
|
+
from .hci import AttPdu, HciPacket, L2capPayload, att_pdus, hci_packets, l2cap_payloads
|
|
70
|
+
from .spp import AsyncSppBridge, SppBridge
|
|
71
|
+
from .connection import SppConnection
|
|
72
|
+
|
|
73
|
+
__version__ = "0.7.0"
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"__version__",
|
|
77
|
+
# framing
|
|
78
|
+
"Framing",
|
|
79
|
+
"Frame",
|
|
80
|
+
"Stuffing",
|
|
81
|
+
"crc_sum16",
|
|
82
|
+
"DIVOOM_NEWMODE",
|
|
83
|
+
"DIVOOM_STUFFED",
|
|
84
|
+
# spp
|
|
85
|
+
"SppBridge",
|
|
86
|
+
"AsyncSppBridge",
|
|
87
|
+
"SppConnection",
|
|
88
|
+
# advertising
|
|
89
|
+
"ADStructure",
|
|
90
|
+
"parse_ad",
|
|
91
|
+
"manufacturer_data",
|
|
92
|
+
"service_data",
|
|
93
|
+
"service_uuids16",
|
|
94
|
+
"local_name",
|
|
95
|
+
"flags",
|
|
96
|
+
# capture / reverse-engineering
|
|
97
|
+
"Btsnoop",
|
|
98
|
+
"BtsnoopRecord",
|
|
99
|
+
"parse_btsnoop",
|
|
100
|
+
"write_btsnoop",
|
|
101
|
+
"make_record",
|
|
102
|
+
"decompress_btsnooz",
|
|
103
|
+
"is_btsnooz",
|
|
104
|
+
"load_btsnoop",
|
|
105
|
+
"HciPacket",
|
|
106
|
+
"L2capPayload",
|
|
107
|
+
"AttPdu",
|
|
108
|
+
"hci_packets",
|
|
109
|
+
"l2cap_payloads",
|
|
110
|
+
"att_pdus",
|
|
111
|
+
"Capture",
|
|
112
|
+
"WireEvent",
|
|
113
|
+
"Mark",
|
|
114
|
+
"Correlation",
|
|
115
|
+
"Recorder",
|
|
116
|
+
"correlate",
|
|
117
|
+
# android live driver (RE pipeline)
|
|
118
|
+
"AndroidDriver",
|
|
119
|
+
"AdbRunner",
|
|
120
|
+
"AdbError",
|
|
121
|
+
"extract_btsnoop_from_zip",
|
|
122
|
+
"UiNode",
|
|
123
|
+
"parse_ui_dump",
|
|
124
|
+
"find_node",
|
|
125
|
+
# static analysis (jadx)
|
|
126
|
+
"ApkAnalysis",
|
|
127
|
+
"Finding",
|
|
128
|
+
"analyze_tree",
|
|
129
|
+
"analyze_apk",
|
|
130
|
+
"decompile_apk",
|
|
131
|
+
"pull_apk",
|
|
132
|
+
# dynamic instrumentation (frida)
|
|
133
|
+
"FridaSession",
|
|
134
|
+
"parse_hook_message",
|
|
135
|
+
"hook_script_path",
|
|
136
|
+
# assigned numbers
|
|
137
|
+
"uuid16_to_128",
|
|
138
|
+
"uuid128_to_16",
|
|
139
|
+
"company_name",
|
|
140
|
+
"gatt_name",
|
|
141
|
+
"sdp_service_name",
|
|
142
|
+
"describe_uuid",
|
|
143
|
+
# sdp
|
|
144
|
+
"parse_data_element",
|
|
145
|
+
"parse_records",
|
|
146
|
+
"parse_ssa_response",
|
|
147
|
+
"rfcomm_channel",
|
|
148
|
+
"find_rfcomm_channels",
|
|
149
|
+
"spp_channel",
|
|
150
|
+
# gatt (over bleak)
|
|
151
|
+
"GattClient",
|
|
152
|
+
"normalize_uuid",
|
|
153
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""BLE advertisement parsing — the passive-broadcast layer.
|
|
2
|
+
|
|
3
|
+
Many BLE sensors never accept a connection; they broadcast their state in the advertisement's
|
|
4
|
+
manufacturer- or service-data. This module parses the AD structure list per the Core Specification
|
|
5
|
+
Supplement (each element is ``[length][AD type][data]``, where ``length`` counts the type byte but
|
|
6
|
+
not itself), and pulls out the fields you actually reverse-engineer against.
|
|
7
|
+
|
|
8
|
+
Note the endianness traps the spec calls out: the 16-bit Company Identifier (in manufacturer data)
|
|
9
|
+
and 16-bit Service-Data UUIDs are **little-endian**.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
# AD type codes (Assigned Numbers / CSS)
|
|
17
|
+
AD_FLAGS = 0x01
|
|
18
|
+
AD_UUID16_INCOMPLETE = 0x02
|
|
19
|
+
AD_UUID16_COMPLETE = 0x03
|
|
20
|
+
AD_NAME_SHORT = 0x08
|
|
21
|
+
AD_NAME_COMPLETE = 0x09
|
|
22
|
+
AD_TX_POWER = 0x0A
|
|
23
|
+
AD_SERVICE_DATA_16 = 0x16
|
|
24
|
+
AD_SERVICE_DATA_32 = 0x20
|
|
25
|
+
AD_SERVICE_DATA_128 = 0x21
|
|
26
|
+
AD_MANUFACTURER = 0xFF
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ADStructure:
|
|
31
|
+
type: int
|
|
32
|
+
data: bytes
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_ad(payload: bytes) -> list[ADStructure]:
|
|
36
|
+
"""Parse a raw advertisement (or scan-response) payload into AD structures.
|
|
37
|
+
|
|
38
|
+
Tolerant of the trailing zero-padding controllers add, and of truncation.
|
|
39
|
+
"""
|
|
40
|
+
out: list[ADStructure] = []
|
|
41
|
+
i, n = 0, len(payload)
|
|
42
|
+
while i < n:
|
|
43
|
+
length = payload[i]
|
|
44
|
+
if length == 0:
|
|
45
|
+
break # padding
|
|
46
|
+
if i + 1 + length > n:
|
|
47
|
+
break # truncated
|
|
48
|
+
out.append(ADStructure(payload[i + 1], payload[i + 2 : i + 1 + length]))
|
|
49
|
+
i += 1 + length
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _structs(ad: bytes | list[ADStructure]) -> list[ADStructure]:
|
|
54
|
+
return parse_ad(ad) if isinstance(ad, (bytes, bytearray)) else ad
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def manufacturer_data(ad: bytes | list[ADStructure]) -> tuple[int, bytes] | None:
|
|
58
|
+
"""Return ``(company_id, data)`` from the Manufacturer Specific Data (0xFF), or None.
|
|
59
|
+
|
|
60
|
+
``company_id`` is decoded little-endian per spec.
|
|
61
|
+
"""
|
|
62
|
+
for s in _structs(ad):
|
|
63
|
+
if s.type == AD_MANUFACTURER and len(s.data) >= 2:
|
|
64
|
+
return int.from_bytes(s.data[:2], "little"), s.data[2:]
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def service_data(ad: bytes | list[ADStructure]) -> dict[int, bytes]:
|
|
69
|
+
"""Map 16-bit Service-Data UUID -> its data bytes (UUID decoded little-endian)."""
|
|
70
|
+
out: dict[int, bytes] = {}
|
|
71
|
+
for s in _structs(ad):
|
|
72
|
+
if s.type == AD_SERVICE_DATA_16 and len(s.data) >= 2:
|
|
73
|
+
out[int.from_bytes(s.data[:2], "little")] = s.data[2:]
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def service_uuids16(ad: bytes | list[ADStructure]) -> list[int]:
|
|
78
|
+
"""All advertised 16-bit service UUIDs (complete + incomplete lists), little-endian."""
|
|
79
|
+
out: list[int] = []
|
|
80
|
+
for s in _structs(ad):
|
|
81
|
+
if s.type in (AD_UUID16_INCOMPLETE, AD_UUID16_COMPLETE):
|
|
82
|
+
for j in range(0, len(s.data) - 1, 2):
|
|
83
|
+
out.append(int.from_bytes(s.data[j : j + 2], "little"))
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def local_name(ad: bytes | list[ADStructure]) -> str | None:
|
|
88
|
+
"""The advertised local name (complete preferred over shortened)."""
|
|
89
|
+
short = None
|
|
90
|
+
for s in _structs(ad):
|
|
91
|
+
if s.type == AD_NAME_COMPLETE:
|
|
92
|
+
return s.data.decode("utf-8", "replace")
|
|
93
|
+
if s.type == AD_NAME_SHORT and short is None:
|
|
94
|
+
short = s.data.decode("utf-8", "replace")
|
|
95
|
+
return short
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def flags(ad: bytes | list[ADStructure]) -> int | None:
|
|
99
|
+
"""The Flags byte (0x01), if present. bit0 LE Limited Disc, bit1 LE General Disc,
|
|
100
|
+
bit2 BR/EDR Not Supported."""
|
|
101
|
+
for s in _structs(ad):
|
|
102
|
+
if s.type == AD_FLAGS and s.data:
|
|
103
|
+
return s.data[0]
|
|
104
|
+
return None
|
untether_bt/android.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Drive an Android device over ADB to reverse-engineer its Bluetooth app.
|
|
2
|
+
|
|
3
|
+
The live half of the RE loop: enable HCI snoop, drive the vendor app by accessibility label while
|
|
4
|
+
**marking** each UI action, then pull the capture and (with :func:`untether_bt.capture.correlate`)
|
|
5
|
+
see exactly which wire bytes each action produced.
|
|
6
|
+
|
|
7
|
+
The driver runs adb through an injectable runner, so the pure logic is fully unit-tested and the
|
|
8
|
+
real device path is a thin shell. Capture is pulled via ``adb bugreport`` (works unrooted) or a
|
|
9
|
+
direct path (rooted).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import glob
|
|
15
|
+
import os
|
|
16
|
+
import tempfile
|
|
17
|
+
import zipfile
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from .capture import Recorder
|
|
21
|
+
from .uiauto import UiNode, find_node, parse_ui_dump
|
|
22
|
+
|
|
23
|
+
_UI_REMOTE = "/sdcard/untether_ui.xml"
|
|
24
|
+
_DEFAULT_SNOOP_PATH = "/data/misc/bluetooth/logs/btsnoop_hci.log"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AdbError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Runner(Protocol):
|
|
32
|
+
def run(self, *args: str) -> str: ...
|
|
33
|
+
def run_bytes(self, *args: str) -> bytes: ...
|
|
34
|
+
def shell(self, *args: str) -> str: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AdbRunner:
|
|
38
|
+
"""Default runner: shells out to the ``adb`` binary (optionally targeting a serial)."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, serial: str | None = None, adb_path: str = "adb", timeout: float = 90.0):
|
|
41
|
+
self._base = [adb_path] + (["-s", serial] if serial else [])
|
|
42
|
+
self._timeout = timeout
|
|
43
|
+
|
|
44
|
+
def _exec(self, args: tuple[str, ...], *, text: bool):
|
|
45
|
+
import subprocess
|
|
46
|
+
|
|
47
|
+
p = subprocess.run( # noqa: S603
|
|
48
|
+
[*self._base, *args], capture_output=True, timeout=self._timeout
|
|
49
|
+
)
|
|
50
|
+
if p.returncode != 0:
|
|
51
|
+
raise AdbError(f"adb {' '.join(args)} failed: {p.stderr.decode('utf-8', 'replace')[:300]}")
|
|
52
|
+
return p.stdout.decode("utf-8", "replace") if text else p.stdout
|
|
53
|
+
|
|
54
|
+
def run(self, *args: str) -> str:
|
|
55
|
+
return self._exec(args, text=True)
|
|
56
|
+
|
|
57
|
+
def run_bytes(self, *args: str) -> bytes:
|
|
58
|
+
return self._exec(args, text=False)
|
|
59
|
+
|
|
60
|
+
def shell(self, *args: str) -> str:
|
|
61
|
+
return self.run("shell", *args)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def extract_btsnoop_from_zip(zip_path: str) -> bytes:
|
|
65
|
+
"""Pull the btsnoop_hci.log out of an ``adb bugreport`` zip."""
|
|
66
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
67
|
+
names = z.namelist()
|
|
68
|
+
cands = [n for n in names if "btsnoop" in n.lower()]
|
|
69
|
+
if not cands:
|
|
70
|
+
raise AdbError("no btsnoop log found in bugreport zip")
|
|
71
|
+
# prefer the real uncompressed btsnoop_hci.log, then largest
|
|
72
|
+
cands.sort(key=lambda n: (not n.endswith("btsnoop_hci.log"), -z.getinfo(n).file_size))
|
|
73
|
+
return z.read(cands[0])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AndroidDriver:
|
|
77
|
+
"""High-level ADB driver for app-driven Bluetooth reverse engineering."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, serial: str | None = None, runner: Runner | None = None):
|
|
80
|
+
self.adb: Runner = runner if runner is not None else AdbRunner(serial)
|
|
81
|
+
|
|
82
|
+
# ---- device ----
|
|
83
|
+
def devices(self) -> list[str]:
|
|
84
|
+
out = self.adb.run("devices")
|
|
85
|
+
return [
|
|
86
|
+
line.split("\t", 1)[0]
|
|
87
|
+
for line in out.splitlines()[1:]
|
|
88
|
+
if "\tdevice" in line
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def launch(self, package: str) -> None:
|
|
92
|
+
self.adb.shell("monkey", "-p", package, "-c", "android.intent.category.LAUNCHER", "1")
|
|
93
|
+
|
|
94
|
+
# ---- UI (accessibility-hierarchy driven) ----
|
|
95
|
+
def dump_ui(self) -> list[UiNode]:
|
|
96
|
+
self.adb.shell("uiautomator", "dump", _UI_REMOTE)
|
|
97
|
+
raw = self.adb.run_bytes("exec-out", "cat", _UI_REMOTE)
|
|
98
|
+
return parse_ui_dump(raw.decode("utf-8", "replace"))
|
|
99
|
+
|
|
100
|
+
def tap_xy(self, x: int, y: int) -> None:
|
|
101
|
+
self.adb.shell("input", "tap", str(x), str(y))
|
|
102
|
+
|
|
103
|
+
def tap(self, label: str, *, fields: tuple[str, ...] = ("text", "desc", "resource_id")) -> UiNode:
|
|
104
|
+
"""Find a node by accessibility label and tap its center. Raises if not found."""
|
|
105
|
+
node = find_node(self.dump_ui(), label, fields=fields)
|
|
106
|
+
if node is None or node.center is None:
|
|
107
|
+
raise AdbError(f"no tappable node matching {label!r}")
|
|
108
|
+
self.tap_xy(*node.center)
|
|
109
|
+
return node
|
|
110
|
+
|
|
111
|
+
def text(self, value: str) -> None:
|
|
112
|
+
self.adb.shell("input", "text", value.replace(" ", "%s"))
|
|
113
|
+
|
|
114
|
+
def key(self, code: int) -> None:
|
|
115
|
+
self.adb.shell("input", "keyevent", str(code))
|
|
116
|
+
|
|
117
|
+
def back(self) -> None:
|
|
118
|
+
self.key(4)
|
|
119
|
+
|
|
120
|
+
def home(self) -> None:
|
|
121
|
+
self.key(3)
|
|
122
|
+
|
|
123
|
+
def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> None:
|
|
124
|
+
self.adb.shell("input", "swipe", str(x1), str(y1), str(x2), str(y2), str(ms))
|
|
125
|
+
|
|
126
|
+
# ---- HCI snoop capture ----
|
|
127
|
+
def enable_hci_snoop(self, *, restart_bt: bool = True) -> None:
|
|
128
|
+
"""Turn on Bluetooth HCI snoop logging.
|
|
129
|
+
|
|
130
|
+
Note: on many builds the durable switch is the Developer Options toggle
|
|
131
|
+
("Enable Bluetooth HCI snoop log"); this sets the setting and (by default) restarts the
|
|
132
|
+
Bluetooth stack so it takes effect. If captures come back empty, flip the toggle by hand.
|
|
133
|
+
"""
|
|
134
|
+
self.adb.shell("settings", "put", "global", "bluetooth_hci_log", "1")
|
|
135
|
+
if restart_bt:
|
|
136
|
+
self.adb.shell("svc", "bluetooth", "disable")
|
|
137
|
+
self.adb.shell("svc", "bluetooth", "enable")
|
|
138
|
+
|
|
139
|
+
def pull_btsnoop(self) -> bytes:
|
|
140
|
+
"""Pull the current capture via ``adb bugreport`` (works unrooted). Returns btsnoop bytes."""
|
|
141
|
+
with tempfile.TemporaryDirectory() as d:
|
|
142
|
+
self.adb.run("bugreport", d)
|
|
143
|
+
zips = glob.glob(os.path.join(d, "*.zip"))
|
|
144
|
+
if not zips:
|
|
145
|
+
raise AdbError("adb bugreport produced no zip")
|
|
146
|
+
zips.sort(key=os.path.getmtime, reverse=True)
|
|
147
|
+
return extract_btsnoop_from_zip(zips[0])
|
|
148
|
+
|
|
149
|
+
def pull_btsnoop_path(self, path: str = _DEFAULT_SNOOP_PATH, *, su: bool = False) -> bytes:
|
|
150
|
+
"""Pull the capture directly from its path (rooted devices)."""
|
|
151
|
+
args = ("exec-out", "su", "0", "cat", path) if su else ("exec-out", "cat", path)
|
|
152
|
+
return self.adb.run_bytes(*args)
|
|
153
|
+
|
|
154
|
+
# ---- the harness helper ----
|
|
155
|
+
def tap_and_mark(self, label: str, recorder: Recorder, **kw: object) -> UiNode:
|
|
156
|
+
"""Tap a labelled node and timestamp the action into ``recorder`` for later correlation."""
|
|
157
|
+
node = self.tap(label, **kw) # type: ignore[arg-type]
|
|
158
|
+
recorder.mark(label)
|
|
159
|
+
return node
|
untether_bt/apk.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Static analysis of a vendor app — decompile with jadx, then map the Bluetooth surface.
|
|
2
|
+
|
|
3
|
+
This is Phase-0/1 of the methodology automated: get the APK (off a connected device or a local
|
|
4
|
+
file), decompile it, and answer the questions that steer everything downstream — **is this app BLE
|
|
5
|
+
GATT or Bluetooth Classic SPP?**, which **UUIDs**/characteristics does it touch, and **where** are
|
|
6
|
+
the command-building / write call sites.
|
|
7
|
+
|
|
8
|
+
``analyze_tree`` is pure (point it at any decompiled source tree); ``decompile_apk`` / ``pull_apk``
|
|
9
|
+
are thin wrappers around ``jadx`` and ``adb``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .android import AdbRunner, Runner
|
|
20
|
+
|
|
21
|
+
# --- signal patterns ---
|
|
22
|
+
_UUID128 = re.compile(
|
|
23
|
+
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
|
|
24
|
+
)
|
|
25
|
+
_SPP_UUID = re.compile(r"0000110[0-9a-fA-F]-0000-1000-8000-00805f9b34fb", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
# kind -> regex. Order matters only for readability; every line is checked against all.
|
|
28
|
+
_SIGNALS: dict[str, re.Pattern[str]] = {
|
|
29
|
+
"ble_write": re.compile(r"writeCharacteristic|writeDescriptor|\.setValue\("),
|
|
30
|
+
"ble_notify": re.compile(r"setCharacteristicNotification|onCharacteristicChanged"),
|
|
31
|
+
"ble_gatt": re.compile(r"BluetoothGatt|connectGatt|BluetoothLeScanner|BluetoothGattCharacteristic"),
|
|
32
|
+
"rfcomm": re.compile(r"createRfcommSocket|createInsecureRfcommSocket|BluetoothSocket"),
|
|
33
|
+
"byte_builder": re.compile(r"new byte\[\]\s*\{"),
|
|
34
|
+
}
|
|
35
|
+
_BLE_KINDS = {"ble_write", "ble_notify", "ble_gatt"}
|
|
36
|
+
_CLASSIC_KINDS = {"rfcomm"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Finding:
|
|
41
|
+
kind: str
|
|
42
|
+
file: str # path relative to the analyzed root
|
|
43
|
+
line: int
|
|
44
|
+
snippet: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ApkAnalysis:
|
|
49
|
+
transport: str = "unknown" # "ble" | "classic-spp" | "both" | "unknown"
|
|
50
|
+
gatt_uuids: list[str] = field(default_factory=list)
|
|
51
|
+
has_spp_uuid: bool = False
|
|
52
|
+
findings: list[Finding] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def by_kind(self) -> Counter[str]:
|
|
55
|
+
return Counter(f.kind for f in self.findings)
|
|
56
|
+
|
|
57
|
+
def summary(self) -> str:
|
|
58
|
+
counts = self.by_kind()
|
|
59
|
+
lines = [f"transport: {self.transport}"]
|
|
60
|
+
if self.has_spp_uuid:
|
|
61
|
+
lines.append("Serial Port (SPP) service UUID present (00001101)")
|
|
62
|
+
if self.gatt_uuids:
|
|
63
|
+
lines.append(f"{len(self.gatt_uuids)} distinct 128-bit UUID(s): " + ", ".join(self.gatt_uuids[:6]))
|
|
64
|
+
lines += [f" {k}: {counts[k]}" for k in sorted(counts)]
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def analyze_tree(root: str | Path, *, max_snippet: int = 160) -> ApkAnalysis:
|
|
69
|
+
"""Walk a decompiled source tree (.java) and map its Bluetooth surface."""
|
|
70
|
+
root = Path(root)
|
|
71
|
+
findings: list[Finding] = []
|
|
72
|
+
uuids: set[str] = set()
|
|
73
|
+
has_spp = False
|
|
74
|
+
kinds_seen: set[str] = set()
|
|
75
|
+
for path in root.rglob("*.java"):
|
|
76
|
+
try:
|
|
77
|
+
text = path.read_text("utf-8", "replace")
|
|
78
|
+
except OSError:
|
|
79
|
+
continue
|
|
80
|
+
rel = str(path.relative_to(root))
|
|
81
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
82
|
+
for u in _UUID128.findall(line):
|
|
83
|
+
uuids.add(u.lower())
|
|
84
|
+
if _SPP_UUID.search(line):
|
|
85
|
+
has_spp = True
|
|
86
|
+
for kind, pat in _SIGNALS.items():
|
|
87
|
+
if pat.search(line):
|
|
88
|
+
kinds_seen.add(kind)
|
|
89
|
+
findings.append(Finding(kind, rel, i, line.strip()[:max_snippet]))
|
|
90
|
+
|
|
91
|
+
ble = bool(kinds_seen & _BLE_KINDS)
|
|
92
|
+
classic = has_spp or bool(kinds_seen & _CLASSIC_KINDS)
|
|
93
|
+
transport = (
|
|
94
|
+
"both" if ble and classic
|
|
95
|
+
else "ble" if ble
|
|
96
|
+
else "classic-spp" if classic
|
|
97
|
+
else "unknown"
|
|
98
|
+
)
|
|
99
|
+
return ApkAnalysis(
|
|
100
|
+
transport=transport,
|
|
101
|
+
gatt_uuids=sorted(uuids),
|
|
102
|
+
has_spp_uuid=has_spp,
|
|
103
|
+
findings=findings,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def decompile_apk(apk_path: str | Path, out_dir: str | Path, *, jadx: str = "jadx") -> Path:
|
|
108
|
+
"""Run ``jadx -d <out_dir> <apk>`` and return the output directory."""
|
|
109
|
+
import subprocess
|
|
110
|
+
|
|
111
|
+
out = Path(out_dir)
|
|
112
|
+
p = subprocess.run( # noqa: S603
|
|
113
|
+
[jadx, "-d", str(out), str(apk_path)], capture_output=True, text=True
|
|
114
|
+
)
|
|
115
|
+
# jadx returns non-zero on partial-decompile warnings but still produces usable sources.
|
|
116
|
+
if not any(out.rglob("*.java")):
|
|
117
|
+
raise RuntimeError(f"jadx produced no sources: {p.stderr[:300]}")
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def pull_apk(package: str, dest: str | Path, *, runner: Runner | None = None) -> Path:
|
|
122
|
+
"""Pull an installed app's base APK off a connected device via adb."""
|
|
123
|
+
adb = runner if runner is not None else AdbRunner()
|
|
124
|
+
paths = adb.run("shell", "pm", "path", package)
|
|
125
|
+
base = None
|
|
126
|
+
for line in paths.splitlines():
|
|
127
|
+
line = line.strip()
|
|
128
|
+
if line.startswith("package:") and line.endswith("base.apk"):
|
|
129
|
+
base = line[len("package:") :]
|
|
130
|
+
break
|
|
131
|
+
if base is None: # no explicit base.apk (single non-split) — take the first
|
|
132
|
+
for line in paths.splitlines():
|
|
133
|
+
if line.startswith("package:"):
|
|
134
|
+
base = line.strip()[len("package:") :]
|
|
135
|
+
break
|
|
136
|
+
if base is None:
|
|
137
|
+
raise RuntimeError(f"package {package} not found on device")
|
|
138
|
+
dest = Path(dest)
|
|
139
|
+
adb.run("pull", base, str(dest))
|
|
140
|
+
return dest
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def analyze_apk(apk_path: str | Path, *, jadx: str = "jadx") -> ApkAnalysis:
|
|
144
|
+
"""Decompile an APK (to a temp dir) and analyze it."""
|
|
145
|
+
import tempfile
|
|
146
|
+
|
|
147
|
+
with tempfile.TemporaryDirectory() as d:
|
|
148
|
+
out = decompile_apk(apk_path, d, jadx=jadx)
|
|
149
|
+
return analyze_tree(out)
|
untether_bt/bluez.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Live SDP browsing on Linux via BlueZ (optional, needs pybluez).
|
|
2
|
+
|
|
3
|
+
The only first-class way to *issue* a Classic SDP query from Python is a Classic-capable host stack.
|
|
4
|
+
On Linux/BlueZ that's pybluez (``pip install untether-bt[bluez]``; Linux only). This is a thin
|
|
5
|
+
wrapper that returns the dynamic RFCOMM/SPP channel — so you don't hardcode it. (On other hosts,
|
|
6
|
+
recover the channel from a capture via ``Capture.sdp_records()`` + ``sdp.spp_channel``, or let the
|
|
7
|
+
``untether_spp`` ESP32 bridge SDP-discover it on-device with ``channel: 0``.)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
SPP_CLASS = "1101"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def browse_services(mac: str) -> list[dict]:
|
|
16
|
+
"""All advertised services for a device (raw pybluez records)."""
|
|
17
|
+
import bluetooth # pybluez — Linux only
|
|
18
|
+
|
|
19
|
+
return bluetooth.find_service(address=mac)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_spp(svc: dict) -> bool:
|
|
23
|
+
classes = svc.get("service-classes") or []
|
|
24
|
+
return any(SPP_CLASS in str(c).upper() for c in classes)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def spp_channel(mac: str) -> int | None:
|
|
28
|
+
"""The RFCOMM channel of the device's Serial Port service (falls back to any RFCOMM port)."""
|
|
29
|
+
services = browse_services(mac)
|
|
30
|
+
rfcomm = [s for s in services if s.get("protocol") == "RFCOMM" and s.get("port")]
|
|
31
|
+
for s in rfcomm:
|
|
32
|
+
if _is_spp(s):
|
|
33
|
+
return int(s["port"])
|
|
34
|
+
return int(rfcomm[0]["port"]) if rfcomm else None
|