~james/scope

fix(capture): bound transcript lines before allocating records

commit
109ad18b4a0c6e116893e947810fa95b3855994e browse tree
parent
cb7f12d3e8eeba75b4f02821fbb85d2741a69beb
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-10-29 19:45 UTC

5 files changed, +48 −113

README.md added

@@ -0,0 +1,3 @@
1+Inspects four level x86_64 guest page tables on Xen and replays captures offline. It reports the entries consulted during translation and predicts ordinary explicit paging accesses using the captured register state and an explicit guest feature profile.
2+
3+<video controls src="meow.mp4"><a href="meow.mp4">meow.mp4</a></video>

format.md deleted

@@ -1,111 +0,0 @@
1-A version 1 capture contains a fixed header, three metadata strings and the table frames below the selected root. Integers are little-endian and no OCaml values are marshalled, so the reader can check lengths and tags before constructing a paging context.
2-
3-## Header
4-
5-```text
6-offset bytes encoding field
7-0 8 bytes 53 43 4f 50 45 00 01 0a
8-8 4 u32 domid
9-12 4 u32 vcpu
10-16 4 u32 physical-address width
11-20 4 u32 feature flags
12-24 56 7 * u64 cr0 cr2 cr3 cr4 efer rflags rip
13-80 4 u32 root-override tag
14-84 8 u64 root-override value
15-
16-feature flags
17- bit 0 = nx
18- bit 1 = 1 gib pages
19- other bits = rejected
20-
21-root override
22- tag 0 = use saved cr3, value must be zero
23- tag 1 = select value as the raw root, retain saved cr3
24- other tags = rejected
25-```
26-
27-## Payload
28-
29-```text
30-string = u32 byte-length, byte-length bytes
31-metadata = string source, string consistency, string domain-identity
32-frame = u64 guest-physical-address, u32 tag, frame-payload
33-tag 0 = 4096 bytes of table contents
34-tag 1 = string read-error
35-frames = u32 count, count frame records
36-file = header, metadata, frames, 16-byte digest
37-digest = raw md5 of every preceding byte
38-
39-bounds
40- runtime = 64-bit
41- physical width = 32..52
42- string length <= 4096
43- frame count <= 65536
44- file length <= 280000000
45- frame alignment = 4096
46- duplicate frames = rejected
47- unknown frame tags = rejected
48- out-of-width addresses = rejected
49- trailing payload bytes = rejected
50- checksum mismatch = rejected
51-```
52-
53-MD5 detects accidental corruption and does not authenticate a capture. Writes go through a mode-0600 temporary file beside the destination, followed by `fsync` and rename, but the directory itself is not synchronised and the rename may therefore be lost after a host crash.
54-
55-## Import
56-
57-```text
58-command
59- scope import TRANSCRIPT OUTPUT [--source TEXT]
60-
61-records
62- context CR0 CR2 CR3 CR4 EFER RFLAGS RIP PHYS_BITS NX GB_PAGES
63- page ADDRESS HEX
64- missing ADDRESS REASON
65-
66-grammar
67- addresses and registers = unsigned decimal or 0x-prefixed hex
68- phys_bits = decimal
69- nx and gb_pages = 0 or 1
70- hex = 8192 hex digits in ascending byte order
71- field separator = ordinary spaces
72- record separator = newline
73- reason = rest of line, may be empty
74- context count = exactly one, may follow frame records
75- duplicate frames = rejected
76- unknown records = rejected
77- line length <= 16384 bytes
78- input length <= 840000000 bytes
79- imported root override = none
80- imported domid and vcpu = 0
81-```
82-
83-## Reports
84-
85-```text
86-schema = scope.report.v1
87-addresses and sizes = hexadecimal strings
88-complete = completeness of the requested analysis
89-info = checks the selected table graph
90-walk = structural fault bits, no assumed access or privilege
91-explain = error code for the explicit request
92-ac = captured rflags unless --ac overrides it
93-error_code_scope = structural_bits_only or request
94-
95-diff
96- intervals = disjoint virtual ranges
97- mapping states = values and paths at the interval start
98- leaf sizes = original sizes retained
99- huge-page split = potentially one record per smaller leaf
100- unreadable subtree = incomplete, never treated as absent
101-
102-limits
103- traversal budget = abort before emitting a partial report
104- alias budget = abort before emitting a partial report
105- error stream = stderr, structured when --json is selected
106- result stream = stdout
107- exit 0 = complete, no fault or finding
108- exit 1 = predicted fault, invalid table, alias or diff
109- exit 2 = incomplete or operational failure
110- exit 64 = usage error
111-```

