ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
remote_gdb_guard_selftest.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Offline adversarial tests for remote-GDB arguments, state, and broker."""
4
5from __future__ import annotations
6
7import argparse
8import contextlib
9import importlib.util
10import io
11import os
12import socket
13import subprocess
14import sys
15import tempfile
16import threading
17import traceback
18from collections.abc import Callable
19from pathlib import Path
20from types import ModuleType, SimpleNamespace
21from unittest import mock
22
23WAIT_SECONDS = 5.0
24RIG_PARITY_CORPUS = {
25 "PI_HOST": (
26 "star",
27 "star.local",
28 "1user@host",
29 "user@001.002.003.004",
30 "192.168.1.20",
31 "-host",
32 ".user@host",
33 "host.",
34 "bad_host",
35 "1.2.3",
36 "1.2.3.999",
37 "host;command",
38 "debug@[2001:db8::20]",
39 ),
40 "JLINK_SN": (
41 "123456789",
42 "J-Link_1.2",
43 "_",
44 "a" * 128,
45 "",
46 ".bad",
47 "a" * 129,
48 "bad+value",
49 "bad value",
50 ),
51 "JLINK_DEVICE": (
52 "R7KA8D2KF_CPU0",
53 "Cortex-M85.rev_1",
54 "_",
55 "a" * 128,
56 "",
57 ".bad",
58 "a" * 129,
59 "bad+value",
60 "bad;value",
61 ),
62}
63
64
65def _load(path: Path, name: str) -> ModuleType:
66 """Execute the exact selected source bytes in a fresh module namespace."""
67 spec = importlib.util.spec_from_file_location(name, path)
68 if spec is None or spec.loader is None:
69 msg = f"cannot load {path}"
70 raise RuntimeError(msg)
71 module = importlib.util.module_from_spec(spec)
72 sys.modules[name] = module
73 spec.loader.exec_module(module)
74 return module
75
76
77def _api(module: ModuleType, name: str) -> object:
78 """Select one exact internal boundary under adversarial test."""
79 return vars(module)[name]
80
81
82def _spawn_status(argv: list[str], environment: dict[str, str]) -> int:
83 """Run one fixed offline shell boundary without subprocess search semantics."""
84 actions = [
85 (os.POSIX_SPAWN_OPEN, 1, "/dev/null", os.O_WRONLY, 0o600),
86 (os.POSIX_SPAWN_OPEN, 2, "/dev/null", os.O_WRONLY, 0o600),
87 ]
88 pid = os.posix_spawn(argv[0], argv, environment, file_actions=actions)
89 _waited, status = os.waitpid(pid, 0)
90 return os.waitstatus_to_exitcode(status)
91
92
93def _case(failures: list[str], name: str, action: Callable[[], object], *, must_fail: bool) -> None:
94 """Run one both-direction case and record an unexpected result."""
95 try:
96 action()
97 except Exception as exc: # noqa: BLE001 - deliberate fail-closed attacks.
98 if not must_fail:
99 failures.append(f"{name}: unexpected {type(exc).__name__}: {exc}")
100 return
101 if must_fail:
102 failures.append(f"{name}: unexpectedly passed")
103
104
105def _host_cases(args: ModuleType, failures: list[str]) -> None:
106 """Attack SSH host option, metacharacter, and control-byte forms."""
107 for host in ("pi.local", "runner@192.168.1.20", "192.168.1.20"):
108 _case(
109 failures,
110 f"valid host {host}",
111 lambda value=host: args.validate_host(value),
112 must_fail=False,
113 )
114 invalid_hosts = (
115 "-V",
116 "-p2222",
117 "--help",
118 "bench#host",
119 "user@@host",
120 "bad host",
121 "bad\thost",
122 "bad;host",
123 "bad|host",
124 "bad&host",
125 "$(id)",
126 "`id`",
127 "bad\nhost",
128 "bad\rhost",
129 "bad\vhost",
130 "bad\fhost",
131 "'bad'",
132 '"bad"',
133 "bad>file",
134 "bad*host",
135 "bad(host)",
136 "debug@[2001:db8::20]",
137 )
138 for value in invalid_hosts:
139 _case(
140 failures,
141 f"invalid host {value!r}",
142 lambda value=value: args.validate_host(value),
143 must_fail=True,
144 )
145
146
147def _transport_cases(args: ModuleType, failures: list[str]) -> None:
148 """Attack serial, device, and port boundary forms."""
149 hostile = (
150 "123 456",
151 "123\t456",
152 "123;456",
153 "123|456",
154 "123&456",
155 "$(id)",
156 "`id`",
157 "123\n456",
158 "123'456",
159 '123"456',
160 "123>456",
161 "123#456",
162 "123*456",
163 "123(456)",
164 "123\r456",
165 "123\v456",
166 "123\f456",
167 r"123\;456",
168 )
169 for value in hostile:
170 _case(
171 failures,
172 f"invalid serial {value!r}",
173 lambda value=value: args.validate_serial(value),
174 must_fail=True,
175 )
176 _case(
177 failures,
178 f"invalid device {value!r}",
179 lambda value=value: args.validate_device(value),
180 must_fail=True,
181 )
182 _case(failures, "valid serial", lambda: args.validate_serial("123456789"), must_fail=False)
183 _case(
184 failures,
185 "valid SEGGER device",
186 lambda: args.validate_device("R7KA8D2KF_CPU0"),
187 must_fail=False,
188 )
189 for port in ("1024", "2331", "65535"):
190 _case(
191 failures,
192 f"valid port {port}",
193 lambda value=port: args.validate_port(value),
194 must_fail=False,
195 )
196 for port in ("", "0", "1023", "65536", "-1", "+2331", "23 31"):
197 _case(
198 failures,
199 f"invalid port {port!r}",
200 lambda value=port: args.validate_port(value),
201 must_fail=True,
202 )
203
204
205def _remote_parse(command: str) -> tuple[int, tuple[str, ...]]:
206 """Drive a generated command through the OpenSSH remote-shell parse layer."""
207 input_read, input_write = os.pipe()
208 output_read, output_write = os.pipe()
209 actions = [
210 (os.POSIX_SPAWN_DUP2, input_read, 0),
211 (os.POSIX_SPAWN_DUP2, output_write, 1),
212 (os.POSIX_SPAWN_CLOSE, input_write),
213 (os.POSIX_SPAWN_CLOSE, output_read),
214 ]
215 pid = os.posix_spawn("/bin/sh", ["/bin/sh", "-c", command], os.environ, file_actions=actions)
216 os.close(input_read)
217 os.close(output_write)
218 os.write(
219 input_write,
220 b'import os,sys\nos.write(1,b"\\0".join(v.encode("ascii") for v in sys.argv[1:]))\n',
221 )
222 os.close(input_write)
223 raw = bytearray()
224 while chunk := os.read(output_read, 4096):
225 raw.extend(chunk)
226 os.close(output_read)
227 _waited, status = os.waitpid(pid, 0)
228 fields = tuple(field.decode("ascii") for field in bytes(raw).split(b"\0") if field)
229 return os.waitstatus_to_exitcode(status), fields
230
231
232def _serialization_cases(args: ModuleType, failures: list[str]) -> None:
233 """Prove exact remote argv after the unavoidable shell serialization."""
234 cases = (
235 (
236 "R7KA8D2KF_CPU0",
237 "123456789",
238 "2331",
239 ("--", "R7KA8D2KF_CPU0", "123456789", "2331"),
240 ),
241 (
242 "Cortex-M85.rev_1",
243 "0000123",
244 "65535",
245 ("--", "Cortex-M85.rev_1", "0000123", "65535"),
246 ),
247 )
248 for device, serial, port, expected in cases:
249 result, observed = _remote_parse(args.remote_command(serial, port, device=device))
250 if result != 0 or observed != expected:
251 failures.append(f"remote serialization: {result}, {observed!r}")
252
253
254def _remote_cli_cases(args: ModuleType, failures: list[str]) -> None:
255 """Require all three fields on the sole remote-supervisor command."""
256 parser = _api(args, "_parser")()
257 valid = (
258 [
259 "remote-command",
260 "--device",
261 "R7KA8D2KF_CPU0",
262 "--serial",
263 "123456789",
264 "--port",
265 "2331",
266 ],
267 )
268 invalid = (
269 ["remote-command", "--serial", "123456789", "--port", "2331"],
270 ["remote-command", "cleanup", "--serial", "123456789", "--port", "2331"],
271 )
272 for argv in valid:
273 with contextlib.redirect_stderr(io.StringIO()):
274 try:
275 parser.parse_args(argv)
276 except SystemExit as exc:
277 failures.append(f"valid remote CLI rejected {argv!r}: {exc.code}")
278 for argv in invalid:
279 with contextlib.redirect_stderr(io.StringIO()):
280 try:
281 parser.parse_args(argv)
282 except SystemExit:
283 continue
284 failures.append(f"invalid remote CLI accepted {argv!r}")
285
286
287def _accepts(action: Callable[[], object]) -> bool:
288 try:
289 action()
290 except Exception: # noqa: BLE001 - parity compares fail-closed outcomes.
291 return False
292 return True
293
294
295def _rig_contract_parity(args: ModuleType, root: Path, failures: list[str]) -> None:
296 """Bind defensive Python parsing to the sole public rig contract corpus."""
297 contract = root / "scripts/hil/lib/rig_contract.sh"
298 environment = {
299 "BASH_ENV": "/nonexistent",
300 "ENV": "/nonexistent",
301 "HOME": "/nonexistent",
302 "LC_ALL": "C",
303 "PATH": "/usr/bin:/bin",
304 }
305 if _spawn_status(["/bin/bash", "-p", str(contract), "--selftest"], environment) != 0:
306 failures.append("public rig contract selftest failed")
307 validators = {
308 "PI_HOST": args.validate_host,
309 "JLINK_SN": args.validate_serial,
310 "JLINK_DEVICE": args.validate_device,
311 }
312 for field, values in RIG_PARITY_CORPUS.items():
313 for value in values:
314 public = (
315 _spawn_status(
316 ["/bin/bash", "-p", str(contract), "--validate", field, value],
317 environment,
318 )
319 == 0
320 )
321 defensive = _accepts(lambda value=value, field=field: validators[field](value))
322 if public != defensive:
323 failures.append(
324 f"rig contract parity mismatch {field}={value!r}: "
325 f"public={public} defensive={defensive}"
326 )
327
328
329def _app_cases(args: ModuleType, root: Path, failures: list[str]) -> None:
330 """Prove option termination and exact single-result app authority."""
331 observed: list[list[str]] = []
332
333 def valid(argv: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
334 observed.append(argv)
335 return subprocess.CompletedProcess(argv, 0, "ek_ra8d2::hw_validated::hil::blinky\n", "")
336
337 result = args.canonical_app(root, "--help", valid)
338 if result != "ek_ra8d2::hw_validated::hil::blinky" or observed[0][-2:] != [
339 "--",
340 "--help",
341 ]:
342 failures.append("canonical app did not bind option terminator")
343
344 def multiline(argv: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
345 return subprocess.CompletedProcess(argv, 0, "one\ntwo\n", "")
346
347 _case(
348 failures,
349 "multi-line app",
350 lambda: args.canonical_app(root, "x", multiline),
351 must_fail=True,
352 )
353 _case(
354 failures,
355 "live canonical app",
356 lambda: args.canonical_app(root, "board::stand_alone::ra8d2-ereader"),
357 must_fail=False,
358 )
359 for selector in ("--help", "bad app", "bad;app", "bad\napp", "../app"):
360 _case(
361 failures,
362 f"hostile app {selector!r}",
363 lambda value=selector: args.canonical_app(root, value),
364 must_fail=True,
365 )
366
367
368def _workspace(temp: Path) -> tuple[Path, Path, Path]:
369 """Create isolated canonical-looking script and private runtime authorities."""
370 root = (temp / "workspace").resolve()
371 script = root / "scripts/dev/remote_gdb_server.sh"
372 script.parent.mkdir(parents=True)
373 script.write_text("#!/bin/bash -p\nexit 0\n", encoding="ascii")
374 script.chmod(0o700)
375 runtime = temp / "runtime"
376 runtime.mkdir(mode=0o700)
377 return root, script, runtime
378
379
380def _request(guard: ModuleType, temp: Path, parent: int = 4242) -> tuple[object, Path]:
381 """Construct one macOS-shaped request with a private injected runtime."""
382 root, script, runtime = _workspace(temp)
383 request = guard.BrokerRequest(
384 root, script, "2331", "", parent, runtime_base=runtime, platform="darwin"
385 )
386 return request, _api(guard, "_state_dir")(request)
387
388
389def _start_broker(
390 guard: ModuleType, request: object, parent_ref: list[int], signals: list[int]
391) -> tuple[threading.Thread, list[Exception], object]:
392 """Start the production broker in a thread with non-signalling hooks."""
393 errors: list[Exception] = []
394 hooks = guard.BrokerHooks(
395 getppid=lambda: parent_ref[0],
396 signal_parent=signals.append,
397 pid_alive=lambda pid: pid == os.getpid(),
398 )
399
400 def target() -> None:
401 try:
402 _api(guard, "_run_broker")(request, hooks)
403 except Exception as exc: # noqa: BLE001 - returned to the self-test thread.
404 errors.append(RuntimeError(f"{exc}\n{traceback.format_exc()}"))
405
406 thread = threading.Thread(target=target, daemon=True)
407 thread.start()
408 try:
409 _api(guard, "_await_broker")(request, os.getpid(), WAIT_SECONDS)
410 except Exception as exc:
411 try:
412 _api(guard, "_request_broker")(
413 _api(guard, "_state_dir")(request), "status", request=request
414 )
415 detail = "status unexpectedly passed"
416 except Exception as status_exc: # noqa: BLE001 - diagnostic only.
417 detail = f"{type(status_exc).__name__}: {status_exc}"
418 message = f"broker did not start; thread errors: {errors!r}; status={detail}"
419 raise RuntimeError(message) from exc
420 return thread, errors, hooks
421
422
423def _broker_cases(guard: ModuleType, failures: list[str]) -> dict[str, object]:
424 """Exercise stop, release, parent death, live exclusion, and cleanup."""
425 retained: dict[str, object] = {}
426 with tempfile.TemporaryDirectory(prefix="ra8-gdb-broker-", dir="/tmp") as directory:
427 request, state_dir = _request(guard, Path(directory))
428 parent_ref = [request.parent_pid]
429 signals: list[int] = []
430 thread, errors, hooks = _start_broker(guard, request, parent_ref, signals)
431 retained.update(_api(guard, "_read_record")(state_dir).value)
432 _case(
433 failures,
434 "second live broker",
435 lambda: _api(guard, "_run_broker")(request, hooks),
436 must_fail=True,
437 )
438 _api(guard, "_control_broker")(request, "stop")
439 thread.join(WAIT_SECONDS)
440 if thread.is_alive() or errors or signals != [request.parent_pid]:
441 failures.append(
442 f"broker stop failed: alive={thread.is_alive()} "
443 f"errors={errors!r} signals={signals!r}"
444 )
445 if (state_dir / guard.STATE_NAME).exists() or (state_dir / guard.SOCKET_NAME).exists():
446 failures.append("broker stop left state or socket")
447
448 with tempfile.TemporaryDirectory(prefix="ra8-gdb-parent-death-", dir="/tmp") as directory:
449 request, state_dir = _request(guard, Path(directory))
450 parent_ref = [request.parent_pid]
451 signals = []
452 thread, errors, _hooks = _start_broker(guard, request, parent_ref, signals)
453 parent_ref[0] = 1
454 thread.join(WAIT_SECONDS)
455 _api(guard, "_control_broker")(request, "stop")
456 if thread.is_alive() or errors or signals:
457 failures.append(
458 f"parent death retained broker: alive={thread.is_alive()} "
459 f"errors={errors!r} signals={signals!r}"
460 )
461 if (state_dir / guard.STATE_NAME).exists() or (state_dir / guard.SOCKET_NAME).exists():
462 failures.append("parent death did not atomically clean state")
463
464 with tempfile.TemporaryDirectory(prefix="ra8-gdb-release-", dir="/tmp") as directory:
465 request, _state_dir = _request(guard, Path(directory))
466 parent_ref = [request.parent_pid]
467 signals = []
468 thread, errors, _hooks = _start_broker(guard, request, parent_ref, signals)
469 _api(guard, "_control_broker")(request, "release")
470 thread.join(WAIT_SECONDS)
471 if thread.is_alive() or errors or signals:
472 failures.append("release did not stop broker without signalling")
473 return retained
474
475
476def _protocol_cases(guard: ModuleType, failures: list[str]) -> None:
477 """Reject malformed protocol, wrong nonce, trailing data, and absent credentials."""
478 nonce = "a" * 64
479 good = _api(guard, "_request_bytes")("status", nonce)
480 if _api(guard, "_parse_request")(good, nonce) != "status":
481 failures.append("valid protocol request did not round trip")
482 attacks = (
483 good + b"{}\n",
484 good.replace(b'"version":1', b'"version":1,"version":1'),
485 _api(guard, "_request_bytes")("status", "b" * 64),
486 b"x" * (guard.MAX_REQUEST_BYTES + 1),
487 )
488 for index, raw in enumerate(attacks):
489 _case(
490 failures,
491 f"protocol attack {index}",
492 lambda raw=raw: _api(guard, "_parse_request")(raw, nonce),
493 must_fail=True,
494 )
495
496 class Peer:
497 def getpeereid(self) -> tuple[int, int]:
498 return os.getuid(), os.getgid()
499
500 if _api(guard, "_peer_uid")(Peer()) != os.getuid():
501 failures.append("getpeereid peer UID was not accepted")
502 credentials = {
503 name: getattr(socket, name)
504 for name in ("SO_PEERCRED", "LOCAL_PEERCRED")
505 if hasattr(socket, name)
506 }
507 for name in credentials:
508 delattr(socket, name)
509 try:
510 _case(
511 failures,
512 "missing peer credential primitive",
513 lambda: _api(guard, "_peer_uid")(object()),
514 must_fail=True,
515 )
516 finally:
517 for name, credential in credentials.items():
518 setattr(socket, name, credential)
519
520
521def _record_fixture(
522 guard: ModuleType, temp: Path, value: dict[str, object]
523) -> tuple[object, Path, dict[str, object]]:
524 """Copy a valid record into a new canonical namespace."""
525 request, state_dir = _request(guard, temp)
526 updated = dict(value)
527 root_stat = request.root.stat()
528 script_stat, script_digest = _api(guard, "_regular_identity")(request.script)
529 helper_digest = _api(guard, "_helper_digest")()
530 updated.update(
531 platform="darwin",
532 root=str(request.root),
533 root_dev=root_stat.st_dev,
534 root_ino=root_stat.st_ino,
535 script=str(request.script),
536 script_dev=script_stat.st_dev,
537 script_ino=script_stat.st_ino,
538 script_sha256=script_digest,
539 helper_sha256=helper_digest,
540 port="2331",
541 )
542 return request, state_dir, updated
543
544
545def _stale_cases(guard: ModuleType, value: dict[str, object], failures: list[str]) -> None:
546 """Clean only identity-matching dead state and preserve ambiguity."""
547 with tempfile.TemporaryDirectory(prefix="ra8-gdb-stale-", dir="/tmp") as directory:
548 request, state_dir, updated = _record_fixture(guard, Path(directory), value)
549 old = _api(guard, "_publish_record")(state_dir, updated)
550 hooks = guard.BrokerHooks(getppid=lambda: request.parent_pid, pid_alive=lambda _pid: False)
551 _api(guard, "_prepare_state")(request, hooks)
552 if (state_dir / guard.STATE_NAME).exists():
553 failures.append("identity-matching stale state was not cleaned")
554 current = _api(guard, "_publish_record")(state_dir, updated)
555 _api(guard, "_unlink_exact")(state_dir, current)
556 replaced = dict(updated)
557 replaced["app_arg"] = "changed"
558 _api(guard, "_publish_record")(state_dir, replaced)
559 if _api(guard, "_unlink_exact")(state_dir, old):
560 failures.append("stale cleanup removed a replaced record")
561
562 with tempfile.TemporaryDirectory(prefix="ra8-gdb-socket-mismatch-", dir="/tmp") as directory:
563 request, state_dir, updated = _record_fixture(guard, Path(directory), value)
564 control = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
565 socket_path = state_dir / guard.SOCKET_NAME
566 control.bind(str(socket_path))
567 socket_path.chmod(0o600)
568 updated["socket_ino"] = int(updated["socket_ino"]) + 1
569 _api(guard, "_publish_record")(state_dir, updated)
570 hooks = guard.BrokerHooks(getppid=lambda: request.parent_pid, pid_alive=lambda _pid: False)
571 _case(
572 failures,
573 "ambiguous stale socket",
574 lambda: _api(guard, "_prepare_state")(request, hooks),
575 must_fail=True,
576 )
577 if not socket_path.exists() or not (state_dir / guard.STATE_NAME).exists():
578 failures.append("ambiguous stale state was modified")
579 control.close()
580
581
582def _filesystem_cases(guard: ModuleType, failures: list[str]) -> None:
583 """Reject links, permissive directories, special records, and duplicate JSON."""
584 with tempfile.TemporaryDirectory(prefix="ra8-gdb-runtime-", dir="/tmp") as directory:
585 temp = Path(directory)
586 runtime = temp / "runtime"
587 runtime.mkdir(mode=0o755)
588 _case(
589 failures,
590 "permissive runtime",
591 lambda: _api(guard, "_runtime_directory")(temp, base=runtime),
592 must_fail=True,
593 )
594 runtime.chmod(0o700)
595 victim = temp / "victim"
596 victim.mkdir(mode=0o700)
597 (runtime / "ra8-remote-gdb").symlink_to(victim, target_is_directory=True)
598 _case(
599 failures,
600 "linked runtime namespace",
601 lambda: _api(guard, "_runtime_directory")(temp, base=runtime),
602 must_fail=True,
603 )
604
605 for kind in ("symlink", "fifo", "permissive", "duplicate"):
606 with tempfile.TemporaryDirectory(prefix=f"ra8-gdb-{kind}-", dir="/tmp") as directory:
607 _request_value, state_dir = _request(guard, Path(directory))
608 state = state_dir / guard.STATE_NAME
609 if kind == "symlink":
610 victim = Path(directory) / "victim"
611 victim.write_text("preserve\n", encoding="ascii")
612 state.symlink_to(victim)
613 elif kind == "fifo":
614 os.mkfifo(state, 0o600)
615 elif kind == "permissive":
616 state.write_text("{}\n", encoding="ascii")
617 state.chmod(0o644)
618 else:
619 state.write_text('{"version":1,"version":1}\n', encoding="ascii")
620 state.chmod(0o600)
621 _case(
622 failures,
623 f"unsafe record {kind}",
624 lambda state_dir=state_dir: _api(guard, "_read_record")(state_dir),
625 must_fail=True,
626 )
627
628
629def _platform_cases(guard: ModuleType, failures: list[str]) -> None:
630 """Prove the macOS fallback is private and never a shared TMPDIR."""
631 with tempfile.TemporaryDirectory(prefix="ra8-gdb-home-", dir="/tmp") as directory:
632 home = Path(directory).resolve() / "home"
633 home.mkdir(mode=0o700)
634 selected = _api(guard, "_home_runtime")(os.getuid(), home)
635 if home not in selected.parents or selected.stat().st_mode & 0o077:
636 failures.append("macOS-shaped home fallback is not private")
637 linked = Path(directory) / "linked-home"
638 linked.symlink_to(home, target_is_directory=True)
639 _case(
640 failures,
641 "linked home fallback",
642 lambda: _api(guard, "_home_runtime")(os.getuid(), linked),
643 must_fail=True,
644 )
645 if "TMPDIR" in str(selected):
646 failures.append("runtime fallback used shared TMPDIR")
647
648
649def _write_proc(proc: Path, pid: int, root: Path, script: Path, ticks: int) -> None:
650 """Build a synthetic Linux procfs identity without creating a process."""
651 process = proc / str(pid)
652 (process / "fd").mkdir(parents=True, exist_ok=True)
653 tail = ["S", *(["0"] * 18), str(ticks)]
654 (process / "stat").write_text(f"{pid} (bash fixture) {' '.join(tail)}\n", encoding="ascii")
655 (process / "status").write_text(
656 f"Uid:\t{os.getuid()}\t{os.getuid()}\t{os.getuid()}\t{os.getuid()}\n",
657 encoding="ascii",
658 )
659 argv = ["/bin/bash", "-p", "--", str(script), "run", "2331"]
660 (process / "cmdline").write_bytes(b"\0".join(field.encode("ascii") for field in argv) + b"\0")
661 links = {
662 process / "exe": Path("/bin/bash").resolve(),
663 process / "cwd": root,
664 process / "fd/255": script,
665 }
666 for link, target in links.items():
667 if not link.exists() and not link.is_symlink():
668 link.symlink_to(target)
669
670
671def _process_cases(guard: ModuleType, failures: list[str]) -> None:
672 """Attack Linux start-time, argv, and open-script process bindings."""
673 with tempfile.TemporaryDirectory(prefix="ra8-gdb-proc-", dir="/tmp") as directory:
674 root, script, _runtime = _workspace(Path(directory))
675 proc = Path(directory).resolve() / "proc"
676 proc.mkdir()
677 pid = 4242
678 ticks = 998877
679 _write_proc(proc, pid, root, script, ticks)
680 request = guard.BrokerRequest(
681 root, script, "2331", "", pid, proc_root=proc, platform="linux"
682 )
683 _case(
684 failures,
685 "valid Linux parent proof",
686 lambda: _api(guard, "_process_proof")(pid, request),
687 must_fail=False,
688 )
689 record = SimpleNamespace(value={"broker_pid": pid, "broker_start_ticks": ticks})
690 stat_path = proc / str(pid) / "stat"
691 stat_path.write_text(
692 f"{pid} (bash fixture) {' '.join(['S', *(['0'] * 18), str(ticks + 1)])}\n",
693 encoding="ascii",
694 )
695 if _api(guard, "_broker_live")(request, record):
696 failures.append("PID start-time reuse passed live identity proof")
697 _write_proc(proc, pid, root, script, ticks)
698 cmdline = proc / str(pid) / "cmdline"
699 cmdline.write_bytes(cmdline.read_bytes()[:-1] + b"forged\0")
700 _case(
701 failures,
702 "forged Linux parent argv",
703 lambda: _api(guard, "_process_proof")(pid, request),
704 must_fail=True,
705 )
706 _write_proc(proc, pid, root, script, ticks)
707 (proc / str(pid) / "fd/255").unlink()
708 _case(
709 failures,
710 "missing Linux script descriptor",
711 lambda: _api(guard, "_process_proof")(pid, request),
712 must_fail=True,
713 )
714
715
716def _schema_cases(guard: ModuleType, value: dict[str, object], failures: list[str]) -> None:
717 """Reject duplicate, extra, missing, Boolean, and wrong-type record fields."""
718 mutations = (
719 lambda item: item.update(version=True),
720 lambda item: item.update(parent_pid=True),
721 lambda item: item.update(parent_argv="not-a-list"),
722 lambda item: item.update(extra="field"),
723 lambda item: item.pop("nonce"),
724 )
725 for index, mutate in enumerate(mutations):
726 with tempfile.TemporaryDirectory(prefix="ra8-gdb-schema-", dir="/tmp") as directory:
727 _request_value, state_dir = _request(guard, Path(directory))
728 changed = dict(value)
729 mutate(changed)
730 _case(
731 failures,
732 f"record schema mutation {index}",
733 lambda changed=changed, state_dir=state_dir: _api(guard, "_publish_record")(
734 state_dir, changed
735 ),
736 must_fail=True,
737 )
738
739
740def _pidfd_order_case(guard: ModuleType, failures: list[str]) -> None:
741 """Model the old reuse bug, then prove capability acquisition occurs first."""
742 generation = [1]
743 authenticated = generation[0]
744 generation[0] = 2
745 opened = generation[0]
746 if authenticated == opened:
747 failures.append("negative PID-reuse control did not model different identities")
748 events: list[str] = []
749 request = guard.BrokerRequest(Path("/x"), Path("/x/s"), "2331", "", 42, platform="linux")
750
751 def open_capability(_request: object) -> tuple[Callable[[int], None], int]:
752 events.append("open")
753 return lambda _pid: None, os.open("/dev/null", os.O_RDONLY)
754
755 def reject(_pid: int, _request: object) -> object:
756 events.append("authenticate")
757 message = "modeled PID reuse"
758 raise guard.GuardError(message)
759
760 def live(_fd: int) -> bool:
761 return True
762
763 with (
764 mock.patch.object(guard, "_signal_authority", open_capability),
765 mock.patch.object(guard, "_process_proof", reject),
766 mock.patch.object(guard, "_pidfd_live", live),
767 ):
768 _case(
769 failures,
770 "pidfd-bound authentication reuse",
771 lambda: _api(guard, "_parent_authority")(
772 request, guard.BrokerHooks(getppid=lambda: 42)
773 ),
774 must_fail=True,
775 )
776 if events != ["open", "authenticate"]:
777 failures.append(f"pidfd ordering was not open-before-authenticate: {events!r}")
778
779
780def _shell_boundary(root: Path, failures: list[str]) -> None:
781 """Drive sanitizer and cwd checks through the exact production wrapper."""
782 script = root / "scripts/dev/remote_gdb_server.sh"
783 with tempfile.TemporaryDirectory(prefix="ra8-gdb-bash-func-", dir="/tmp") as directory:
784 marker = Path(directory) / "imported"
785 environment = {
786 "BASH_ENV": str(Path(directory) / "missing"),
787 "BASH_FUNC_cd%%": f'() {{ /usr/bin/touch {marker}; builtin cd "$@"; }}',
788 "BASH_FUNC_ra8_remote_gdb_probe%%": f"() {{ /usr/bin/touch {marker}; }}",
789 "HOME": directory,
790 "PATH": "/usr/bin:/bin",
791 }
792 control = _spawn_status(["/bin/bash", "-c", "ra8_remote_gdb_probe"], environment)
793 if control != 0 or not marker.exists():
794 failures.append("raw exported-function attack control was vacuous")
795 marker.unlink(missing_ok=True)
796 result = _spawn_status(["/bin/bash", "-p", str(script), "invalid-action"], environment)
797 if result == 0 or marker.exists():
798 failures.append("raw BASH_FUNC entry ran at privileged wrapper boundary")
799 previous = os.open(".", os.O_RDONLY)
800 try:
801 os.chdir(directory)
802 descendant = _spawn_status(
803 ["/bin/bash", "-p", str(script), "--boundary-selftest"], environment
804 )
805 finally:
806 os.fchdir(previous)
807 os.close(previous)
808 if descendant != 0 or marker.exists():
809 failures.append("production wrapper leaked a function to an ordinary Bash descendant")
810
811
812def _remote_program_cases(remote_helper: Path, root: Path, failures: list[str]) -> None:
813 """Run the remote supervisor selftest and reject PID-sweep regressions."""
814 remote = _load(remote_helper, "ra8_remote_gdb_remote")
815 if remote.selftest() != 0:
816 failures.append("remote direct-child supervisor selftest failed")
817 source = (root / "scripts/dev/remote_gdb_server.sh").read_text(encoding="ascii")
818 failures.extend(
819 f"remote PID-sweep residue remains: {forbidden}"
820 for forbidden in ("/proc/[0-9]*", "REMOTE_CLEANUP", "server_pids")
821 if forbidden in source
822 )
823
824
825def selftest(helper: Path, args_helper: Path, remote_helper: Path, root: Path) -> int:
826 """Run all offline both-direction tests without real signals or network."""
827 guard = _load(helper, "ra8_remote_gdb_guard")
828 args = _load(args_helper, "ra8_remote_gdb_args")
829 failures: list[str] = []
830 _host_cases(args, failures)
831 _transport_cases(args, failures)
832 _serialization_cases(args, failures)
833 _remote_cli_cases(args, failures)
834 _rig_contract_parity(args, root, failures)
835 _app_cases(args, root, failures)
836 record = _broker_cases(guard, failures)
837 _protocol_cases(guard, failures)
838 _stale_cases(guard, record, failures)
839 _filesystem_cases(guard, failures)
840 _platform_cases(guard, failures)
841 _process_cases(guard, failures)
842 _schema_cases(guard, record, failures)
843 _pidfd_order_case(guard, failures)
844 _shell_boundary(root, failures)
845 _remote_program_cases(remote_helper, root, failures)
846 if failures:
847 for failure in failures:
848 print(f" [FAIL] {failure}", file=sys.stderr)
849 return 1
850 print("remote_gdb_guard_selftest.py: PASS (transport/state/broker/platform/PID-reuse)")
851 return 0
852
853
854def main() -> int:
855 """Parse exact authorities and run the offline suite."""
856 parser = argparse.ArgumentParser(description=__doc__)
857 parser.add_argument("--helper", type=Path, required=True)
858 parser.add_argument("--args-helper", type=Path, required=True)
859 parser.add_argument("--remote-helper", type=Path, required=True)
860 parser.add_argument("--root", type=Path, required=True)
861 options = parser.parse_args()
862 return selftest(
863 options.helper.resolve(strict=True),
864 options.args_helper.resolve(strict=True),
865 options.remote_helper.resolve(strict=True),
866 options.root.resolve(strict=True),
867 )
868
869
870if __name__ == "__main__":
871 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298