probolos 1.0.0b1__tar.gz
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.
- probolos-1.0.0b1/CAPABILITIES.md +459 -0
- probolos-1.0.0b1/CHANGELOG.md +1248 -0
- probolos-1.0.0b1/CITATION.cff +29 -0
- probolos-1.0.0b1/LICENSE +674 -0
- probolos-1.0.0b1/MANIFEST.in +14 -0
- probolos-1.0.0b1/PKG-INFO +416 -0
- probolos-1.0.0b1/README.md +385 -0
- probolos-1.0.0b1/ROADMAP.md +279 -0
- probolos-1.0.0b1/SECURITY.md +590 -0
- probolos-1.0.0b1/docs/GITHUB-SETUP.md +188 -0
- probolos-1.0.0b1/docs/QA-LOG.md +122 -0
- probolos-1.0.0b1/install.sh +222 -0
- probolos-1.0.0b1/interrogation_study.py +177 -0
- probolos-1.0.0b1/probolos/__init__.py +17 -0
- probolos-1.0.0b1/probolos/__main__.py +748 -0
- probolos-1.0.0b1/probolos/agent.py +737 -0
- probolos-1.0.0b1/probolos/agentlink.py +830 -0
- probolos-1.0.0b1/probolos/analyzers.py +327 -0
- probolos-1.0.0b1/probolos/atomicio.py +149 -0
- probolos-1.0.0b1/probolos/countdown_dialog.py +158 -0
- probolos-1.0.0b1/probolos/daemon.py +1770 -0
- probolos-1.0.0b1/probolos/deferred_bind.py +388 -0
- probolos-1.0.0b1/probolos/descriptors.py +315 -0
- probolos-1.0.0b1/probolos/descriptors_safe.py +358 -0
- probolos-1.0.0b1/probolos/dialogs.py +480 -0
- probolos-1.0.0b1/probolos/gate.py +163 -0
- probolos-1.0.0b1/probolos/gate_client.py +227 -0
- probolos-1.0.0b1/probolos/gate_server.py +1246 -0
- probolos-1.0.0b1/probolos/history.py +205 -0
- probolos-1.0.0b1/probolos/instance.py +73 -0
- probolos-1.0.0b1/probolos/interrogate.py +277 -0
- probolos-1.0.0b1/probolos/ledger.py +742 -0
- probolos-1.0.0b1/probolos/mediawatch.py +391 -0
- probolos-1.0.0b1/probolos/payload.py +209 -0
- probolos-1.0.0b1/probolos/privsep.py +384 -0
- probolos-1.0.0b1/probolos/protocol.py +185 -0
- probolos-1.0.0b1/probolos/quarantine.py +369 -0
- probolos-1.0.0b1/probolos/report.py +269 -0
- probolos-1.0.0b1/probolos/rules.py +1032 -0
- probolos-1.0.0b1/probolos/safety.py +375 -0
- probolos-1.0.0b1/probolos/securefs.py +106 -0
- probolos-1.0.0b1/probolos/session.py +252 -0
- probolos-1.0.0b1/probolos/storage.py +838 -0
- probolos-1.0.0b1/probolos/storage_hardening.py +150 -0
- probolos-1.0.0b1/probolos/sysfs.py +869 -0
- probolos-1.0.0b1/probolos/textsafe.py +334 -0
- probolos-1.0.0b1/probolos/trust.py +543 -0
- probolos-1.0.0b1/probolos/usbclass.py +245 -0
- probolos-1.0.0b1/probolos.egg-info/PKG-INFO +416 -0
- probolos-1.0.0b1/probolos.egg-info/SOURCES.txt +89 -0
- probolos-1.0.0b1/probolos.egg-info/dependency_links.txt +1 -0
- probolos-1.0.0b1/probolos.egg-info/entry_points.txt +2 -0
- probolos-1.0.0b1/probolos.egg-info/requires.txt +10 -0
- probolos-1.0.0b1/probolos.egg-info/top_level.txt +1 -0
- probolos-1.0.0b1/pyproject.toml +165 -0
- probolos-1.0.0b1/setup.cfg +4 -0
- probolos-1.0.0b1/systemd/README.md +129 -0
- probolos-1.0.0b1/systemd/probolos-agent.service +41 -0
- probolos-1.0.0b1/systemd/probolos.service +128 -0
- probolos-1.0.0b1/systemd/probolos.sysusers +12 -0
- probolos-1.0.0b1/testbed/README.md +119 -0
- probolos-1.0.0b1/testbed/__init__.py +0 -0
- probolos-1.0.0b1/testbed/emulate.py +189 -0
- probolos-1.0.0b1/testbed/hidexp/EXPERIMENT.md +147 -0
- probolos-1.0.0b1/testbed/hidexp/hid_attack.py +127 -0
- probolos-1.0.0b1/testbed/hidexp/hid_gadget_down.sh +38 -0
- probolos-1.0.0b1/testbed/hidexp/hid_gadget_up.sh +79 -0
- probolos-1.0.0b1/testbed/rawgadget.py +274 -0
- probolos-1.0.0b1/testbed/spawn.py +226 -0
- probolos-1.0.0b1/tests/__init__.py +13 -0
- probolos-1.0.0b1/tests/_media.py +83 -0
- probolos-1.0.0b1/tests/_support.py +332 -0
- probolos-1.0.0b1/tests/run_all.py +56 -0
- probolos-1.0.0b1/tests/test_agent.py +2381 -0
- probolos-1.0.0b1/tests/test_boundary.py +1032 -0
- probolos-1.0.0b1/tests/test_cli.py +596 -0
- probolos-1.0.0b1/tests/test_daemon.py +2315 -0
- probolos-1.0.0b1/tests/test_descriptors.py +532 -0
- probolos-1.0.0b1/tests/test_gate.py +513 -0
- probolos-1.0.0b1/tests/test_interrogate.py +146 -0
- probolos-1.0.0b1/tests/test_ledger.py +937 -0
- probolos-1.0.0b1/tests/test_privsep.py +1979 -0
- probolos-1.0.0b1/tests/test_properties.py +737 -0
- probolos-1.0.0b1/tests/test_quarantine.py +1028 -0
- probolos-1.0.0b1/tests/test_rules.py +1017 -0
- probolos-1.0.0b1/tests/test_service.py +172 -0
- probolos-1.0.0b1/tests/test_storage.py +1256 -0
- probolos-1.0.0b1/tests/test_sysfs.py +639 -0
- probolos-1.0.0b1/tests/test_testbed.py +74 -0
- probolos-1.0.0b1/tests/test_textsafe.py +289 -0
- probolos-1.0.0b1/tests/test_trust.py +731 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# Capabilities and scope
|
|
2
|
+
|
|
3
|
+
> **Feature freeze.** §1 below, as of the `v0.11.0` tag, is the feature
|
|
4
|
+
> contract for 1.0.0. Until 1.0.0 nothing new goes in: every change is a fix,
|
|
5
|
+
> a security fix, testing, documentation or release work (`ROADMAP.md`). A
|
|
6
|
+
> change that makes documented behaviour true is a fix; one that adds
|
|
7
|
+
> behaviour goes on the post-1.0 list (§3.0).
|
|
8
|
+
|
|
9
|
+
This document is the authoritative answer to "what does Probolos actually do?".
|
|
10
|
+
It exists because a security tool that is vague about its own boundary is worse
|
|
11
|
+
than one with a narrow but honest one: a user who overestimates the tool makes
|
|
12
|
+
decisions the tool cannot support.
|
|
13
|
+
|
|
14
|
+
Three lists follow: **what is implemented and active**, **what is deliberately
|
|
15
|
+
or currently not done**, and **what is under consideration**. Every entry in the
|
|
16
|
+
first list corresponds to code on an execution path that actually runs. Anything
|
|
17
|
+
that exists in the tree but is not reachable is in the second list, not the
|
|
18
|
+
first — that distinction is the point of this document.
|
|
19
|
+
|
|
20
|
+
Status: **beta**. Validated largely under software emulation
|
|
21
|
+
(`dummy_hcd`/`raw_gadget`); real-hardware coverage is limited.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 1. What Probolos does
|
|
26
|
+
|
|
27
|
+
### 1.1 Admission control
|
|
28
|
+
|
|
29
|
+
| Capability | Mechanism |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Deny-by-default for newly attached USB devices | `authorized_default=0` written per USB bus (`gate.py`) |
|
|
32
|
+
| Per-device hold and release | `/sys/bus/usb/devices/<dev>/authorized` |
|
|
33
|
+
| Final admission policy | terminal/agent decision, remembered trust, or safety exemption; inspection can activate earlier |
|
|
34
|
+
| Original bus state restored on exit, crash, or signal | `atexit` + signal handlers in `gate.py`; `--release` recovers manually |
|
|
35
|
+
| Devices already attached at start are left alone | the gate governs *new* attachments only |
|
|
36
|
+
|
|
37
|
+
The prompt is served on an already-trusted terminal or an already-authenticated
|
|
38
|
+
agent socket. A device that has just been plugged in is still unauthorized, so
|
|
39
|
+
it cannot press its own "yes". This is the core invariant.
|
|
40
|
+
|
|
41
|
+
The desktop agent asks with as much friction as the device earns
|
|
42
|
+
(`daemon._prompt_steps`): **one dialog** for a device that can neither type
|
|
43
|
+
nor carry traffic and showed nothing suspicious; **two** (a second "switch it
|
|
44
|
+
on?") for keyboards/HID, network, radio, vendor-specific or unknown functions,
|
|
45
|
+
or any warning; and for a CRITICAL finding the **countdown**: "Allow anyway"
|
|
46
|
+
stays disabled for 10 s, "always" is never offered, and the daemon itself
|
|
47
|
+
refuses an approval that arrives sooner. A dialog that closes without a
|
|
48
|
+
button (killed, timed out, no display) is no decision, never a refusal. On a
|
|
49
|
+
terminal a CRITICAL device needs the typed word `authorize`.
|
|
50
|
+
|
|
51
|
+
### 1.2 Stage 1 — Identity
|
|
52
|
+
|
|
53
|
+
Descriptor parsing from sysfs and the raw `descriptors` blob (`descriptors.py`,
|
|
54
|
+
`sysfs.py`): device / configuration / interface descriptors, string descriptors,
|
|
55
|
+
speed, and per-configuration power.
|
|
56
|
+
|
|
57
|
+
`bMaxPower` is scaled by the correct unit — 2 mA below USB 3.0, 8 mA at
|
|
58
|
+
SuperSpeed — selected from `bcdUSB`, and the bus limit is 500 mA / 900 mA
|
|
59
|
+
accordingly.
|
|
60
|
+
|
|
61
|
+
### 1.3 Stage 2 — Consistency rules
|
|
62
|
+
|
|
63
|
+
Static rules over the declared identity (`rules.evaluate`). Severities are
|
|
64
|
+
defaults and are overridable per rule in a YAML config (`--rules`).
|
|
65
|
+
|
|
66
|
+
**CRITICAL**
|
|
67
|
+
- `storage-with-keyboard` — a mass-storage device that also declares HID input
|
|
68
|
+
- `network-with-keyboard` — a network interface that also declares HID input
|
|
69
|
+
- `crafted-strings-hid` — text-manipulation characters in the strings of an input device
|
|
70
|
+
- `storage-with-undeclared-hid` — a mass-storage device that also declares an
|
|
71
|
+
input interface whose shape the descriptors do not show (HID subclass 0, or a
|
|
72
|
+
declared mouse whose report descriptor was not read)
|
|
73
|
+
- `unreadable-descriptors`, `descriptor-chain-truncated`, `configurations-missing`
|
|
74
|
+
— part of what the device declared was never examined (parse failure, a chain
|
|
75
|
+
cut short, or fewer configurations read than declared), so no rule above can
|
|
76
|
+
vouch for it
|
|
77
|
+
|
|
78
|
+
**WARNING**
|
|
79
|
+
- `keyboard-with-unrelated-function`
|
|
80
|
+
- `self-contradictory-identity`
|
|
81
|
+
- `interface-count-mismatch` — declared `bNumInterfaces` vs. interfaces present
|
|
82
|
+
- `crafted-strings`
|
|
83
|
+
- `network-with-undeclared-hid` — a network interface beside an input interface
|
|
84
|
+
of unclear shape (as `storage-with-undeclared-hid`)
|
|
85
|
+
- `stacked-combining-marks` — more combining marks on one character than any
|
|
86
|
+
writing system uses, which can draw over the report
|
|
87
|
+
- `power-exceeds-bus-limit` — declares more than the specification permits
|
|
88
|
+
|
|
89
|
+
**NOTICE**
|
|
90
|
+
- `multiple-distinct-functions`
|
|
91
|
+
- `no-interfaces`
|
|
92
|
+
- `keyboard-at-high-speed`
|
|
93
|
+
- `invisible-string-characters`
|
|
94
|
+
- `storage-declares-negligible-power`
|
|
95
|
+
- `power-varies-across-configurations`
|
|
96
|
+
- `descriptor-length-overstated` — `wTotalLength` claims more configuration data
|
|
97
|
+
than was delivered
|
|
98
|
+
|
|
99
|
+
A check that raises is reported, never skipped: `analyzer-failed:<check>` is
|
|
100
|
+
CRITICAL when that check is the one that decides its stage, and NOTICE
|
|
101
|
+
otherwise.
|
|
102
|
+
|
|
103
|
+
### 1.4 Stage 3 — Pre-authorization quarantine
|
|
104
|
+
|
|
105
|
+
The device is temporarily activated and evdev nodes are grabbed as they appear.
|
|
106
|
+
It is re-blocked before those grabs are released, including on exceptions.
|
|
107
|
+
Input can escape before a grab succeeds. Late nodes are checked throughout the
|
|
108
|
+
window, and failed grabs stop it. Event buffers are bounded; timing comes from
|
|
109
|
+
kernel input-event timestamps rather than Python's batch-read times.
|
|
110
|
+
|
|
111
|
+
Behavioural rules (`rules.behaviour_findings`):
|
|
112
|
+
|
|
113
|
+
- `machine-generated-keystrokes` (CRITICAL) — inter-keystroke coefficient of variation below human range
|
|
114
|
+
- `unprompted-typing` (CRITICAL) — input while the user was told not to touch it
|
|
115
|
+
- `unexpected-keystrokes` (WARNING)
|
|
116
|
+
- `immediate-activity` (WARNING)
|
|
117
|
+
- `incomplete-isolation` (WARNING) — a node could not be grabbed
|
|
118
|
+
- `quarantine-unavailable` (NOTICE)
|
|
119
|
+
- `quarantine-not-restored` (CRITICAL) — `authorized=0` could not be written
|
|
120
|
+
back after observation, so the device may still be live
|
|
121
|
+
|
|
122
|
+
Payload rule (`analyzers.PayloadAnalyzer`, only with `--capture-payload`):
|
|
123
|
+
|
|
124
|
+
- `payload-captured` (CRITICAL) — the device typed at least one key during
|
|
125
|
+
quarantine; its keystrokes are rebuilt as a DuckyScript-style transcript,
|
|
126
|
+
assuming a US QWERTY layout and capped at about 4096 characters (keystrokes
|
|
127
|
+
past the cap are counted, not transcribed)
|
|
128
|
+
|
|
129
|
+
Authorization-to-first-grab latency is reported. It is not a measurement of
|
|
130
|
+
all escaped input, and `--close-race-window` does not eliminate the race.
|
|
131
|
+
Optional raw keystroke capture (`--capture-payload`, off by default) feeds a
|
|
132
|
+
payload analyzer.
|
|
133
|
+
|
|
134
|
+
### 1.5 Stage 4 — Storage inspection without mounting
|
|
135
|
+
|
|
136
|
+
Read-only inspection of the first sectors of a USB block device (`storage.py`):
|
|
137
|
+
MBR entries and GPT header/protective-MBR detection, with limited filesystem
|
|
138
|
+
signatures. ISO 9660 and UDF are reported only when their volume structure
|
|
139
|
+
checks out (ISO 9660: descriptor set, PVD both-byte-order fields, root
|
|
140
|
+
directory record, size; UDF: the recognition sequence), not on the magic
|
|
141
|
+
alone; the other signatures are still magic-only. GPT entries are read only
|
|
142
|
+
for their type GUID and attributes, only from the standard location inside
|
|
143
|
+
the header read (LBA 2, 128-byte entries), and only the media-change rules
|
|
144
|
+
(§1.13) use them; the geometry rules still judge the protective MBR. Probolos does not mount or write the
|
|
145
|
+
medium; other services can still mount it during activation.
|
|
146
|
+
|
|
147
|
+
Recognised filesystems are a fixed internal set (NTFS, exFAT, FAT12/16/32,
|
|
148
|
+
ext2/3/4, btrfs, ISO 9660, UDF); detection does not delegate to libblkid.
|
|
149
|
+
f2fs, minix and squashfs, among others, read as "no known filesystem" — the
|
|
150
|
+
same string a medium with no structure at all produces. The stage 4 result is
|
|
151
|
+
**contextual, not a gate criterion**: a device can also force the
|
|
152
|
+
identity-only path by delaying its block node past the poll window. Admission
|
|
153
|
+
rests on stages 1–2.
|
|
154
|
+
|
|
155
|
+
Every inspection failure (switch-on refused, no block node, node never ready,
|
|
156
|
+
unreadable or stalled read, removal mid-scan, re-block refused) is reported the
|
|
157
|
+
same way: `not inspected: <reason>` in the MEDIUM block plus the
|
|
158
|
+
`storage-unreadable` notice. The reason shown is from a fixed vocabulary; the
|
|
159
|
+
raw error text and paths go to the JSON audit log (`medium.detail`) only.
|
|
160
|
+
|
|
161
|
+
- `partition-beyond-end-of-device` (WARNING)
|
|
162
|
+
- `overlapping-partitions` (WARNING)
|
|
163
|
+
- `impossible-partition-geometry` (WARNING) — entries that cannot exist on this
|
|
164
|
+
medium (`storage_hardening`); they are not read
|
|
165
|
+
- `filesystem-type-mismatch` (NOTICE)
|
|
166
|
+
- `filesystem-signature-without-structure` (NOTICE)
|
|
167
|
+
- `large-unallocated-gap` (NOTICE)
|
|
168
|
+
- `gpt-without-protective-mbr` (NOTICE)
|
|
169
|
+
|
|
170
|
+
The read/parse phase runs in a forked worker with a timeout. Authorization,
|
|
171
|
+
gate-provided descriptor acquisition and cleanup are outside that deadline. Storage inspection is **refused** on a
|
|
172
|
+
composite storage+input device, because authorizing it to look would switch the
|
|
173
|
+
input half on without a grab, and on any other composite (storage+network,
|
|
174
|
+
serial or vendor), because it would switch those functions on before a
|
|
175
|
+
decision. This guard includes every parsed configuration and alternate setting. Incomplete descriptors disable early activation entirely.
|
|
176
|
+
|
|
177
|
+
### 1.6 Memory across sessions
|
|
178
|
+
|
|
179
|
+
- **Ledger** (`ledger.py`) — bounded per-identity history, with a
|
|
180
|
+
**normalized** SHA-256 fingerprint of the parsed descriptor set: VID/PID,
|
|
181
|
+
`bcdDevice`, top-level class triple, and per-interface
|
|
182
|
+
class/subclass/protocol. Bus-negotiated fields (`bcdUSB`, `bMaxPower`,
|
|
183
|
+
endpoint descriptors, SuperSpeed companion descriptors) are excluded, so
|
|
184
|
+
the same physical stick on a USB 2 vs. a USB 3 controller does not produce
|
|
185
|
+
a spurious drift alarm. The raw-byte hash is stored beside it (`raw_hash`)
|
|
186
|
+
for forensics but never fed to the CRITICAL rule.
|
|
187
|
+
- **Trust store** (`trust.py`) — devices pinned by identity *and* descriptor
|
|
188
|
+
hash. Trust never overrides a CRITICAL finding. Both files are written
|
|
189
|
+
atomically. The ledger is `0600`. The trust store is `0600` unless
|
|
190
|
+
`--privsep` is used: there the launcher makes an existing store `0644` and
|
|
191
|
+
the gate creates a new one `0644`, so the analyzer can read it back, and any
|
|
192
|
+
later rewrite keeps the read bits the file already has. An unreadable trust
|
|
193
|
+
store fails **closed** (nothing trusted, everything asked).
|
|
194
|
+
|
|
195
|
+
History rules (`analyzers.LedgerAnalyzer`):
|
|
196
|
+
|
|
197
|
+
- `descriptor-drift` (CRITICAL) — the normalized descriptor set differs from
|
|
198
|
+
this identity's baseline: the first readable set recorded for it, whatever
|
|
199
|
+
that sighting's decision, or the set most recently approved
|
|
200
|
+
- `previously-rejected` (CRITICAL) — this identity was refused before; the
|
|
201
|
+
prompt is the countdown
|
|
202
|
+
- `ledger-unavailable` (NOTICE) — the history could not be read, so the device
|
|
203
|
+
is judged without it
|
|
204
|
+
|
|
205
|
+
### 1.7 Session awareness
|
|
206
|
+
|
|
207
|
+
Screen-lock handling (`session.py`): devices arriving while the session is
|
|
208
|
+
locked are queued or denied per `--lock-policy`, and a queued device is re-asked
|
|
209
|
+
on unlock. Deferral never becomes silent approval.
|
|
210
|
+
|
|
211
|
+
With no one to ask at all -- the service has no terminal, and no desktop agent
|
|
212
|
+
is connected (login screen, logout, agent restart) -- a device is **held**
|
|
213
|
+
blocked before stages 3 and 4, and asked about when an agent connects. A
|
|
214
|
+
replayed udev "add" for a held device is ignored. A device whose question was
|
|
215
|
+
lost to a departing agent three times is refused as unanswered. A question
|
|
216
|
+
that was shown and not answered is recorded as "no answer", not re-queued,
|
|
217
|
+
and the agent shows a display-only "still blocked, replug it" notice
|
|
218
|
+
(critical urgency for a CRITICAL device).
|
|
219
|
+
|
|
220
|
+
### 1.8 Privilege separation (`--privsep`)
|
|
221
|
+
|
|
222
|
+
A separate root gate (`gate_server.py`) performs the only privileged operations —
|
|
223
|
+
writing `authorized`, opening input and block nodes read-only — and passes file
|
|
224
|
+
descriptors over a `SEQPACKET` socket with `SCM_RIGHTS` to an analyzer running
|
|
225
|
+
as `nobody`, or the account `--privsep-user` names (the systemd service: its
|
|
226
|
+
own `probolos` system account). The privilege drop is verified, including
|
|
227
|
+
that `setuid(0)` fails afterwards.
|
|
228
|
+
|
|
229
|
+
The gate distinguishes temporary activation from final admission. Read scope
|
|
230
|
+
follows the USB device ancestor, skipping interface nodes, and requires blocked
|
|
231
|
+
state or an unexpired temporary permission for the same device instance. Final
|
|
232
|
+
admission gives no continued read/deauthorization permission. Whole-device
|
|
233
|
+
requests cannot target interfaces. See `SECURITY.md` for the limits of this
|
|
234
|
+
boundary; the analyzer still controls admission policy.
|
|
235
|
+
|
|
236
|
+
"Always" under `--privsep` is written by the gate (`REQ_TRUST`), never by the
|
|
237
|
+
analyzer: only for a device the gate admitted on this connection, within
|
|
238
|
+
60 s, once, and only under the fingerprint the gate took itself before first
|
|
239
|
+
switching the device on. The trust file's path comes from the root side's
|
|
240
|
+
command line.
|
|
241
|
+
|
|
242
|
+
### 1.9 Lockout safety
|
|
243
|
+
|
|
244
|
+
Watchdog with configurable timeout, panic file, `--release` recovery,
|
|
245
|
+
`--allow-port` to exempt known ports, `--dry-run`, and best-effort restoration of bus
|
|
246
|
+
state, including partial startup failures and analyzer disconnects. On the reference laptop the built-in keyboard is on the
|
|
247
|
+
i8042 PS/2 controller and is unaffected by USB authorization entirely.
|
|
248
|
+
|
|
249
|
+
### 1.10 Hostile-text handling
|
|
250
|
+
|
|
251
|
+
All device-supplied strings pass through `textsafe.py` before display:
|
|
252
|
+
bidirectional overrides, control characters, and invisible characters are
|
|
253
|
+
neutralized and reported as findings. A device cannot forge or reorder the
|
|
254
|
+
report about itself.
|
|
255
|
+
|
|
256
|
+
### 1.11 Non-product tooling
|
|
257
|
+
|
|
258
|
+
- `testbed/` — `dummy_hcd` / `raw_gadget` software emulation and a HID attack fixture
|
|
259
|
+
- `interrogation_study.py` + `interrogate.py` — **research instrument only.**
|
|
260
|
+
Active control-transfer fingerprinting for data collection. It classifies
|
|
261
|
+
nothing and is not wired into the daemon.
|
|
262
|
+
|
|
263
|
+
### 1.12 Notes on what changed
|
|
264
|
+
|
|
265
|
+
Several capabilities in the lists above were, until recently, code that
|
|
266
|
+
existed without being reachable or that did not do what it said. They are
|
|
267
|
+
called out because the failure mode is worth recognising rather than just
|
|
268
|
+
repairing:
|
|
269
|
+
|
|
270
|
+
| Was | Now |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `deferred_bind` decided it was supported by counting interface directories on a device held at `authorized=0`, where none exist. It returned `False` on every device and the daemon silently took the racy path. | Uses bus-wide `drivers_autoprobe` to defer binding, opt-in via the legacy `--close-race-window` flag. The input race remains. |
|
|
273
|
+
| `descriptors_safe` was imported by nothing, so its bounds on descriptor count and its `bLength` checks protected nothing. | `descriptors.parse()` walks through it. Truncation is now recorded and surfaced instead of discarded. |
|
|
274
|
+
| `storage_hardening` had one of four functions called. A partition with a legal start and an absurd length was read anyway. | All three bounds are wired, and `MediumReport.suspicious` — previously written and read by nobody — is now a finding. |
|
|
275
|
+
| `TrustStore.load()` parsed an admission list without checking who owned it or who could write it. | Ownership, mode, and symlink checks before the JSON is believed. Fails closed. |
|
|
276
|
+
| `_write_attr_pinned()` opened the pinned directory with `O_DIRECTORY \| O_NOFOLLOW` on the path as given, but `/sys/bus/usb/devices/<name>` entries are symlinks — so the direct backend failed with ENOTDIR on every write except `admit()`, which lacked the flag. The gate could not close in the default (non-privsep) mode. | `realpath()` before the open, symlink refusal preserved on the attribute. `admit()` uses the same discipline. Measured on real hardware: five root hubs, all closed cleanly. |
|
|
277
|
+
| `SafetyPolicy.is_protected()` accepted `removable=fixed` on any device. For a device behind an EXTERNAL hub that value comes from the hub's own `DeviceRemovable` bitmap, so one hostile hub silently disabled every check on everything behind it. | The chain from the device to the controller is walked; every ancestor must itself be `fixed` and the walk must reach a root hub before the exemption is granted. |
|
|
278
|
+
| `Ledger.record()` overwrote `descriptor_hash` on every decision, and `LedgerAnalyzer` compared against that field. A refusal, a timeout-denial, or merely queueing a device via `_hold_until_unlocked` adopted the attacker's blob as the reference. | A dedicated `baseline_hash` moved only by an explicit approval (`Ledger.record(..., approved=True)`). Old ledgers migrate via `known_hashes[0]`. |
|
|
279
|
+
| `descriptor_fingerprint()` hashed the raw descriptor blob, so the same physical stick on a USB 2 vs. a USB 3 controller produced a different digest — measured false positive on a Kingston DataTraveler. | Normalized fingerprint over the parsed device/interface identity, dropping bus-negotiated fields. The raw hash is kept beside it in `raw_hash` for forensics. Ledgers written by earlier versions clear their baseline on load and re-learn from the next sighting. |
|
|
280
|
+
| `report._line()` padded with Python character count, so a long ASCII name, a CJK product name, or a product string containing box-drawing characters broke the report box. `textsafe.pad`/`fit`/`display_width` were written for exactly this and had no callers. | The box is gone: the report is indented text, `_wrap()` measures in terminal columns (`textsafe.display_width`, `split_width`), and `_quote()` quotes device-supplied strings so they read as testimony, not as verdict text. `pad`/`fit` lost their callers with the box (§2.2). |
|
|
281
|
+
| The shipped systemd unit ran `--privsep --agent --timeout 0` with no `--agent-user`. At boot there is no graphical session, so `_resolve_agent_identity()` called `sys.exit()` and `Restart=on-failure` looped forever with the gate never closing. | Auto-detection failure logs and continues without the agent (explicit unknown `--agent-user` still exits). The unit gained `Environment=PROBOLOS_AGENT_USER` and a drop-in note. |
|
|
282
|
+
|
|
283
|
+
Each row has regression tests under `tests/`. Hardware claims are not
|
|
284
|
+
inferred from mock tests.
|
|
285
|
+
|
|
286
|
+
### 1.13 Media changes in admitted card readers (`--watch-media`, off by default)
|
|
287
|
+
|
|
288
|
+
**A separate detection layer, not part of the admission gate.** A card is a
|
|
289
|
+
SCSI medium inside a reader, not a USB device: inserting one is a unit
|
|
290
|
+
attention in the bound `usb-storage`/`uas` driver, with no re-enumeration and
|
|
291
|
+
no `authorized` decision point. The pre-authorization quarantine therefore
|
|
292
|
+
does not extend to cards, and nothing here claims it does.
|
|
293
|
+
|
|
294
|
+
| Piece | Mechanism |
|
|
295
|
+
|---|---|
|
|
296
|
+
| Which hosts | storage-only devices admitted by this run (prompt, trust, safety exemption) and storage-only devices present at startup (`mediawatch.MediaWatch.register`) |
|
|
297
|
+
| Event | udev `block`/`disk` `change` (and `add`) on a disk whose sysfs path is under a watched host, same kernel directory instance (`daemon._dispatch`) |
|
|
298
|
+
| Read | stage 4's own `storage.inspect_safely` on `/dev/sdX`: raw, read-only, never mounted, bounded, watchdog paused |
|
|
299
|
+
| Drift | ledger `media` section keyed on reader identity + SCSI LUN; the first layout is the baseline and never moves |
|
|
300
|
+
| Privsep | the gate, started with the same flag, records admitted/baseline storage-only hosts and allows whole-disk read-only opens and switch-**off** for them (`gate_server._media_host`) |
|
|
301
|
+
| Enforcement | `--media-policy log` (default) or `deauthorize`: drop the **whole reader** on a CRITICAL finding; nothing finer exists |
|
|
302
|
+
|
|
303
|
+
Rules (`rules.media_findings`), added to the stage 4 structural findings:
|
|
304
|
+
|
|
305
|
+
- `media-efi-system-partition` (CRITICAL) — MBR type `0xEF` or the GPT ESP GUID
|
|
306
|
+
- `media-hidden-partition` (CRITICAL) — hidden MBR types, or GPT attribute bit 62
|
|
307
|
+
- `media-inserted-while-locked` (WARNING)
|
|
308
|
+
- `media-layout-drift` (WARNING for a layout never seen in the slot, NOTICE for one seen before)
|
|
309
|
+
|
|
310
|
+
Every report states whether automount was inhibited for the disk
|
|
311
|
+
(`UDISKS_IGNORE`/`UDISKS_AUTO` from udev) or the medium was already mounted
|
|
312
|
+
when read, in which case it is post-hoc alerting. Latency follows the kernel's
|
|
313
|
+
disk-event polling (typically 1–2 s); a slot whose `events` does not include
|
|
314
|
+
`media_change`, or that nobody polls, is flagged when first seen.
|
|
315
|
+
|
|
316
|
+
Not covered: SD/MMC readers that are not USB mass storage (`mmcblk`, e.g.
|
|
317
|
+
SDHCI or `rtsx`), composite readers, and filesystem-parser exploits (§2.1).
|
|
318
|
+
|
|
319
|
+
### 1.14 The background service
|
|
320
|
+
|
|
321
|
+
`sudo ./install.sh` installs the code root-owned in `/opt/probolos` and enables
|
|
322
|
+
two systemd units (`systemd/`): `probolos.service`, the gate as a system
|
|
323
|
+
service with `--privsep --agent` and a sandboxed unit, and
|
|
324
|
+
`probolos-agent.service`, the desktop agent as a user service. Without an
|
|
325
|
+
agent the service holds devices (§1.7); with one, it asks through the desktop
|
|
326
|
+
prompt (§1.1). The units are checked in CI (`systemd-analyze verify`); their
|
|
327
|
+
behaviour on real hardware is part of the hardware matrix (ROADMAP 2.2).
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## 2. What Probolos does not do
|
|
332
|
+
|
|
333
|
+
### 2.1 Out of scope by design
|
|
334
|
+
|
|
335
|
+
These are not deficiencies; they are boundaries. Each belongs to a different
|
|
336
|
+
subsystem or threat model.
|
|
337
|
+
|
|
338
|
+
- **Vulnerabilities in the kernel USB stack.** The gate acts *after* the kernel
|
|
339
|
+
has parsed descriptors. Memory corruption during enumeration is reached before
|
|
340
|
+
Probolos sees anything. Only a sacrificial host (`usbip`) removes this.
|
|
341
|
+
- **Thunderbolt / PCIe DMA.** A different subsystem entirely — that is what the
|
|
342
|
+
IOMMU and `boltctl` are for.
|
|
343
|
+
- **USB Power Delivery.** PD negotiation happens in the Type-C port manager, not
|
|
344
|
+
in USB device authorization.
|
|
345
|
+
- **Wireless gateways.** A Bluetooth adapter passes every check and then admits
|
|
346
|
+
a keyboard over the air, with no USB event at all.
|
|
347
|
+
- **A patient attacker.** A device that behaves for a week and attacks afterwards
|
|
348
|
+
passes everything here.
|
|
349
|
+
- **Descriptor forgery.** An O.MG cable declares exactly what a real cable
|
|
350
|
+
declares. No identity-based check can separate them — which is precisely why
|
|
351
|
+
stages 3 and 4 exist.
|
|
352
|
+
- **Malicious hub or controller silicon.** Trust in the bus topology is assumed.
|
|
353
|
+
- **Post-authorization monitoring.** Once a device is approved, Probolos stops
|
|
354
|
+
watching it. The one exception is `--watch-media` (§1.13), which watches
|
|
355
|
+
the *media* of admitted storage hosts and still does not gate them.
|
|
356
|
+
- **Admission control for removable media.** A card has no `authorized`
|
|
357
|
+
switch of its own. `--watch-media` observes and alerts; at most it drops the
|
|
358
|
+
whole reader.
|
|
359
|
+
- **Filesystem-parser exploits.** A crafted exFAT/NTFS/FAT that attacks the
|
|
360
|
+
kernel's filesystem driver on mount looks like an ordinary filesystem in a
|
|
361
|
+
partition table. Kernel fs-driver bugs are the same class as kernel USB-stack
|
|
362
|
+
bugs above.
|
|
363
|
+
- **Content scanning.** No file inspection, no signatures, no anti-malware. The
|
|
364
|
+
storage stage reads partition metadata, nothing else.
|
|
365
|
+
- **Non-Linux platforms.** The entire mechanism is Linux sysfs USB authorization.
|
|
366
|
+
|
|
367
|
+
### 2.2 Present in the tree but not on any execution path
|
|
368
|
+
|
|
369
|
+
Listed separately because reading the source could suggest otherwise. The four
|
|
370
|
+
entries that used to head this list — an inert `deferred_bind`, an orphaned
|
|
371
|
+
`descriptors_safe`, a half-wired `storage_hardening`, and an unverified trust
|
|
372
|
+
store — have been fixed; see §1.12.
|
|
373
|
+
|
|
374
|
+
- **HID report descriptor analysis.** `descriptors_safe.walk_hid_items()`
|
|
375
|
+
exists, is hardened, and is tested — but the sysfs `descriptors` blob does not
|
|
376
|
+
contain report descriptors, only the HID descriptor (`0x21`) that announces
|
|
377
|
+
their length. Reaching the report descriptor itself requires the device to be
|
|
378
|
+
authorized and `usbhid` bound, i.e. the quarantine stage. Not yet wired to
|
|
379
|
+
anything; see §3.2.
|
|
380
|
+
- **`textsafe.pad()` and `textsafe.fit()`.** Tested, and correct for
|
|
381
|
+
double-width and combining characters, but the boxed report they were
|
|
382
|
+
written for is gone (§1.12): nothing outside the tests calls them. They stay
|
|
383
|
+
until after 1.0.
|
|
384
|
+
- **Deferred binding remains experimental** (`--close-race-window`). It does
|
|
385
|
+
not guarantee a zero exposure window, it is not available under
|
|
386
|
+
`--privsep`, and it is outside the 1.0 guarantees (§3.0).
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## 3. What may be built
|
|
391
|
+
|
|
392
|
+
### 3.0 Deferred past 1.0
|
|
393
|
+
|
|
394
|
+
Decided at the feature freeze (`v0.11.0`), so the absence reads as a decision.
|
|
395
|
+
None of these is in 1.0.0; each is a post-1.0 candidate.
|
|
396
|
+
|
|
397
|
+
| Deferred | Note |
|
|
398
|
+
|---|---|
|
|
399
|
+
| A GUI for history and trust | `--history`, `--trusted` and `--remove-*` stay command-line |
|
|
400
|
+
| `--remove-history N\|PATTERN`, numbered `--history` | |
|
|
401
|
+
| A configurable dialog timeout | 60 s stays the default |
|
|
402
|
+
| HID report-descriptor analysis | §3.2 |
|
|
403
|
+
| `--close-race-window` (deferred binding) | Stays in the tree, **experimental, outside the 1.0 guarantees** (§2.2) |
|
|
404
|
+
|
|
405
|
+
### 3.1 Validate the existing scope before adding features
|
|
406
|
+
|
|
407
|
+
For a thesis, prioritize hardware experiments: escaped keystrokes at the trusted
|
|
408
|
+
host, detection/miss rates, false positives on ordinary devices, stage latency,
|
|
409
|
+
and recovery after failures. Measure actual effects, not just discovery-to-grab
|
|
410
|
+
time. A clear, limited contribution with reproducible evidence is sufficient;
|
|
411
|
+
new detectors, kernel components or a second machine are separate research work.
|
|
412
|
+
|
|
413
|
+
### 3.2 Reach the HID report descriptors
|
|
414
|
+
|
|
415
|
+
`walk_hid_items()` is written, hardened against push/pop imbalance, collection
|
|
416
|
+
depth, and absurd report sizes — and has nothing to read. The report descriptor
|
|
417
|
+
is not in the sysfs blob. Getting to it means either reading
|
|
418
|
+
`/sys/bus/hid/devices/*/report_descriptor` during the quarantine window, once
|
|
419
|
+
`usbhid` has bound, or fetching it over a control transfer with the device
|
|
420
|
+
authorized but no driver attached — which `--close-race-window` now makes
|
|
421
|
+
possible for the first time.
|
|
422
|
+
|
|
423
|
+
The rule it unlocks is a strong one with no equivalent in the current set: a
|
|
424
|
+
device whose interface descriptor says *mouse* while its report descriptor
|
|
425
|
+
claims the keyboard usage page. Identity-level checks cannot see that at all.
|
|
426
|
+
|
|
427
|
+
### 3.3 Other connected work
|
|
428
|
+
|
|
429
|
+
- An HMAC over the trust store, keyed by a root-only file, would extend §1.6
|
|
430
|
+
from "nobody else could write this" to "nobody else did write this".
|
|
431
|
+
- Validate the systemd service (§1.14) on real hardware (§3.1, ROADMAP 2.2).
|
|
432
|
+
|
|
433
|
+
### 3.4 Research directions
|
|
434
|
+
|
|
435
|
+
- **Population-scale false-positive measurement of two specification-derived rules**
|
|
436
|
+
(`descriptor-chain-truncated`, `descriptor-length-overstated`). Both are
|
|
437
|
+
reasoned from the specification rather than observed in the wild, which is
|
|
438
|
+
the profile of a rule that turns out to fire on some ordinary vendor's
|
|
439
|
+
firmware.
|
|
440
|
+
- **Active interrogation as a detector** — promote `interrogate.py` from study
|
|
441
|
+
to rule, *if and only if* the collected distributions actually separate
|
|
442
|
+
consumer USB stacks from general-purpose microcontrollers.
|
|
443
|
+
- **Post-authorization monitoring via HID-BPF** — addresses §2.1's blind spot
|
|
444
|
+
after approval. Requires C and clang.
|
|
445
|
+
- **Port and topology watch** — a device appearing on a port that is physically
|
|
446
|
+
internal is worth a finding on its own.
|
|
447
|
+
- **Differential analysis across visits** — beyond descriptor hash equality, to
|
|
448
|
+
*what* changed and whether the change is plausible.
|
|
449
|
+
- **Vendor plausibility** — a VID assigned to one company whose strings name
|
|
450
|
+
another.
|
|
451
|
+
- **Population-scale false-positive measurement** — the rule set is only as
|
|
452
|
+
good as its false-positive rate across ordinary hardware, which is currently
|
|
453
|
+
unmeasured.
|
|
454
|
+
|
|
455
|
+
### 3.5 Explicitly not planned
|
|
456
|
+
|
|
457
|
+
Thunderbolt/DMA, USB-PD, wireless attack surfaces, content scanning, and
|
|
458
|
+
non-Linux platforms. They are listed here so their absence reads as a decision
|
|
459
|
+
rather than an omission.
|