lib/capture.ml modified

@@ -188,12 +188,26 @@ let import ~source path =
188188 let channel = open_in path in
189189 Fun.protect ~finally:(fun () -> close_in_noerr channel) (fun () ->
190190 if in_channel_length channel > maximum_bytes * 3 then invalid_arg "import exceeds format size budget";
191+ let buffer = Bytes.create 16384 in
192+ let line () =
193+ let rec read length =
194+ match input_char channel with
195+ | '\n' -> Bytes.sub_string buffer 0 length
196+ | c ->
197+ if length = Bytes.length buffer then invalid_arg "import line exceeds 16384 bytes";
198+ Bytes.set buffer length c;
199+ read (length + 1)
200+ | exception End_of_file ->
201+ if length = 0 then raise End_of_file;
202+ Bytes.sub_string buffer 0 length
203+ in
204+ read 0
205+ in
191206 let context = ref None and frames = ref [] and count = ref 0 in
192207 let number = ref 0 in
193208 (try while true do
194- let line = input_line channel in
209+ let line = line () in
195210 incr number;
196- if String.length line > 16384 then invalid_arg "import line exceeds 16384 bytes";
197211 let fields = String.split_on_char ' ' line |> List.filter ((<>) "") in
198212 match fields with
199213 | [] -> ()

meow.mp4 added

Binary file.

test/check.ml modified

@@ -232,6 +232,34 @@ let generated () =
232232 let exploding = capture ~context [0x1000L, Demo.page (List.init 512 (fun index -> index, 0x1007L))] in
233233 rejects (fun () -> ignore (Paging.enumerate ~max_entries:4096 context (Capture.read exploding)))
234234
235+let imports () =
236+ let path = Filename.temp_file "scopeimport" "" in
237+ Fun.protect ~finally:(fun () -> Sys.remove path) (fun () ->
238+ let context = "context 0x80010001 0 0x1000 0x20 0xd00 2 0 48 1 1" in
239+ let write text =
240+ let channel = open_out_bin path in
241+ Fun.protect ~finally:(fun () -> close_out_noerr channel) (fun () -> output_string channel text)
242+ in
243+ List.iter (fun ending ->
244+ write (context ^ ending);
245+ let imported = Capture.import ~source:"test" path in
246+ check (Capture.pages imported = 0) "import accepts context at eof") [""; "\n"];
247+ List.iter (fun ending ->
248+ write (context ^ "\n" ^ String.make 16384 ' ' ^ ending);
249+ check (Capture.pages (Capture.import ~source:"test" path) = 0)
250+ "import accepts maximum line length") [""; "\n"];
251+ List.iter (fun ending ->
252+ write (context ^ "\n" ^ String.make 16385 ' ' ^ ending);
253+ rejects (fun () -> ignore (Capture.import ~source:"test" path))) [""; "\n"];
254+ write (context ^ "\n" ^ String.make (2 * 1024 * 1024) 'x');
255+ let allocated = Gc.allocated_bytes () in
256+ let rejected = try ignore (Capture.import ~source:"test" path); false with
257+ Invalid_argument reason -> reason = "import line exceeds 16384 bytes"
258+ in
259+ let allocated = Gc.allocated_bytes () -. allocated in
260+ check rejected "import rejects oversized record before parsing";
261+ check (allocated < 1048576.) "import rejects oversized line with bounded allocation")
262+
235263 let watch () =
236264 let base = Filename.temp_file "scopewatch" "" in
237265 Sys.remove base;
@@ -269,5 +297,6 @@ let () =
269297 aliases ();
270298 codec ();
271299 generated ();
300+ imports ();
272301 watch ();
273302 Printf.printf "%d checks passed\n" !count