harald 0.2.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.
- harald/__init__.py +1 -0
- harald/__main__.py +6 -0
- harald/analyze.py +200 -0
- harald/apk.py +490 -0
- harald/arm.py +191 -0
- harald/ble.py +194 -0
- harald/capture/__init__.py +1 -0
- harald/capture/att.py +144 -0
- harald/capture/files.py +220 -0
- harald/capture/l2cap.py +79 -0
- harald/capture/rfcomm.py +220 -0
- harald/capture/timeline.py +127 -0
- harald/classic.py +500 -0
- harald/classic_mac.py +569 -0
- harald/cli.py +661 -0
- harald/devices/README.md +16 -0
- harald/devices/wh-1000xm5/device.toml +8 -0
- harald/devices/wh-1000xm5/driver.py +517 -0
- harald/devices/wh-1000xm5/protocol.md +127 -0
- harald/errors.py +35 -0
- harald/library.py +145 -0
- harald/playbook/__init__.py +51 -0
- harald/playbook/claude-code/SKILL.md +272 -0
- harald/playbook/codex/AGENTS.md +267 -0
- harald/server.py +422 -0
- harald/templates/driver.py.tmpl +155 -0
- harald/templates/driver_classic.py.tmpl +384 -0
- harald/templates/driver_cli.py.tmpl +29 -0
- harald/templates/driver_payload.py.tmpl +106 -0
- harald/templates/protocol.md.tmpl +22 -0
- harald/templates/protocol_classic.md.tmpl +31 -0
- harald/templates.py +383 -0
- harald/workspace.py +49 -0
- harald-0.2.0.dist-info/METADATA +283 -0
- harald-0.2.0.dist-info/RECORD +38 -0
- harald-0.2.0.dist-info/WHEEL +4 -0
- harald-0.2.0.dist-info/entry_points.txt +2 -0
- harald-0.2.0.dist-info/licenses/LICENSE +21 -0
harald/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
harald/__main__.py
ADDED
harald/analyze.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Payload analysis: compare captured command payloads and test simple hypotheses.
|
|
2
|
+
|
|
3
|
+
Pure helpers over `bytes`. They show which bytes stay constant and which carry the
|
|
4
|
+
value, spot common checksums and length fields, judge whether payloads look
|
|
5
|
+
encrypted, and rank candidate app-wide aes keys by how well they decode them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
15
|
+
|
|
16
|
+
BLOCK = 16
|
|
17
|
+
AES_KEY_SIZES = (16, 24, 32)
|
|
18
|
+
MODES = ("aes-ecb", "aes-cbc-zero-iv") # ecb first so it wins ties (see try_keys)
|
|
19
|
+
ENTROPY_THRESHOLD = 3.5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DiffReport:
|
|
24
|
+
lengths: dict[int, int] # payload length -> how many payloads have it
|
|
25
|
+
constant: dict[int, int] # position -> the byte every payload has there
|
|
26
|
+
varying: list[int] # positions whose byte differs between payloads
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class KeyResult:
|
|
31
|
+
key_hex: str
|
|
32
|
+
mode: str
|
|
33
|
+
score: float
|
|
34
|
+
samples: list[str] # first 3 plaintexts as hex
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def diff_payloads(payloads: list[bytes]) -> DiffReport:
|
|
38
|
+
"""Split byte positions into constant and varying, over the shortest payload."""
|
|
39
|
+
lengths = dict(sorted(Counter(len(p) for p in payloads).items()))
|
|
40
|
+
shortest = min(lengths, default=0)
|
|
41
|
+
constant: dict[int, int] = {}
|
|
42
|
+
varying: list[int] = []
|
|
43
|
+
for i in range(shortest):
|
|
44
|
+
values = {p[i] for p in payloads}
|
|
45
|
+
if len(values) == 1:
|
|
46
|
+
constant[i] = values.pop()
|
|
47
|
+
else:
|
|
48
|
+
varying.append(i)
|
|
49
|
+
return DiffReport(lengths=lengths, constant=constant, varying=varying)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def guess_checks(payload: bytes) -> list[str]:
|
|
53
|
+
"""Name the common integrity and length fields this payload is consistent with.
|
|
54
|
+
|
|
55
|
+
A length byte at position i matches when it equals the total length n, the
|
|
56
|
+
bytes from it onward (n - i), or the bytes after it (n - i - 1). A framed
|
|
57
|
+
checksum is the byte before a trailing end marker, over the bytes after a
|
|
58
|
+
leading start marker.
|
|
59
|
+
"""
|
|
60
|
+
n = len(payload)
|
|
61
|
+
found: list[str] = []
|
|
62
|
+
if n >= 2:
|
|
63
|
+
body, last = payload[:-1], payload[-1]
|
|
64
|
+
if sum(body) & 0xFF == last:
|
|
65
|
+
found.append("sum8 at -1")
|
|
66
|
+
if _xor8(body) == last:
|
|
67
|
+
found.append("xor8 at -1")
|
|
68
|
+
for i in (0, 1):
|
|
69
|
+
if n > i and payload[i] in (n, n - i, n - i - 1):
|
|
70
|
+
found.append(f"length at {i}")
|
|
71
|
+
if n >= 3:
|
|
72
|
+
crc = crc16_ccitt(payload[:-2])
|
|
73
|
+
if payload[-2:] == crc.to_bytes(2, "little"):
|
|
74
|
+
found.append("crc16-ccitt at -2 (le)")
|
|
75
|
+
if payload[-2:] == crc.to_bytes(2, "big"):
|
|
76
|
+
found.append("crc16-ccitt at -2 (be)")
|
|
77
|
+
if n >= 4:
|
|
78
|
+
framed, check = payload[1:-2], payload[-2]
|
|
79
|
+
if sum(framed) & 0xFF == check:
|
|
80
|
+
found.append("sum8 at -2 (framed)")
|
|
81
|
+
if _xor8(framed) == check:
|
|
82
|
+
found.append("xor8 at -2 (framed)")
|
|
83
|
+
return found
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _xor8(data: bytes) -> int:
|
|
87
|
+
xor = 0
|
|
88
|
+
for b in data:
|
|
89
|
+
xor ^= b
|
|
90
|
+
return xor
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def crc16_ccitt(data: bytes) -> int:
|
|
94
|
+
"""CRC-16/CCITT-FALSE: poly 0x1021, init 0xffff, no reflection, no final xor."""
|
|
95
|
+
crc = 0xFFFF
|
|
96
|
+
for b in data:
|
|
97
|
+
crc ^= b << 8
|
|
98
|
+
for _ in range(8):
|
|
99
|
+
crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1)
|
|
100
|
+
crc &= 0xFFFF
|
|
101
|
+
return crc
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def byte_entropy(data: bytes) -> float:
|
|
105
|
+
"""Shannon entropy of the byte values in `data`, in bits per byte."""
|
|
106
|
+
if not data:
|
|
107
|
+
return 0.0
|
|
108
|
+
n = len(data)
|
|
109
|
+
return -sum(c / n * math.log2(c / n) for c in Counter(data).values())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def looks_encrypted(payloads: list[bytes]) -> bool:
|
|
113
|
+
"""Guess whether payloads are block-cipher output rather than plain commands.
|
|
114
|
+
|
|
115
|
+
Every payload must be a non-empty multiple of 16 bytes. They are judged per
|
|
116
|
+
16-byte block: a block that is byte-identical in every payload, such as the
|
|
117
|
+
padding block a whole-block plaintext gets under ecb, tells nothing and is left
|
|
118
|
+
out. Over the blocks that remain, True when no position is constant across the
|
|
119
|
+
payloads (checked once there are at least 3) and the mean per-payload byte
|
|
120
|
+
entropy is at least 3.5 bits. Fully identical payloads give False.
|
|
121
|
+
"""
|
|
122
|
+
if not payloads or any(not p or len(p) % BLOCK for p in payloads):
|
|
123
|
+
return False
|
|
124
|
+
varying = [v for v in _without_shared_blocks(payloads) if v]
|
|
125
|
+
if not varying: # every block is shared: the payloads are identical
|
|
126
|
+
return False
|
|
127
|
+
if len(varying) >= 3 and diff_payloads(varying).constant:
|
|
128
|
+
return False
|
|
129
|
+
mean = sum(byte_entropy(v) for v in varying) / len(varying)
|
|
130
|
+
return mean >= ENTROPY_THRESHOLD
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _without_shared_blocks(payloads: list[bytes]) -> list[bytes]:
|
|
134
|
+
"""Each payload minus the 16-byte blocks that every payload has byte-identical.
|
|
135
|
+
|
|
136
|
+
A single payload has nothing to compare against, so it comes back whole.
|
|
137
|
+
"""
|
|
138
|
+
if len(payloads) < 2:
|
|
139
|
+
return list(payloads)
|
|
140
|
+
longest = max(len(p) for p in payloads)
|
|
141
|
+
# a payload too short for block i gives b"" there, so that block is not shared
|
|
142
|
+
shared = {
|
|
143
|
+
i
|
|
144
|
+
for i in range(0, longest, BLOCK)
|
|
145
|
+
if len({p[i : i + BLOCK] for p in payloads}) == 1
|
|
146
|
+
}
|
|
147
|
+
return [
|
|
148
|
+
b"".join(p[i : i + BLOCK] for i in range(0, len(p), BLOCK) if i not in shared)
|
|
149
|
+
for p in payloads
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def try_keys(payloads: list[bytes], keys: list[bytes]) -> list[KeyResult]:
|
|
154
|
+
"""Decrypt payloads with each candidate key and mode, best-scoring first.
|
|
155
|
+
|
|
156
|
+
Keys that are not 16, 24 or 32 bytes and payloads that are not a non-empty
|
|
157
|
+
multiple of 16 bytes are skipped. With a zero iv, cbc's first plaintext
|
|
158
|
+
block equals ecb's for any payload length, so `score` (first block only)
|
|
159
|
+
always ties across the two modes. Ties are broken by the fraction of
|
|
160
|
+
positions constant over the whole shortest plaintext, which tells the modes
|
|
161
|
+
apart from the second block on. Single-block payloads still tie there too;
|
|
162
|
+
ecb is tried first and the sort is stable, so ecb ranks first.
|
|
163
|
+
"""
|
|
164
|
+
blocks = [p for p in payloads if p and len(p) % BLOCK == 0]
|
|
165
|
+
if not blocks:
|
|
166
|
+
return []
|
|
167
|
+
ranked: list[tuple[float, float, KeyResult]] = []
|
|
168
|
+
for key in keys:
|
|
169
|
+
if len(key) not in AES_KEY_SIZES:
|
|
170
|
+
continue
|
|
171
|
+
for mode in MODES:
|
|
172
|
+
plain = [_decrypt(key, mode, p) for p in blocks]
|
|
173
|
+
result = KeyResult(
|
|
174
|
+
key_hex=key.hex(),
|
|
175
|
+
mode=mode,
|
|
176
|
+
score=_score(plain),
|
|
177
|
+
samples=[p.hex() for p in plain[:3]],
|
|
178
|
+
)
|
|
179
|
+
ranked.append((result.score, _full_score(plain), result))
|
|
180
|
+
ranked.sort(key=lambda t: (t[0], t[1]), reverse=True)
|
|
181
|
+
return [result for _, _, result in ranked]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _decrypt(key: bytes, mode: str, data: bytes) -> bytes:
|
|
185
|
+
cipher_mode = modes.ECB() if mode == "aes-ecb" else modes.CBC(bytes(BLOCK))
|
|
186
|
+
decryptor = Cipher(algorithms.AES(key), cipher_mode).decryptor()
|
|
187
|
+
return decryptor.update(data) + decryptor.finalize()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _score(plain: list[bytes]) -> float:
|
|
191
|
+
"""Constant first-block positions, plus half the first-byte agreement, capped at 1."""
|
|
192
|
+
constant = sum(len({p[i] for p in plain}) == 1 for i in range(BLOCK)) / BLOCK
|
|
193
|
+
agree = Counter(p[0] for p in plain).most_common(1)[0][1] / len(plain)
|
|
194
|
+
return min(1.0, constant + 0.5 * agree)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _full_score(plain: list[bytes]) -> float:
|
|
198
|
+
"""Fraction of positions, over the shortest plaintext, constant across all."""
|
|
199
|
+
shortest = min(len(p) for p in plain)
|
|
200
|
+
return len(diff_payloads(plain).constant) / shortest
|
harald/apk.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""Static analysis of a vendor android app the user already has on disk.
|
|
2
|
+
|
|
3
|
+
harald never downloads apps: `analyze_apk` reads only the local file it is given.
|
|
4
|
+
The flow is: decide which framework the app is built with from the zip member
|
|
5
|
+
names (for a .xapk/.apks bundle, the names inside every member apk), and only
|
|
6
|
+
for a native app unpack the base apk, run `jadx` on it, and point the reader at
|
|
7
|
+
the lines worth reading. Only the jadx output dir is kept afterwards.
|
|
8
|
+
|
|
9
|
+
flutter and react-native apps keep their ble logic in a compiled blob (a dart or
|
|
10
|
+
javascript bundle), so jadx never sees it; for those the report just says so.
|
|
11
|
+
|
|
12
|
+
The scan references the android gatt api (`BluetoothGattCharacteristic`,
|
|
13
|
+
`writeCharacteristic`), the classic rfcomm socket calls
|
|
14
|
+
(`createRfcommSocketToServiceRecord`, `listenUsingRfcommWithServiceRecord`) and
|
|
15
|
+
`javax.crypto` (`SecretKeySpec`, `Cipher`) by name to find places worth reading. It does not reproduce how those apis are used; it only
|
|
16
|
+
reports file:line locations an agent should open.
|
|
17
|
+
|
|
18
|
+
A real app decompiles to tens of thousands of files, mostly third-party libraries.
|
|
19
|
+
The scan skips those library trees, reads the app's own package first, and keeps at
|
|
20
|
+
most `MAX_PER_KIND` gatt uuids and candidates of each kind, so the report fits in an
|
|
21
|
+
mcp tool result. `ApkReport.totals` has the full counts; when anything was cut, the
|
|
22
|
+
whole list goes to `<jadx_dir>/harald-candidates.txt` and a warning says so.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import binascii
|
|
29
|
+
import fnmatch
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import tempfile
|
|
34
|
+
import zipfile
|
|
35
|
+
from collections.abc import Iterable
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Literal, get_args
|
|
39
|
+
|
|
40
|
+
from harald.analyze import AES_KEY_SIZES
|
|
41
|
+
from harald.errors import HaraldError, ToolMissing
|
|
42
|
+
|
|
43
|
+
Framework = Literal["native", "flutter", "react-native"]
|
|
44
|
+
CandidateKind = Literal[
|
|
45
|
+
"service-uuid",
|
|
46
|
+
"char-uuid",
|
|
47
|
+
"aes-key",
|
|
48
|
+
"base64-blob",
|
|
49
|
+
"command-builder",
|
|
50
|
+
"rfcomm-uuid",
|
|
51
|
+
"rfcomm-socket",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
_BUNDLE_SUFFIXES = (".xapk", ".apks")
|
|
55
|
+
_RN_BUNDLE = "assets/index.android.bundle"
|
|
56
|
+
|
|
57
|
+
# the report keeps at most this many gatt uuids, and this many candidates per kind.
|
|
58
|
+
MAX_PER_KIND = 50
|
|
59
|
+
# written into the jadx output when the report was cut; it lists every hit.
|
|
60
|
+
CANDIDATES_FILE = "harald-candidates.txt"
|
|
61
|
+
|
|
62
|
+
# third-party library trees under jadx's sources/. their generic gatt, crypto and
|
|
63
|
+
# base64 code would bury the vendor's own, so they are not scanned, unless the app's
|
|
64
|
+
# own package lives inside one. `kotlin*` also covers kotlinx.
|
|
65
|
+
_LIBRARY_TREES = (
|
|
66
|
+
"android/",
|
|
67
|
+
"androidx/",
|
|
68
|
+
"kotlin*/",
|
|
69
|
+
"java/",
|
|
70
|
+
"javax/",
|
|
71
|
+
"dagger/",
|
|
72
|
+
"okhttp3/",
|
|
73
|
+
"okio/",
|
|
74
|
+
"retrofit2/",
|
|
75
|
+
"rx/",
|
|
76
|
+
"io/reactivex/",
|
|
77
|
+
"com/google/",
|
|
78
|
+
"com/facebook/",
|
|
79
|
+
"com/squareup/",
|
|
80
|
+
"com/airbnb/",
|
|
81
|
+
"com/bumptech/",
|
|
82
|
+
"com/fasterxml/",
|
|
83
|
+
"com/alibaba/",
|
|
84
|
+
"com/tencent/",
|
|
85
|
+
"com/umeng/",
|
|
86
|
+
"com/huawei/hms/",
|
|
87
|
+
"org/jetbrains/",
|
|
88
|
+
"org/intellij/",
|
|
89
|
+
"org/apache/",
|
|
90
|
+
"org/json/",
|
|
91
|
+
"org/slf4j/",
|
|
92
|
+
"org/greenrobot/",
|
|
93
|
+
"org/bouncycastle/",
|
|
94
|
+
"no/nordicsemi/",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# bluetooth sig base uuid; a 16-bit uuid 0xffXX expands to 0000ffXX-<base tail>.
|
|
98
|
+
_SIG_BASE = "0000{:04x}-0000-1000-8000-00805f9b34fb"
|
|
99
|
+
# the serial port profile uuid, the one most classic gadgets open an rfcomm socket on.
|
|
100
|
+
SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb"
|
|
101
|
+
|
|
102
|
+
_UUID128 = re.compile(
|
|
103
|
+
r"[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}"
|
|
104
|
+
)
|
|
105
|
+
# ruling r15: a standalone 0xffXX literal, the custom short uuids vendors use for
|
|
106
|
+
# private services; never the head of a longer hex number such as 0xff000000.
|
|
107
|
+
_UUID16 = re.compile(r"\b0x[fF]{2}[0-9a-fA-F]{2}(?![0-9a-fA-F])")
|
|
108
|
+
# a quoted base64 constant as long as an encoded aes key (hyphen-free, so it never
|
|
109
|
+
# matches a 128-bit uuid). _key_blob then checks that it decodes to a key size.
|
|
110
|
+
_BASE64 = re.compile(r'"([A-Za-z0-9+/]{22,44}={0,2})"')
|
|
111
|
+
# a path segment, as in androidx/core/app/ActivityCompat or Android/data/...: a
|
|
112
|
+
# lowercase word of two or more letters, then `/`. paths can decode as base64 but are
|
|
113
|
+
# never keys; a random base64 key holds one by chance under 1% of the time.
|
|
114
|
+
_PATH_SEGMENT = re.compile(r"(?:^|/)[a-z]{2,}/")
|
|
115
|
+
# code that builds a key or a cipher; an import or a type in a signature is not that.
|
|
116
|
+
_KEY_OR_CIPHER = re.compile(
|
|
117
|
+
r"\bnew\s+(?:[\w$]+\.)*SecretKeySpec\s*\(|\bCipher\s*\.\s*getInstance\s*\("
|
|
118
|
+
)
|
|
119
|
+
_BYTE_ARRAY = re.compile(r"new\s+byte\s*\[")
|
|
120
|
+
# the android calls that open a classic rfcomm socket, as a client or a server.
|
|
121
|
+
_RFCOMM_CALL = re.compile(
|
|
122
|
+
r"\bcreate(?:Insecure)?RfcommSocketToServiceRecord\("
|
|
123
|
+
r"|\blistenUsing(?:Insecure)?RfcommWithServiceRecord\("
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
_SNIPPET_MAX = 200
|
|
127
|
+
# how many earlier lines a wrapped statement may span when labelling a uuid.
|
|
128
|
+
_STATEMENT_LOOKBACK = 3
|
|
129
|
+
_HINT_TAIL_LINES = 3
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Candidate:
|
|
134
|
+
"""A place in the decompiled source worth reading. `location` is file:line."""
|
|
135
|
+
|
|
136
|
+
kind: CandidateKind
|
|
137
|
+
location: str
|
|
138
|
+
snippet: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class ApkReport:
|
|
143
|
+
"""`gatt_uuids` and `candidates` are capped at `MAX_PER_KIND` per kind.
|
|
144
|
+
|
|
145
|
+
`gatt_uuids` keeps its v0.1 name but holds every uuid found, rfcomm ones too;
|
|
146
|
+
the candidates label each one as a service, char or rfcomm uuid.
|
|
147
|
+
|
|
148
|
+
`totals` counts everything the scan found, before the cap: `gatt_uuids` and one
|
|
149
|
+
entry per candidate kind. It stays empty when no sources were scanned.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
path: Path
|
|
153
|
+
package: str | None
|
|
154
|
+
framework: Framework
|
|
155
|
+
gatt_uuids: list[str]
|
|
156
|
+
candidates: list[Candidate]
|
|
157
|
+
jadx_dir: Path | None
|
|
158
|
+
warnings: list[str]
|
|
159
|
+
totals: dict[str, int] = field(default_factory=dict)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def unpack(path: Path, dest: Path) -> Path:
|
|
163
|
+
"""Resolve an apk bundle to a single apk file path.
|
|
164
|
+
|
|
165
|
+
A plain `.apk` is already an apk, so its own path is returned. A `.xapk` or
|
|
166
|
+
`.apks` is a zip of apks: the base apk (the member named `base.apk`, else the
|
|
167
|
+
largest `*.apk`) is extracted into `dest` and that path is returned.
|
|
168
|
+
"""
|
|
169
|
+
path = Path(path)
|
|
170
|
+
if not _is_bundle(path):
|
|
171
|
+
return path
|
|
172
|
+
|
|
173
|
+
dest = Path(dest)
|
|
174
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
with zipfile.ZipFile(path) as bundle:
|
|
176
|
+
apk_members = _apk_members(bundle)
|
|
177
|
+
if not apk_members:
|
|
178
|
+
raise HaraldError("no apk inside this bundle")
|
|
179
|
+
member = _pick_base_apk(bundle, apk_members)
|
|
180
|
+
extracted = dest / Path(member).name
|
|
181
|
+
with bundle.open(member) as src, extracted.open("wb") as out:
|
|
182
|
+
shutil.copyfileobj(src, out)
|
|
183
|
+
return extracted
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_bundle(path: Path) -> bool:
|
|
187
|
+
return path.suffix.lower() in _BUNDLE_SUFFIXES
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _apk_members(bundle: zipfile.ZipFile) -> list[str]:
|
|
191
|
+
return [n for n in bundle.namelist() if n.lower().endswith(".apk")]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _pick_base_apk(bundle: zipfile.ZipFile, members: list[str]) -> str:
|
|
195
|
+
for name in members:
|
|
196
|
+
if Path(name).name.lower() == "base.apk":
|
|
197
|
+
return name
|
|
198
|
+
return max(members, key=lambda n: bundle.getinfo(n).file_size)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def detect_framework(apk_root: Path) -> str:
|
|
202
|
+
"""Classify an unzipped apk tree as flutter, react-native, or native."""
|
|
203
|
+
root = Path(apk_root)
|
|
204
|
+
names = (p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file())
|
|
205
|
+
return _framework_from_names(names)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _framework_from_names(names: Iterable[str]) -> str:
|
|
209
|
+
"""The same rule as `detect_framework`, applied to zip member names."""
|
|
210
|
+
names = set(names)
|
|
211
|
+
if any(n.startswith("lib/") and n.endswith("/libapp.so") for n in names):
|
|
212
|
+
return "flutter"
|
|
213
|
+
if _RN_BUNDLE in names:
|
|
214
|
+
return "react-native"
|
|
215
|
+
return "native"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _app_names(path: Path) -> set[str]:
|
|
219
|
+
"""Member names of an apk, or of every apk inside a bundle (ruling r16).
|
|
220
|
+
|
|
221
|
+
Split bundles keep `lib/<abi>/` in `config.<abi>.apk`, so the base apk alone
|
|
222
|
+
would make a flutter app look native. Nothing is extracted to disk.
|
|
223
|
+
"""
|
|
224
|
+
with zipfile.ZipFile(path) as outer:
|
|
225
|
+
if not _is_bundle(path):
|
|
226
|
+
return set(outer.namelist())
|
|
227
|
+
names: set[str] = set()
|
|
228
|
+
for member in _apk_members(outer):
|
|
229
|
+
with outer.open(member) as inner, zipfile.ZipFile(inner) as split:
|
|
230
|
+
names.update(split.namelist())
|
|
231
|
+
return names
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def run_jadx(apk: Path, out_dir: Path, *, runner=subprocess.run) -> Path:
|
|
235
|
+
"""Decompile `apk` into `out_dir` with jadx and return `out_dir`.
|
|
236
|
+
|
|
237
|
+
Raises `ToolMissing` when jadx is not installed and `HaraldError` when it
|
|
238
|
+
writes no java sources. The runner is injected so tests never shell out.
|
|
239
|
+
"""
|
|
240
|
+
out_dir = Path(out_dir)
|
|
241
|
+
_jadx(Path(apk), out_dir, runner)
|
|
242
|
+
return out_dir
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _jadx(apk: Path, out_dir: Path, runner) -> tuple[int, str]:
|
|
246
|
+
"""Run jadx; return its exit code and last output line."""
|
|
247
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
cmd = ["jadx", "-d", str(out_dir), str(apk)]
|
|
249
|
+
try:
|
|
250
|
+
result = runner(cmd, capture_output=True, text=True)
|
|
251
|
+
except FileNotFoundError as exc:
|
|
252
|
+
raise ToolMissing(
|
|
253
|
+
"jadx not found", hint="install jadx: brew install jadx"
|
|
254
|
+
) from exc
|
|
255
|
+
|
|
256
|
+
# jadx's logger may print errors on stdout, so fall back to it.
|
|
257
|
+
lines = _output_lines(result.stderr) or _output_lines(result.stdout)
|
|
258
|
+
if not any(out_dir.rglob("*.java")):
|
|
259
|
+
raise HaraldError(
|
|
260
|
+
f"jadx produced no java sources (exit code {result.returncode})",
|
|
261
|
+
hint="; ".join(lines[-_HINT_TAIL_LINES:])
|
|
262
|
+
or "jadx printed nothing; check that the file is a complete apk",
|
|
263
|
+
)
|
|
264
|
+
return result.returncode, lines[-1] if lines else ""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _output_lines(text: str | None) -> list[str]:
|
|
268
|
+
return [line.strip() for line in (text or "").splitlines() if line.strip()]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def analyze_apk(path: Path, *, jadx_runner=subprocess.run) -> ApkReport:
|
|
272
|
+
"""Detect a local apk's framework and, for a native app, scan its sources."""
|
|
273
|
+
path = Path(path)
|
|
274
|
+
if not path.is_file():
|
|
275
|
+
raise HaraldError("apk file not found", hint=f"no file at {path}")
|
|
276
|
+
|
|
277
|
+
framework = _framework_from_names(_app_names(path))
|
|
278
|
+
if framework != "native":
|
|
279
|
+
warning = (
|
|
280
|
+
f"logic not in jadx output: this app is {framework} "
|
|
281
|
+
f"and its ble code is compiled; use capture analysis"
|
|
282
|
+
)
|
|
283
|
+
return ApkReport(
|
|
284
|
+
path=path,
|
|
285
|
+
package=None,
|
|
286
|
+
framework=framework,
|
|
287
|
+
gatt_uuids=[],
|
|
288
|
+
candidates=[],
|
|
289
|
+
jadx_dir=None,
|
|
290
|
+
warnings=[warning],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
jadx_dir = Path(tempfile.mkdtemp(prefix="harald-jadx-"))
|
|
294
|
+
try:
|
|
295
|
+
with tempfile.TemporaryDirectory(prefix="harald-apk-") as unpacked:
|
|
296
|
+
apk = unpack(path, Path(unpacked))
|
|
297
|
+
code, last_line = _jadx(apk, jadx_dir, jadx_runner)
|
|
298
|
+
package = _read_package(jadx_dir)
|
|
299
|
+
all_uuids, all_candidates = _scan_sources(jadx_dir, package)
|
|
300
|
+
gatt_uuids, candidates = _capped(all_uuids, all_candidates)
|
|
301
|
+
cut = len(gatt_uuids) < len(all_uuids) or len(candidates) < len(all_candidates)
|
|
302
|
+
if cut:
|
|
303
|
+
listing = jadx_dir / CANDIDATES_FILE
|
|
304
|
+
_write_listing(listing, all_uuids, all_candidates)
|
|
305
|
+
except BaseException:
|
|
306
|
+
shutil.rmtree(jadx_dir, ignore_errors=True)
|
|
307
|
+
raise
|
|
308
|
+
|
|
309
|
+
warnings = []
|
|
310
|
+
if code != 0:
|
|
311
|
+
warning = f"jadx exited with code {code}; its output may be partial"
|
|
312
|
+
warnings.append(f"{warning}: {last_line}" if last_line else warning)
|
|
313
|
+
if cut:
|
|
314
|
+
warnings.append(
|
|
315
|
+
f"the report lists at most {MAX_PER_KIND} gatt uuids and "
|
|
316
|
+
f"{MAX_PER_KIND} candidates per kind, the app's own package first; "
|
|
317
|
+
f"totals has the full counts and {listing} lists them all"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return ApkReport(
|
|
321
|
+
path=path,
|
|
322
|
+
package=package,
|
|
323
|
+
framework=framework,
|
|
324
|
+
gatt_uuids=sorted(gatt_uuids),
|
|
325
|
+
candidates=candidates,
|
|
326
|
+
jadx_dir=jadx_dir,
|
|
327
|
+
warnings=warnings,
|
|
328
|
+
totals=_totals(all_uuids, all_candidates),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _capped(
|
|
333
|
+
uuids: list[str], candidates: list[Candidate]
|
|
334
|
+
) -> tuple[list[str], list[Candidate]]:
|
|
335
|
+
"""The first `MAX_PER_KIND` uuids, and the first `MAX_PER_KIND` of each kind."""
|
|
336
|
+
kept: list[Candidate] = []
|
|
337
|
+
seen: dict[str, int] = {}
|
|
338
|
+
for c in candidates:
|
|
339
|
+
seen[c.kind] = seen.get(c.kind, 0) + 1
|
|
340
|
+
if seen[c.kind] <= MAX_PER_KIND:
|
|
341
|
+
kept.append(c)
|
|
342
|
+
return uuids[:MAX_PER_KIND], kept
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _totals(uuids: list[str], candidates: list[Candidate]) -> dict[str, int]:
|
|
346
|
+
totals = {"gatt_uuids": len(uuids)}
|
|
347
|
+
for kind in get_args(CandidateKind):
|
|
348
|
+
totals[kind] = sum(c.kind == kind for c in candidates)
|
|
349
|
+
return totals
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _write_listing(path: Path, uuids: list[str], candidates: list[Candidate]) -> None:
|
|
353
|
+
"""Every gatt uuid and candidate the scan found, in the cli's layout."""
|
|
354
|
+
lines = [f"gatt uuids ({len(uuids)}):"]
|
|
355
|
+
lines += [f" {uuid}" for uuid in sorted(uuids)]
|
|
356
|
+
lines.append(f"candidates ({len(candidates)}, file:line under the jadx output):")
|
|
357
|
+
lines += [f" {c.kind} {c.location} {c.snippet}" for c in candidates]
|
|
358
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _source_files(jadx_dir: Path, package: str | None) -> list[tuple[Path, str]]:
|
|
362
|
+
"""(file, path under jadx_dir) for every java file worth scanning.
|
|
363
|
+
|
|
364
|
+
The app's own package comes first, then the rest by path. Files in third-party
|
|
365
|
+
library trees are left out, unless they are in the app's own package.
|
|
366
|
+
"""
|
|
367
|
+
own = package.replace(".", "/") + "/" if package else None
|
|
368
|
+
found: list[tuple[bool, str, Path]] = []
|
|
369
|
+
for java in jadx_dir.rglob("*.java"):
|
|
370
|
+
rel = java.relative_to(jadx_dir).as_posix()
|
|
371
|
+
source = rel.removeprefix("sources/") # the java package path
|
|
372
|
+
mine = own is not None and source.startswith(own)
|
|
373
|
+
if not mine and _is_library(source):
|
|
374
|
+
continue
|
|
375
|
+
found.append((not mine, rel, java))
|
|
376
|
+
return [(java, rel) for _, rel, java in sorted(found)]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _is_library(source: str) -> bool:
|
|
380
|
+
return any(fnmatch.fnmatchcase(source, tree + "*") for tree in _LIBRARY_TREES)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _scan_sources(
|
|
384
|
+
jadx_dir: Path, package: str | None
|
|
385
|
+
) -> tuple[list[str], list[Candidate]]:
|
|
386
|
+
"""Collect uuids and flag lines worth reading across decompiled sources.
|
|
387
|
+
|
|
388
|
+
Both lists are in scan order: the app's own package first. Nothing is capped.
|
|
389
|
+
"""
|
|
390
|
+
uuids: dict[str, None] = {} # an ordered set
|
|
391
|
+
candidates: list[Candidate] = []
|
|
392
|
+
|
|
393
|
+
for java, rel in _source_files(jadx_dir, package):
|
|
394
|
+
try:
|
|
395
|
+
text = java.read_text(encoding="utf-8", errors="replace")
|
|
396
|
+
except OSError:
|
|
397
|
+
continue
|
|
398
|
+
writes_chars = "writeCharacteristic" in text
|
|
399
|
+
lines = text.splitlines()
|
|
400
|
+
|
|
401
|
+
for idx, line in enumerate(lines):
|
|
402
|
+
location = f"{rel}:{idx + 1}"
|
|
403
|
+
snippet = line.strip()[:_SNIPPET_MAX]
|
|
404
|
+
|
|
405
|
+
uuid_kinds: list[str] = []
|
|
406
|
+
for start, uuid in _uuids_in(line):
|
|
407
|
+
uuids[uuid] = None
|
|
408
|
+
head = f"{_statement_head(lines, idx)} {line[:start]}"
|
|
409
|
+
kind = _uuid_kind(head, uuid)
|
|
410
|
+
if kind not in uuid_kinds:
|
|
411
|
+
uuid_kinds.append(kind)
|
|
412
|
+
candidates.extend(Candidate(k, location, snippet) for k in uuid_kinds)
|
|
413
|
+
|
|
414
|
+
if _RFCOMM_CALL.search(line):
|
|
415
|
+
candidates.append(Candidate("rfcomm-socket", location, snippet))
|
|
416
|
+
|
|
417
|
+
if _KEY_OR_CIPHER.search(line):
|
|
418
|
+
candidates.append(Candidate("aes-key", location, snippet))
|
|
419
|
+
|
|
420
|
+
if any(_key_blob(m.group(1)) for m in _BASE64.finditer(line)):
|
|
421
|
+
candidates.append(Candidate("base64-blob", location, snippet))
|
|
422
|
+
|
|
423
|
+
if writes_chars and (
|
|
424
|
+
_BYTE_ARRAY.search(line) or "writeCharacteristic" in line
|
|
425
|
+
):
|
|
426
|
+
candidates.append(Candidate("command-builder", location, snippet))
|
|
427
|
+
|
|
428
|
+
return list(uuids), candidates
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _key_blob(text: str) -> bool:
|
|
432
|
+
"""True for padded base64 that decodes to an aes key size and is not a path."""
|
|
433
|
+
if _PATH_SEGMENT.search(text):
|
|
434
|
+
return False
|
|
435
|
+
try:
|
|
436
|
+
raw = base64.b64decode(text, validate=True)
|
|
437
|
+
except binascii.Error:
|
|
438
|
+
return False
|
|
439
|
+
return len(raw) in AES_KEY_SIZES
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _uuids_in(line: str) -> list[tuple[int, str]]:
|
|
443
|
+
"""(column, uuid) for every uuid on a line, lowercased, 16-bit expanded."""
|
|
444
|
+
out = [(m.start(), m.group(0).lower()) for m in _UUID128.finditer(line)]
|
|
445
|
+
out += [
|
|
446
|
+
(m.start(), _SIG_BASE.format(int(m.group(0), 16)))
|
|
447
|
+
for m in _UUID16.finditer(line)
|
|
448
|
+
]
|
|
449
|
+
return out
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _statement_head(lines: list[str], idx: int) -> str:
|
|
453
|
+
"""Earlier lines of a statement wrapped onto `lines[idx]`, such as
|
|
454
|
+
`UUID SERVICE_UUID =` above the line that holds the uuid."""
|
|
455
|
+
head: list[str] = []
|
|
456
|
+
j = idx - 1
|
|
457
|
+
while j >= 0 and len(head) < _STATEMENT_LOOKBACK:
|
|
458
|
+
prev = lines[j].strip()
|
|
459
|
+
if not prev or prev.endswith((";", "{", "}")):
|
|
460
|
+
break
|
|
461
|
+
head.insert(0, prev)
|
|
462
|
+
j -= 1
|
|
463
|
+
return " ".join(head)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _uuid_kind(head: str, uuid: str) -> str:
|
|
467
|
+
"""Label a uuid by what comes before it in its statement.
|
|
468
|
+
|
|
469
|
+
It is an rfcomm uuid when its statement mentions rfcomm before it, or when it is
|
|
470
|
+
the spp uuid; otherwise the nearest of "service" or "char" decides.
|
|
471
|
+
"""
|
|
472
|
+
lowered = head.lower()
|
|
473
|
+
if "rfcomm" in lowered or uuid == SPP_UUID:
|
|
474
|
+
return "rfcomm-uuid"
|
|
475
|
+
if lowered.rfind("service") > lowered.rfind("char"):
|
|
476
|
+
return "service-uuid"
|
|
477
|
+
return "char-uuid"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _read_package(root: Path) -> str | None:
|
|
481
|
+
"""Best-effort package name from a decoded AndroidManifest.xml, if present."""
|
|
482
|
+
for manifest in root.rglob("AndroidManifest.xml"):
|
|
483
|
+
try:
|
|
484
|
+
text = manifest.read_text(encoding="utf-8", errors="replace")
|
|
485
|
+
except OSError:
|
|
486
|
+
continue
|
|
487
|
+
match = re.search(r'package="([^"]+)"', text)
|
|
488
|
+
if match:
|
|
489
|
+
return match.group(1)
|
|
490
|
+
return None
|