-
Notifications
You must be signed in to change notification settings - Fork 52
Expand file tree
/
Copy pathvalidate-bundle
More file actions
executable file
·658 lines (606 loc) · 28.4 KB
/
Copy pathvalidate-bundle
File metadata and controls
executable file
·658 lines (606 loc) · 28.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
#!/bin/sh
# This file is part of KASLD - https://github.com/bcoles/kasld
#
# validate-bundle — inference-layer soundness check against a kasld bundle.
#
# Companion to extra/check-results (the live-system per-leak validator).
# This tool runs OFFLINE against a captured bundle (extra/collect output),
# extracts ground truth from the bundle's sysroot, runs the arch-correct
# kasld binary over the same sysroot, and asserts the engine's resolved
# ranges contain the truth — the soundness criterion that the ground-truth
# value must fall inside every inferred range (a window that excludes the
# truth is a soundness bug, not a tightness improvement).
#
# A bundle needs --kallsyms for full validation (otherwise the virt
# kernel-text truth is unavailable). A bundle without kallsyms still gets
# phys-text validation against /proc/iomem and shape-only checks for the
# other quantities.
#
# Usage:
# extra/validate-bundle <bundle-dir>
#
# Exit codes:
# 0 every observable quantity PASS or N/A (no truth)
# 1 at least one FAIL (soundness violation — file a bug)
# 2 bundle malformed / kasld binary missing
#
# Dependencies: jq, awk, sed. No root required (truth comes from the
# bundle's captured files, not /proc on the host).
# ---
# <bcoles@gmail.com>
set -u
PROG=$(basename "$0")
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
die() { echo "$PROG: $*" >&2; exit 2; }
PERTURB=0
while [ "$#" -gt 0 ]; do
case "$1" in
--perturb) PERTURB=1; shift ;;
--) shift; break ;;
-*) die "unknown option '$1'" ;;
*) break ;;
esac
done
if [ "$#" -ne 1 ]; then
cat <<EOF >&2
Usage: $PROG [--perturb] <bundle-dir>
Compare a kasld bundle's engine-resolved ranges against the ground truth
captured in the bundle's sysroot. Reports PASS/FAIL/N_A per quantity.
--perturb Truth-free mode: instead of truth-containment, assert the GUARANTEED
window does not move when a container-fakeable input is spoofed — a
container-faked value must not reach the guaranteed window. The fake
set is enumerated from the source audit (currently MemTotal/LowTotal,
the cgroup-reported memory size), exercised both with the trusted DRAM
extent present and masked. Needs no ground truth, so it runs on
anonymized bundles too.
EOF
exit 2
fi
BUNDLE=$1
[ -d "$BUNDLE" ] || die "bundle '$BUNDLE' is not a directory"
[ -d "$BUNDLE/sysroot" ] || die "bundle missing sysroot/"
[ -f "$BUNDLE/meta.txt" ] || die "bundle missing meta.txt"
command -v jq >/dev/null || die "jq not found (apt install jq)"
# ---------------------------------------------------------------------------
# Colours (TTY only)
# ---------------------------------------------------------------------------
if [ -t 1 ]; then
GREEN=$(printf '\033[0;32m')
RED=$(printf '\033[0;31m')
YELLOW=$(printf '\033[0;33m')
DIM=$(printf '\033[2m')
BOLD=$(printf '\033[1m')
RESET=$(printf '\033[0m')
else
GREEN='' RED='' YELLOW='' DIM='' BOLD='' RESET=''
fi
# Counters (mktemp so the pipeline subshells can update)
TMP=$(mktemp -d) || die "mktemp failed"
trap 'rm -rf "$TMP"' EXIT INT TERM
printf '0 0 0\n' > "$TMP/counts"
pass() { printf ' %sPASS%s %-22s %s\n' "$GREEN" "$RESET" "$1" "$2"; bump pass; }
fail() { printf ' %sFAIL%s %-22s %s\n' "$RED" "$RESET" "$1" "$2"; bump fail; }
nare() { printf ' %sN/A%s %-22s %s\n' "$YELLOW" "$RESET" "$1" "$2"; bump skip; }
bump() {
read -r p f s < "$TMP/counts"
case "$1" in
pass) p=$((p + 1)) ;;
fail) f=$((f + 1)) ;;
skip) s=$((s + 1)) ;;
esac
printf '%d %d %d\n' "$p" "$f" "$s" > "$TMP/counts"
}
# ---------------------------------------------------------------------------
# Hex helpers — normalise to 16-char lowercase for byte-wise comparison.
# ---------------------------------------------------------------------------
# Zero is an address, not an absence. Stripping leading zeros from an all-zero
# value leaves the empty string, and every caller tests the result with -n --
# so a legitimately-zero edge read as "not resolved". s390's static virtual
# window floor is 0, which is why nothing on that arch could ever be checked.
hex16() {
printf '%s\n' "$1" | tr 'A-F' 'a-f' | sed 's/^0x//; s/^0*//; s/^$/0/' \
| awk '{ printf "%016s\n", $0 }' | tr ' ' '0'
}
# String compare on 16-char zero-padded lowercase hex (the hex16 output).
# Forced via a "x" prefix so awk does not try to parse strings containing
# 'e' or 'd' as scientific notation, e.g. "000000002e133000" -> 2e133.
# With both operands equal-length lowercase hex, lexicographic order is
# numeric order ('0'..'9' < 'a'..'f' in ASCII).
hex_le() { awk -v a="x$1" -v b="x$2" 'BEGIN { exit !(a <= b) }'; }
hex_ge() { awk -v a="x$1" -v b="x$2" 'BEGIN { exit !(a >= b) }'; }
hex_in() { hex_ge "$1" "$2" && hex_le "$1" "$3"; }
# ---------------------------------------------------------------------------
# Locate the arch-correct kasld binary for this bundle. Use qemu-user when
# the bundle is for a foreign arch.
# ---------------------------------------------------------------------------
ARCH=$(awk -F: '/^uname_m_raw:/ { gsub(/[ \t]/, "", $2); print $2 }' "$BUNDLE/meta.txt")
REL=$(awk -F: '/^kernel_release:/ { gsub(/[ \t]/, "", $2); print $2 }' "$BUNDLE/meta.txt")
DISTRO=$(awk -F: '/^distro:/ { gsub(/[ \t]/, "", $2); print $2 }' "$BUNDLE/meta.txt")
HOST_ARCH=$(uname -m)
QEMU_DIR=${QEMU_DIR:-} # empty: resolve qemu-<arch> from PATH (as tests/replay)
# Reuse the same uname_m_raw → "<build-subdir> <qemu-binary>" mapping as
# tests/replay, so behaviour stays consistent.
case "$ARCH" in
x86_64) SUB=x86_64-linux-gnu; QBIN=qemu-x86_64 ;;
i686|i386) SUB=i686-unknown-linux-musl; QBIN=qemu-i386 ;;
aarch64) SUB=aarch64-linux-musl; QBIN=qemu-aarch64 ;;
armv7l|armv6l|armhf|arm) SUB=armv7-unknown-linux-musleabi; QBIN=qemu-arm ;;
# armv7b/armv6b is what a big-endian arm kernel reports for itself; armeb is
# the toolchain's name for the same target.
armeb | armv7b | armv6b) SUB=armeb-linux-musleabi; QBIN=qemu-armeb ;;
riscv64) SUB=riscv64-linux-musl; QBIN=qemu-riscv64 ;;
riscv32) SUB=riscv32-linux-musl; QBIN=qemu-riscv32 ;;
mips) SUB=mips-unknown-linux-musl; QBIN=qemu-mips ;;
mipsel) SUB=mipsel-unknown-linux-musl; QBIN=qemu-mipsel ;;
mips64) SUB=mips64-unknown-linux-musl; QBIN=qemu-mips64 ;;
mips64el) SUB=mips64el-unknown-linux-musl; QBIN=qemu-mips64el ;;
ppc|powerpc) SUB=powerpc-linux-musl; QBIN=qemu-ppc ;;
ppcle|powerpcle) SUB=powerpcle-unknown-linux-musl; QBIN= ;;
ppc64|powerpc64) SUB=powerpc64-unknown-linux-musl; QBIN=qemu-ppc64 ;;
ppc64le|powerpc64le) SUB=powerpc64le-unknown-linux-musl; QBIN=qemu-ppc64le ;;
s390x) SUB=s390x-ibm-linux-musl; QBIN=qemu-s390x ;;
loongarch64) SUB=loongarch64-unknown-linux-musl; QBIN=qemu-loongarch64 ;;
*) die "no build-subdir mapping for arch '$ARCH'" ;;
esac
# Resolve the binary by arch glob, not the exact triple: `make cross` (musl) and
# CI's cross-tools toolchains produce different full triples for the same arch
# (riscv32-linux-musl vs riscv32-unknown-linux-musl) that share the leading arch
# component. Mirrors tests/replay's build-dir resolution.
BIN=""
for _bd in "$ROOT/build/${SUB%%-*}"-*; do
[ -x "$_bd/kasld" ] && { BIN=$_bd/kasld; break; }
done
[ -n "$BIN" ] || die "kasld binary missing: build/${SUB%%-*}-*/kasld (run 'make cross')"
# Decide: native (host can run it directly) or qemu-user (foreign arch).
native_ok=0
case "$HOST_ARCH" in
x86_64|amd64) case "$ARCH" in x86_64|i686|i386) native_ok=1 ;; esac ;;
*) [ "$ARCH" = "$HOST_ARCH" ] && native_ok=1 ;;
esac
QEMU_NOTE=""
if [ "$native_ok" = 1 ]; then
RUNNER=""
else
[ -n "$QBIN" ] || die "no qemu-user binary exists for arch '$ARCH'; validate this bundle natively on a matching host"
# Prefer QEMU_DIR, fall back to PATH (CI installs qemu-user there, not in a
# QEMU_DIR) — same resolution as tests/replay.
if [ -x "$QEMU_DIR/$QBIN" ]; then
RUNNER="$QEMU_DIR/$QBIN"
else
RUNNER=$(command -v "$QBIN" 2>/dev/null)
fi
[ -n "$RUNNER" ] || die "foreign-arch bundle ($ARCH): qemu-user '$QBIN' not found (QEMU_DIR=$QEMU_DIR or PATH)"
QEMU_NOTE="qemu-user replay; component subprocesses don't exec under nested qemu — engine sees no leaks, windows reflect static arch tops"
fi
# ---------------------------------------------------------------------------
# Run kasld over the bundle's sysroot, capture the JSON.
# ---------------------------------------------------------------------------
JSON=$TMP/kasld.json
# Live-kernel probes (perf / timing side-channels / SIDT / mincore / mmap-brute
# / ioctl leaks, /proc/self, ...) obtain their result from the RUNNING kernel,
# not from a captured file, so on a native-arch replay they describe the HOST
# kernel and could push the engine's window off the bundle's true base. The
# binary excludes every such probe under KASLD_SYSROOT (they carry live:1 in
# .kasld_meta and the orchestrator filters them), which this tool always sets —
# so no --skip is needed. Every remaining component is a pure function of the
# captured files: deterministic, host-independent, and sound for a bundle
# captured on a different machine.
# run_kasld_sysroot SYSROOT OUT — run the arch-correct binary (native or under
# qemu-user) over an arbitrary sysroot.
run_kasld_sysroot() {
if [ -n "$RUNNER" ]; then
KASLD_SYSROOT="$1" KASLD_UNAME_RELEASE="$REL" \
"$RUNNER" "$BIN" -j -q > "$2" 2>/dev/null
else
KASLD_SYSROOT="$1" KASLD_UNAME_RELEASE="$REL" \
"$BIN" -j -q > "$2" 2>/dev/null
fi
}
# guaranteed_sig JSON — the sound-window layout fields as a stable signature,
# or EMPTY (no window resolved) / PARSE-ERROR (bad JSON).
guaranteed_sig() {
jq -e . "$1" >/dev/null 2>&1 || { printf 'PARSE-ERROR\n'; return; }
s=$(jq -r '[.layout.virt_image_base_min, .layout.virt_image_base_max,
.layout.phys_kaslr_text_min, .layout.phys_kaslr_text_max,
.kaslr.memory_kaslr.virt_page_offset_base.min,
.kaslr.memory_kaslr.virt_page_offset_base.max]
| map(if . == null then "." else tostring end) | join("|")' "$1")
case "$s" in
'.|.|.|.|.|.') printf 'EMPTY\n' ;;
*) printf '%s\n' "$s" ;;
esac
}
# The container-fakeable input set: files a container / lxcfs / cgroup can spoof
# AND that kasld reads into its inference. The source audit found MemTotal /
# LowTotal (a cgroup memory limit reports through them) is the ONLY one that can
# reach the GUARANTEED window — the other virtualised inputs are host-true where
# kasld uses them (cpuinfo flags select the VA layout; the cpu COUNT is not read
# into any bound) or feed only worker count / affinity, never a layout bound. So
# the fake set is {meminfo}, exercised on both paths a container can present it
# (see the two cases below). A future rule that reads another spoofable file into
# the window drops in here as another fake_* mutator + perturb_case.
# fake_meminfo DIR — shrink MemTotal/LowTotal, as a small memory cgroup reports.
fake_meminfo() {
sed -i -E 's/^(MemTotal:)[[:space:]]*[0-9]+/\1 65536/;
s/^(LowTotal:)[[:space:]]*[0-9]+/\1 65536/' \
"$1/proc/meminfo" 2>/dev/null || true
}
# perturb_case NAME REAL FAKE — run both sysroots and assert the guaranteed
# window is byte-identical. REAL and FAKE must differ ONLY in fakeable inputs, so
# any movement is a fakeable value reaching the guaranteed window (a soundness
# bug), not the legitimate widening of losing a trusted source.
perturb_case() {
pc_name=$1
run_kasld_sysroot "$2" "$TMP/pc-real.json"
run_kasld_sysroot "$3" "$TMP/pc-fake.json"
pc_sr=$(guaranteed_sig "$TMP/pc-real.json")
pc_sf=$(guaranteed_sig "$TMP/pc-fake.json")
case "$pc_sr" in
PARSE-ERROR) nare "$pc_name" "kasld produced no/invalid JSON on the real run" ;;
EMPTY) nare "$pc_name" "no guaranteed window resolved — nothing to move" ;;
*)
if [ "$pc_sr" = "$pc_sf" ]; then
pass "$pc_name" "guaranteed window unchanged under the fake"
else
fail "$pc_name" "guaranteed window MOVED under the fake"
printf ' real: %s\n faked: %s\n' "$pc_sr" "$pc_sf" >&2
fi ;;
esac
}
# --perturb: truth-free soundness — NO container-fakeable input may reach the
# GUARANTEED window (two-window model: it depends only on trusted, >= sound-floor
# inputs). kallsyms is removed first so the base is a window bounded by the
# ceiling (a pin would conflict-drop the bad ceiling and mask the bug). No ground
# truth needed — runs on anonymized bundles too. The binary skips live probes
# under KASLD_SYSROOT (live:1 in .kasld_meta), so each run is a deterministic
# function of the captured files.
if [ "$PERTURB" = 1 ]; then
printf '%sPerturbation:%s no container-fakeable input may move the guaranteed window\n' \
"$BOLD" "$RESET"
if ! grep -qE '^(MemTotal|LowTotal):' "$BUNDLE/sysroot/proc/meminfo" 2>/dev/null; then
nare "container fakes" "no MemTotal/LowTotal in bundle — nothing to fake"
else
# Case 1: fake meminfo with the trusted DRAM extent (zoneinfo) PRESENT — the
# ceiling rule must prefer it and ignore the faked meminfo.
R1=$TMP/c1-real; F1=$TMP/c1-fake
cp -a "$BUNDLE/sysroot" "$R1" || die "cp sysroot failed"
rm -f "$R1/proc/kallsyms"
cp -a "$R1" "$F1"; fake_meminfo "$F1"
perturb_case "fake meminfo" "$R1" "$F1"
# Case 2: fake meminfo with the trusted DRAM extent (zoneinfo) ALSO masked, as
# a stricter container might — forcing the meminfo fallback, which must stay
# likely-only (below the sound floor) so the guaranteed window still holds.
# Both sides lose zoneinfo equally, so the only difference remains meminfo.
R2=$TMP/c2-real; F2=$TMP/c2-fake
cp -a "$R1" "$R2"; rm -f "$R2/proc/zoneinfo"
cp -a "$R2" "$F2"; fake_meminfo "$F2"
perturb_case "fake meminfo (dram extent masked)" "$R2" "$F2"
fi
read -r p f s < "$TMP/counts"
printf '%sSummary: %d PASS, %d FAIL, %d N/A%s\n' "$BOLD" "$p" "$f" "$s" "$RESET"
[ "$f" -gt 0 ] && exit 1
exit 0
fi
# Don't gate on exit code: kasld returns the count of failed component
# subprocesses (sometimes >0 even on a healthy bundle — kasld_classify_outcome()
# maps "no_result"/"unavailable"/"timed_out" outcomes that feed stats.no_result
# and a non-zero summary on some paths, and under qemu-user every component
# fails to exec). The output JSON is what matters.
if [ -n "$RUNNER" ]; then
KASLD_SYSROOT="$BUNDLE/sysroot" KASLD_UNAME_RELEASE="$REL" \
"$RUNNER" "$BIN" -j -q > "$JSON" 2>"$TMP/err"
else
KASLD_SYSROOT="$BUNDLE/sysroot" KASLD_UNAME_RELEASE="$REL" \
"$BIN" -j -q > "$JSON" 2>"$TMP/err"
fi
if [ ! -s "$JSON" ]; then
echo "$PROG: kasld produced empty JSON" >&2
[ -s "$TMP/err" ] && { echo "stderr (first lines):" >&2; head -5 "$TMP/err" >&2; }
exit 2
fi
# Catch the case where JSON exists but is unparsable.
jq empty "$JSON" 2>/dev/null || die "kasld JSON is malformed"
# ---------------------------------------------------------------------------
# Extract ground truth from the bundle.
# ---------------------------------------------------------------------------
KS=$BUNDLE/sysroot/proc/kallsyms
IM=$BUNDLE/sysroot/proc/iomem
truth_stext=""
truth_text=""
truth_end=""
truth_text_src=""
if [ -s "$KS" ]; then
truth_stext=$(awk '$3 == "_stext" { print $1 }' "$KS")
truth_text=$(awk '$3 == "_text" { print $1 }' "$KS")
truth_end=$(awk '$3 == "_end" { print $1 }' "$KS")
case "$truth_stext" in
0000000000000000|00000000|"") truth_stext="" ;;
esac
case "$truth_text" in
0000000000000000|00000000|"") truth_text="" ;;
esac
fi
# A capture may record the runtime _text directly, for kernels that do not
# export it in kallsyms -- mips exports only _stext, at any privilege, so no
# amount of root recovers it from that file. The recording harness reads it from
# the booted kernel's own symbol table.
#
# It must come from the kernel's symbols and NOT be reconstructed from an
# address KASLD reports. The window this truth is checked against is built from
# the same arch constants a reconstruction would use -- project the physical
# _text from iomem through PAGE_OFFSET, say, and PAGE_OFFSET cancels against a
# window anchored on it, leaving a comparison that holds for any kernel ever
# built. A truth that cannot fail is worse than no truth: it reports as coverage.
#
# So the field is trusted only as far as its stated source, which is printed
# beside it.
if [ -z "$truth_text" ] && [ -s "$BUNDLE/meta.txt" ]; then
_oob=$(awk -F: '/^truth_virt_text:/ { gsub(/[ \t]/, "", $2); print $2 }' \
"$BUNDLE/meta.txt" 2>/dev/null | head -1)
case "$_oob" in
0x* | 0X*) _oob=${_oob#0[xX]} ;;
esac
case "$_oob" in
"" | *[!0-9a-fA-F]*) : ;;
*)
case "$_oob" in
*[1-9a-fA-F]*)
truth_text=$_oob
truth_text_src=$(awk -F: '/^truth_virt_text_source:/ {
sub(/^[^:]*:[ \t]*/, ""); print }' "$BUNDLE/meta.txt" 2>/dev/null |
head -1)
[ -n "$truth_text_src" ] || truth_text_src="source not stated"
;;
esac
;;
esac
fi
# _text is the quantity kasld solves and the one its windows bound. _stext is a
# DIFFERENT symbol: it sits a per-arch head gap above _text -- 0x400 on mips,
# 0x10000 on arm64, 0x20000 on loongarch64 -- and coincides with it only where
# that gap is zero.
#
# So _stext is not a fallback for _text. Substituting it compared a _stext truth
# against a _text answer, which reads as an off-by-head-gap mismatch on exactly
# the arches whose kallsyms exports only _stext (mips does: every recorded boot
# has _stext and no _text). Where the gap is zero the two genuinely are one
# address, and that case is restored below. Where it is not, the virtual check
# needs the recorded truth above; without it the bundle reports N/A, which is the
# honest answer -- nothing here reconstructs _text from _stext, because the gap
# is a per-build property and STEXT_OFFSET is a fallback constant, not this
# build's fact.
truth_vtext=$truth_text
[ -n "$truth_vtext" ] && truth_vtext=$(hex16 "$truth_vtext")
[ -n "$truth_stext" ] && truth_stext=$(hex16 "$truth_stext")
[ -n "$truth_end" ] && truth_end=$(hex16 "$truth_end")
truth_ptext=""
if [ -s "$IM" ]; then
# First "Kernel code" line — format: " lo-hi : Kernel code"
line=$(grep ' Kernel code$' "$IM" 2>/dev/null | head -1)
if [ -n "$line" ]; then
lo=$(printf '%s' "$line" | sed 's/^ *//' | cut -d'-' -f1)
truth_ptext=$(hex16 "$lo")
# An unprivileged read of /proc/iomem reports every range as 0-0. The
# kernel is not at physical 0 on any supported arch, so an all-zero start
# is a masked field rather than a base -- the same reading the kallsyms
# truth above gives a zeroed address.
case "$truth_ptext" in
0000000000000000) truth_ptext="" ;;
esac
fi
fi
# ---------------------------------------------------------------------------
# Print bundle / truth summary.
# ---------------------------------------------------------------------------
printf '%sBundle:%s %s\n' "$BOLD" "$RESET" "$BUNDLE"
printf ' arch=%s distro=%s kernel=%s\n' "$ARCH" "$DISTRO" "$REL"
printf ' binary: %s\n' "$BIN"
if [ -n "$QEMU_NOTE" ]; then
printf ' %srunner: %s%s\n' "$YELLOW" "$QEMU_NOTE" "$RESET"
fi
echo
printf '%sGround truth:%s\n' "$BOLD" "$RESET"
if [ -n "$truth_vtext" ] && [ -n "$truth_text_src" ]; then
printf ' virt _text = 0x%s (recorded: %s)\n' "$truth_vtext" "$truth_text_src"
elif [ -n "$truth_vtext" ]; then
printf ' virt _text = 0x%s\n' "$truth_vtext"
elif [ -n "$truth_stext" ]; then
printf ' virt _text = %s(kallsyms exports _stext 0x%s, not _text)%s\n' \
"$DIM" "$truth_stext" "$RESET"
else
printf ' virt _text = %s(unavailable — no --kallsyms?)%s\n' "$DIM" "$RESET"
fi
if [ -n "$truth_ptext" ]; then
printf ' phys text = 0x%s (iomem "Kernel code" start)\n' "$truth_ptext"
else
printf ' phys text = %s(unavailable — kptr_restrict?)%s\n' "$DIM" "$RESET"
fi
echo
# ---------------------------------------------------------------------------
# Vantage completeness.
#
# Not a soundness check, and deliberately not counted: a missing vantage source
# does not move an inferred range. It is reported because its absence is SILENT
# in the analysis -- kasld says "unknown", or in the container line "none", and
# a reader has no way to tell a source that answered from one the bundle never
# carried. Which is the same question the bundle exists to settle for the
# layout, asked about the observer.
#
# Presence of the FIELD, not just the file: a status file stripped of its Uid:
# line degrades exactly as a missing one does, and a bundle assembled by hand
# (or scrubbed harder than extra/collect scrubs) is where that happens.
# ---------------------------------------------------------------------------
SR=$BUNDLE/sysroot
vant_have() { printf ' %-13s %s%s%s\n' "$1" "$GREEN" "$2" "$RESET"; }
vant_miss() { printf ' %-13s %s(absent) %s%s\n' "$1" "$YELLOW" "$2" "$RESET"; }
printf '%sVantage sources:%s\n' "$BOLD" "$RESET"
if [ -s "$SR/proc/self/status" ] && grep -q '^Uid:' "$SR/proc/self/status"; then
vu=$(awk '$1 == "Uid:" { print $2 }' "$SR/proc/self/status")
vg=$(awk '$1 == "Gid:" { print $2 }' "$SR/proc/self/status")
ng=$(awk '$1 == "Groups:" { print NF - 1 }' "$SR/proc/self/status")
vant_have identity "uid=${vu:-?} gid=${vg:-?}, ${ng:-0} group(s)"
else
vant_miss identity "— the report cannot name the uid, gid or groups it ran as"
fi
if [ -s "$SR/proc/self/attr/current" ]; then
vant_have "mac label" "$(tr -d '\0\n' < "$SR/proc/self/attr/current")"
else
vant_miss "mac label" "— no security context row"
fi
if [ -s "$SR/sys/kernel/security/lsm" ]; then
vant_have "lsm list" "$(tr -d '\0\n' < "$SR/sys/kernel/security/lsm")"
else
vant_miss "lsm list" "— LSM reports as unknown, which is not 'no LSM'"
fi
if [ -s "$SR/etc/group" ]; then
vant_have "group names" "$(grep -c ':' "$SR/etc/group") entries"
else
vant_miss "group names" "— gids report by number unless kasld gates on them"
fi
# Container detection reads four sources and concludes from ALL of them; a
# bundle carrying none of them reports "Container: none", which is the answer
# for a host that is not containerized. Absence is the misleading case here,
# so the line is about the SET, not any one file.
if [ -e "$SR/.dockerenv" ] || [ -e "$SR/run/.containerenv" ] ||
[ -s "$SR/proc/self/cgroup" ] || [ -s "$SR/proc/1/cgroup" ]; then
vant_have container "detectable from the captured markers/cgroups"
else
vant_miss container "— reports 'none', indistinguishable from not captured"
fi
echo
# ---------------------------------------------------------------------------
# Pull engine-resolved ranges from JSON.
# ---------------------------------------------------------------------------
get() { jq -r "$1 // empty" "$JSON" 2>/dev/null; }
vmin=$(get '.layout.virt_image_base_min')
vmax=$(get '.layout.virt_image_base_max')
vtext=$(get '.kaslr.virtual.image_base')
# Emitted only when it differs from image_base: absent means a zero head gap.
vstext=$(get '.kaslr.virtual.stext')
pmin=$(get '.layout.phys_kaslr_text_min')
pmax=$(get '.layout.phys_kaslr_text_max')
ptext=$(get '.kaslr.physical.image_base')
[ -n "$vmin" ] && vmin=$(hex16 "$vmin")
[ -n "$vmax" ] && vmax=$(hex16 "$vmax")
[ -n "$vtext" ] && vtext=$(hex16 "$vtext")
[ -n "$vstext" ] && vstext=$(hex16 "$vstext")
# No _text truth, a _stext one, and a zero head gap: the symbols are the same
# address, so the _stext truth IS the _text truth and the checks below can use
# it. A non-zero gap gets its own check rather than being folded in here.
if [ -z "$truth_vtext" ] && [ -n "$truth_stext" ] && [ -z "$vstext" ] &&
[ -n "$vtext" ]; then
truth_vtext=$truth_stext
fi
[ -n "$pmin" ] && pmin=$(hex16 "$pmin")
[ -n "$pmax" ] && pmax=$(hex16 "$pmax")
[ -n "$ptext" ] && ptext=$(hex16 "$ptext")
# ---------------------------------------------------------------------------
# Soundness checks.
# ---------------------------------------------------------------------------
printf '%sSoundness:%s\n' "$BOLD" "$RESET"
# Virt KASLR window
if [ -n "$vmin" ] && [ -n "$vmax" ]; then
if [ -n "$truth_vtext" ]; then
if hex_in "$truth_vtext" "$vmin" "$vmax"; then
pass "virt kaslr window" "[0x$vmin, 0x$vmax] ∋ 0x$truth_vtext"
else
fail "virt kaslr window" "[0x$vmin, 0x$vmax] ∌ 0x$truth_vtext"
fi
else
if [ -n "$truth_stext" ]; then
nare "virt kaslr window" "kallsyms exports _stext, not _text (window: [0x$vmin, 0x$vmax])"
else
nare "virt kaslr window" "no kallsyms truth (window: [0x$vmin, 0x$vmax])"
fi
fi
else
nare "virt kaslr window" "no resolved window in JSON"
fi
# Virt text-base resolved point (when a leak pinned it)
if [ -n "$vtext" ] && [ -n "$truth_vtext" ]; then
if [ "$vtext" = "$truth_vtext" ]; then
pass "virt image_base" "exact match 0x$vtext"
elif [ -n "$truth_end" ] && hex_in "$vtext" "$truth_vtext" "$truth_end"; then
pass "virt image_base" "0x$vtext within kernel image [0x$truth_vtext, 0x$truth_end]"
else
fail "virt image_base" "resolved 0x$vtext ≠ truth 0x$truth_vtext"
fi
elif [ -n "$vtext" ] && [ -n "$truth_stext" ]; then
nare "virt image_base" "resolved 0x$vtext (kallsyms exports _stext, not _text)"
elif [ -n "$vtext" ]; then
nare "virt image_base" "resolved 0x$vtext (no truth)"
fi
# No _stext check here, deliberately. kasld reports the OBSERVED _stext symbol
# when kallsyms carries one and only falls back to image base + STEXT_OFFSET
# otherwise, so on a bundle whose truth came from kallsyms the two sides have
# the same origin and the comparison is circular -- it passed unchanged with
# STEXT_OFFSET deliberately set wrong. What does validate the resolution on
# these arches is `phys image_base` below, whose truth comes from iomem and is
# independent of kallsyms.
# Phys KASLR window
if [ -n "$pmin" ] && [ -n "$pmax" ]; then
if [ -n "$truth_ptext" ]; then
if hex_in "$truth_ptext" "$pmin" "$pmax"; then
pass "phys kaslr window" "[0x$pmin, 0x$pmax] ∋ 0x$truth_ptext"
else
fail "phys kaslr window" "[0x$pmin, 0x$pmax] ∌ 0x$truth_ptext"
fi
else
nare "phys kaslr window" "no iomem truth (window: [0x$pmin, 0x$pmax])"
fi
else
nare "phys kaslr window" "no resolved window (coupled arch or no narrowing)"
fi
# Phys text-base resolved point
if [ -n "$ptext" ] && [ -n "$truth_ptext" ]; then
if [ "$ptext" = "$truth_ptext" ]; then
pass "phys image_base" "exact match 0x$ptext"
else
# An aligned-down base sitting at-or-just-below iomem 'Kernel code' start
# is acceptable (KASLR aligns to PHYSICAL_ALIGN; _stext can be offset).
if hex_le "$ptext" "$truth_ptext"; then
pass "phys image_base" "0x$ptext ≤ truth 0x$truth_ptext (aligned base)"
else
fail "phys image_base" "resolved 0x$ptext ≠ truth 0x$truth_ptext"
fi
fi
elif [ -n "$ptext" ]; then
nare "phys image_base" "resolved 0x$ptext (no truth)"
fi
# RANDOMIZE_MEMORY region windows (direct map / vmalloc / vmemmap). Truth for
# each region base is the variable's VALUE, captured from /proc/kcore into the
# kcore-region-truth frame (a few bytes — never kcore itself). Absent on arches
# without CONFIG_RANDOMIZE_MEMORY, or from a bundle captured before this frame
# existed, in which case the region is N/A (or silent when kasld resolves no
# window for it either). A region base outside its resolved window is a
# soundness bug, same criterion as the text base.
RT=$BUNDLE/sysroot/kcore-region-truth
for _reg in page_offset:page_offset_base vmalloc:vmalloc_base vmemmap:vmemmap_base; do
_rlabel=${_reg%%:*}; _rsym=${_reg#*:}
_rtruth=""
[ -s "$RT" ] && _rtruth=$(sed -n "s/^region_truth $_rsym = 0x\([0-9a-fA-F]*\).*/\1/p" "$RT" | head -1)
_rwmin=$(get ".kaslr.memory_kaslr.virt_${_rsym}.min")
_rwmax=$(get ".kaslr.memory_kaslr.virt_${_rsym}.max")
if [ -n "$_rwmin" ] && [ -n "$_rwmax" ]; then
_rwmin=$(hex16 "$_rwmin"); _rwmax=$(hex16 "$_rwmax")
if [ -n "$_rtruth" ]; then
_rtruth=$(hex16 "$_rtruth")
if hex_in "$_rtruth" "$_rwmin" "$_rwmax"; then
pass "$_rlabel window" "[0x$_rwmin, 0x$_rwmax] ∋ 0x$_rtruth"
else
fail "$_rlabel window" "[0x$_rwmin, 0x$_rwmax] ∌ 0x$_rtruth"
fi
else
nare "$_rlabel window" "no kcore region truth (window: [0x$_rwmin, 0x$_rwmax])"
fi
elif [ -n "$_rtruth" ]; then
nare "$_rlabel window" "truth 0x$(hex16 "$_rtruth") but no resolved window"
fi
done
echo
read -r p f s < "$TMP/counts"
total=$((p + f + s))
printf '%sSummary: %d PASS, %d FAIL, %d N/A (%d checks)%s\n' \
"$BOLD" "$p" "$f" "$s" "$total" "$RESET"
[ "$f" -gt 0 ] && exit 1
exit 0