Skip to content

Commit 45d74ee

Browse files
committed
components: structured disposition to replace skip-reason
The free-text `R <text>` skip-reason was captured but never surfaced, and only 8 components emitted it. Replace it with a structured disposition, `R cat=<category> [gate=<token>] [msg="<text>"]`, in a closed vocabulary (mitigation / absent / disabled / inconclusive) that refines the exit code. Typed emitters return the exit code the category implies, so the two channels cannot disagree; a mitigation names the control it confirmed. Surface it per-component in JSON, as a mitigation digest in default text and markdown, in full under --verbose, and as confirmed_mitigations in the hardening report. posture-diff now flags a confirmed mitigation that stopped blocking a leak; posture-summary gains a defenses column. Add a parse_disposition fuzz harness (and fix an undefined LINE_LEN in two sibling harnesses). Update CONTRIBUTING and the usage/architecture docs.
1 parent 51693b5 commit 45d74ee

27 files changed

Lines changed: 587 additions & 131 deletions

CONTRIBUTING.md

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,11 @@ protocol — the field grammar, a field-by-field anatomy of a line, and the
6363
region/confidence vocabularies — is documented in
6464
[docs/architecture.md → The tagged-line protocol](docs/architecture.md#the-tagged-line-protocol).
6565

66-
The orchestrator ignores any line that does not begin with `P` or `V` followed
67-
by a space, so a component can freely print diagnostic messages (progress,
68-
errors, explanations). A component may emit zero, one, or multiple tagged lines.
66+
Beyond the address records (`P`/`V`), two more tagged kinds share the channel —
67+
scalar facts (`S`) and dispositions (`R`); the orchestrator parses all three and
68+
ignores anything else, so a component can freely print diagnostic messages
69+
(progress, errors, explanations). A component may emit zero, one, or multiple
70+
tagged lines.
6971

7072
### Position vs. confidence
7173

@@ -182,12 +184,31 @@ failed to randomize) emits scalar facts via `kasld_emit_scalar()` instead of an
182184
address; which facts, and how the engine consumes each, are documented in
183185
[docs/architecture.md → KASLR runtime states](docs/architecture.md#kaslr-runtime-states).
184186

185-
A leak or probe that determines it cannot run — or ran and found nothing worth
186-
attributing to a specific gate — calls `kasld_skip_reason(text)`, which emits a
187-
short `R` skip-reason line (e.g. "KPTI enabled", "not an Intel CPU"). The
188-
orchestrator captures it onto the per-component log; it is recorded metadata,
189-
never engine evidence, and is orthogonal to the exit code: the code names the
190-
outcome class, the reason names the specific gate within it.
187+
A leak or probe that ends without a tagged result can report *why* with a
188+
**disposition** — a short `R` line in a closed category that refines the exit
189+
code (recorded metadata, never engine evidence). The category carries the
190+
distinction the exit code cannot: an unavailable technique blocked by a
191+
*defensive control on the target* versus one that merely lacks a *prerequisite
192+
on this host*, and an empty run that is a deliberate opt-out versus an honest
193+
"ran, no clean signal, cannot prove why". The typed emitters emit the line and
194+
return the exit code the category implies, so the two channels cannot disagree:
195+
196+
| Emitter | Meaning | Returns |
197+
|---|---|---|
198+
| `kasld_disp_mitigation(gate, msg)` | A defensive control foiled it; `gate` names the control (`kpti`, a `CONFIG_` id, a CVE id) and is **required** | `KASLD_EXIT_UNAVAILABLE` |
199+
| `kasld_disp_mitigation_denied(gate, msg)` | A control *denied the source* (the access-denied variant) | `KASLD_EXIT_NOPERM` |
200+
| `kasld_disp_absent(msg)` | An attacker prerequisite is missing on this host | `KASLD_EXIT_UNAVAILABLE` |
201+
| `kasld_disp_disabled(msg)` | Deliberate operator opt-out (needs a flag/env) | `KASLD_EXIT_UNAVAILABLE` |
202+
| `kasld_disp_inconclusive(msg)` | Ran, no clean signal, cannot prove why | `0` |
203+
204+
Use `kasld_disposition(cat, gate, msg)` (no return value) where the exit code is
205+
decided elsewhere — inside a helper or a loop. A mitigation with no gate is a
206+
bug and emits nothing. Emit a disposition only when it soundly classifies the
207+
null result beyond the exit code — above all, a confirmed mitigation; a
208+
component that merely found no matching entry emits nothing. A `mitigation`
209+
disposition is confirmed active in the hardening report; the mitigation category
210+
is surfaced in default output, all categories under `--verbose`, and each
211+
disposition per-component in JSON.
191212

192213
### Diagnostics and options
193214

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,7 +718,7 @@ test-engine : $(TEST_ENG_BIN)
718718
FUZZ_CC ?= clang
719719
FUZZ_CFLAGS ?= -O1 -g -fsanitize=fuzzer,address,undefined -DKASLD_TESTING -I src
720720
FUZZ_OUT := $(BUILD_DIR)/fuzz
721-
FUZZ_TARGETS := fuzz_parse_hex fuzz_capture_result fuzz_capture_scalar fuzz_parse_meta fuzz_btf
721+
FUZZ_TARGETS := fuzz_parse_hex fuzz_capture_result fuzz_capture_scalar fuzz_parse_meta fuzz_parse_disposition fuzz_btf
722722
FUZZ_BINS := $(addprefix $(FUZZ_OUT)/,$(FUZZ_TARGETS))
723723

724724
$(FUZZ_OUT)/% : tests/fuzz/%.c

docs/architecture.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -407,10 +407,12 @@ V vmalloc pos=interior conf=heuristic sample=0xffffc90000123456
407407
P ram pos=extent conf=parsed lo=0x100000 hi=0x7fedffff
408408
```
409409

410-
The orchestrator ignores any line that does not begin with `P` or `V` followed
411-
by a space, so components can freely print diagnostics. A component may emit
412-
zero, one, or multiple tagged lines, and it never writes the format by hand — it
413-
calls one of the five emitter helpers (see
410+
Address records begin with `P` or `V`; two further tagged kinds share the
411+
channel — `S` (a scalar system fact, via `kasld_emit_scalar()`) and `R` (a
412+
component *disposition*: why it produced no tagged result, via the disposition
413+
emitters). Any other line is a diagnostic and ignored, so components can print
414+
freely. A component may emit zero, one, or multiple tagged lines, and it never
415+
writes the format by hand — it calls one of the emitter helpers (see
414416
[Emitter API](../CONTRIBUTING.md#emitter-api)), which produce the correct shape
415417
and reject malformed inputs at the source.
416418

docs/usage.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,9 +427,11 @@ The `environment` object is the recon vantage: `container`, `seccomp`,
427427
leak sources (fields are a `null` or enum when they do not apply).
428428

429429
The `components` array holds one record per component — `name`,
430-
`exit_code`, `outcome`, and the parsed `meta` from
431-
`KASLD_META` (including `cve` / `patch` / `config` / `sysctl` keys). The
432-
`hardening` object is described under
430+
`exit_code`, `outcome`, an optional `disposition` (why a component produced no
431+
tagged result: `category``mitigation` / `absent` / `disabled` /
432+
`inconclusive` — plus, for a mitigation, the `gate` it confirmed and an optional
433+
`message`), and the parsed `meta` from `KASLD_META` (including `cve` / `patch` /
434+
`config` / `sysctl` keys). The `hardening` object is described under
433435
[Hardening assessment](#hardening-assessment).
434436
A per-component patch worklist — `{component, cve, fixed_in, leaked_here}`
435437
— is a direct projection of the `components` array:
@@ -489,7 +491,11 @@ $ ./kasld --explain
489491

490492
The `--hardening` (`-H`) flag appends a post-run hardening assessment that
491493
evaluates the system's KASLR defenses based on the component results and
492-
their machine-readable metadata. The assessment has seven sections:
494+
their machine-readable metadata. It opens with **Confirmed active mitigations**
495+
(shown when present) — controls a component observed to defeat its leak this
496+
run, keyed by the gate (`kpti`, an MDS hardware fix, a hardening `CONFIG`); this
497+
is the runtime-observed complement to the sysctl gates, and appears in json as
498+
`hardening.confirmed_mitigations`. It is followed by seven analysis sections:
493499

494500
1. **KASLR posture** (only when degraded) — surfaces a runtime KASLR
495501
state that downgrades effective slot entropy to 0 bits. Fires on

extra/posture-diff

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ posture='{
9595
guar_bits: (.kaslr.inferred.entropy_bits // 0),
9696
pguar_bits: (.kaslr.inferred_physical.entropy_bits // 0),
9797
unpatched: ([(.hardening.patched_vulnerabilities.possibly_unpatched // [])[].component] | sort),
98-
defenses_off: ([(.hardening.active_defenses // [])[] | select(.active == false) | .gate] | sort)
98+
defenses_off: ([(.hardening.active_defenses // [])[] | select(.active == false) | .gate] | sort),
99+
confirmed: ([(.hardening.confirmed_mitigations // [])[].gate] | sort | unique)
99100
}'
100101

101102
b_posture=$(jq "$posture" "$base") || exit 2
@@ -116,7 +117,12 @@ findings=$(jq -rn --argjson b "$b_posture" --argjson c "$c_posture" '
116117
then "KASLR posture worsened: \($b.kaslr) -> \($c.kaslr)"
117118
else empty end ),
118119
( ($c.unpatched - $b.unpatched)[] | "new CVE-class leak succeeded: \(.)" ),
119-
( ($c.defenses_off - $b.defenses_off)[] | "defense turned off: \(.)" )
120+
( ($c.defenses_off - $b.defenses_off)[] | "defense turned off: \(.)" ),
121+
# A control confirmed to defeat a leak at baseline no longer does — a
122+
# non-sysctl defense (KPTI, an MDS hardware fix, a hardening CONFIG) has
123+
# weakened. These come from deterministic checks, so a drop is a real change,
124+
# not run-to-run noise.
125+
( ($b.confirmed - $c.confirmed)[] | "confirmed mitigation no longer blocking a leak: \(.)" )
120126
') || exit 2
121127

122128
if [ -n "$findings" ]; then

extra/posture-summary

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ row='{
9696
pbits: (.kaslr.inferred_physical.entropy_bits // 0),
9797
leaks: (.hardening.exposure.succeeded // 0),
9898
total: (.hardening.exposure.total // 0),
99+
defenses: ([(.hardening.confirmed_mitigations // [])[].gate] | unique | length),
99100
cves: ((.hardening.patched_vulnerabilities.possibly_unpatched // []) | length),
100101
topfix: ((.hardening.available_hardening // []) | (if length > 0 then .[0].action else "-" end))
101102
}'
@@ -120,7 +121,7 @@ done
120121
exit 2
121122
}
122123

123-
HDR="host arch kernel kaslr vbits pbits leaks cves top-fix"
124+
HDR="host arch kernel kaslr vbits pbits leaks defenses cves top-fix"
124125

125126
case "$fmt" in
126127
json)
@@ -129,19 +130,19 @@ json)
129130
csv)
130131
echo "$HDR" | tr ' ' ','
131132
jq -rs 'sort_by(.host)[] | [.host, .arch, .kernel, .kaslr, .vbits, .pbits,
132-
"\(.leaks)/\(.total)", .cves, .topfix] | @csv' "$tmp"
133+
"\(.leaks)/\(.total)", .defenses, .cves, .topfix] | @csv' "$tmp"
133134
;;
134135
markdown)
135136
echo "$HDR" | sed 's/ / | /g; s/^/| /; s/$/ |/'
136137
echo "$HDR" | sed 's/[^ ]*/---/g; s/ / | /g; s/^/| /; s/$/ |/'
137-
jq -rs 'sort_by(.host)[] | "| \(.host) | \(.arch) | \(.kernel) | \(.kaslr) | \(.vbits) | \(.pbits) | \(.leaks)/\(.total) | \(.cves) | \(.topfix) |"' "$tmp"
138+
jq -rs 'sort_by(.host)[] | "| \(.host) | \(.arch) | \(.kernel) | \(.kaslr) | \(.vbits) | \(.pbits) | \(.leaks)/\(.total) | \(.defenses) | \(.cves) | \(.topfix) |"' "$tmp"
138139
;;
139140
text)
140141
# Header + rows as tab-separated, then align each column to its widest cell.
141142
{
142143
echo "$HDR" | tr ' ' '\t'
143144
jq -rs 'sort_by(.host)[] | [.host, .arch, .kernel, .kaslr, "\(.vbits)b",
144-
"\(.pbits)b", "\(.leaks)/\(.total)", .cves, .topfix] | @tsv' "$tmp"
145+
"\(.pbits)b", "\(.leaks)/\(.total)", .defenses, .cves, .topfix] | @tsv' "$tmp"
145146
} | awk -F'\t' '
146147
{ for (i = 1; i <= NF; i++) { c[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } nc = NF; nr = NR }
147148
END { for (r = 1; r <= nr; r++) { line = ""

src/components/databounce.c

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,13 @@ int main(void) {
171171
if (!is_intel_cpu()) {
172172
fprintf(stderr,
173173
"[-] databounce: not an Intel CPU; attack not applicable\n");
174-
kasld_skip_reason("not an Intel CPU");
175-
return KASLD_EXIT_UNAVAILABLE;
174+
return kasld_disp_absent("not an Intel CPU");
176175
}
177176

178177
if (!has_rtm()) {
179178
fprintf(stderr, "[-] databounce: TSX/RTM not available; "
180179
"required for store-to-load forwarding\n");
181-
kasld_skip_reason("TSX/RTM not available");
182-
return KASLD_EXIT_UNAVAILABLE;
180+
return kasld_disp_absent("TSX/RTM not available");
183181
}
184182

185183
fprintf(stderr, "[.] databounce: using TSX abort mode\n");
@@ -221,8 +219,8 @@ int main(void) {
221219
if (!addr || best_count < DATABOUNCE_SWEEPS / 4) {
222220
fprintf(stderr, "[-] databounce: no kernel mapping detected "
223221
"(CPU may not be vulnerable)\n");
224-
kasld_skip_reason("no kernel mapping (CPU may not be vulnerable)");
225-
return 0;
222+
return kasld_disp_inconclusive(
223+
"no kernel mapping (CPU may not be vulnerable)");
226224
}
227225

228226
/* Same trampoline-vs-stext distinction as echoload: KPTI changes

src/components/echoload.c

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,12 @@ int main(void) {
225225
if (!getenv("KASLD_EXPERIMENTAL")) {
226226
fprintf(stderr, "[-] echoload: experimental component; "
227227
"set KASLD_EXPERIMENTAL=1 to enable\n");
228-
kasld_skip_reason("experimental (set KASLD_EXPERIMENTAL=1)");
229-
return KASLD_EXIT_UNAVAILABLE;
228+
return kasld_disp_disabled("experimental (set KASLD_EXPERIMENTAL=1)");
230229
}
231230

232231
if (!is_intel_cpu()) {
233232
fprintf(stderr, "[-] echoload: not an Intel CPU; attack not applicable\n");
234-
kasld_skip_reason("not an Intel CPU");
235-
return KASLD_EXIT_UNAVAILABLE;
233+
return kasld_disp_absent("not an Intel CPU");
236234
}
237235

238236
int use_tsx;
@@ -280,18 +278,17 @@ int main(void) {
280278
if (!addr) {
281279
fprintf(stderr, "[-] echoload: no kernel mapping detected "
282280
"(CPU may not be vulnerable)\n");
283-
kasld_skip_reason("no kernel mapping (CPU may not be vulnerable)");
284-
return 0;
281+
return kasld_disp_inconclusive(
282+
"no kernel mapping (CPU may not be vulnerable)");
285283
}
286284

287285
/* Unanimity verification: repeat the full sweep and require every result to
288286
* match. If any sweep disagrees, the result is rejected entirely. */
289287
for (int v = 0; v < ECHOLOAD_VERIFY; v++) {
290288
if (addr != echoload_sweep(use_tsx)) {
291289
fprintf(stderr, "[-] echoload: inconsistent results. Aborting ...\n");
292-
kasld_skip_reason(
290+
return kasld_disp_inconclusive(
293291
"inconsistent results (noise); a quieter run may resolve");
294-
return 0;
295292
}
296293
}
297294

src/components/entrybleed.c

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,7 @@ static int detect_kernel_version(void) {
273273

274274
if (strstr(u.machine, "64") == NULL) {
275275
kasld_err("system is not using a 64-bit kernel");
276-
kasld_skip_reason("not a 64-bit kernel");
277-
exit(KASLD_EXIT_UNAVAILABLE);
276+
exit(kasld_disp_absent("not a 64-bit kernel"));
278277
}
279278

280279
snprintf(kernel_version, KERNEL_VERSION_SIZE_BUFFER, "%s %s", u.release,
@@ -289,7 +288,8 @@ static int detect_kernel_version(void) {
289288
}
290289

291290
kasld_err("kernel version '%s' not recognized", kernel_version);
292-
kasld_skip_reason("no offsets for this kernel build");
291+
kasld_disposition(DISP_INCONCLUSIVE, NULL,
292+
"no offsets for this kernel build");
293293
return -1;
294294
}
295295

@@ -336,8 +336,7 @@ static unsigned long get_kernel_addr_entrybleed(void) {
336336

337337
if (cpu == CPU_VENDOR_UNKNOWN) {
338338
kasld_err("Unknown CPU vendor");
339-
kasld_skip_reason("unknown CPU vendor");
340-
exit(KASLD_EXIT_UNAVAILABLE);
339+
exit(kasld_disp_absent("unknown CPU vendor"));
341340
}
342341

343342
bool pti = detect_kpti();
@@ -347,8 +346,7 @@ static unsigned long get_kernel_addr_entrybleed(void) {
347346

348347
if (cpu == CPU_VENDOR_AMD && pti) {
349348
kasld_err("AMD systems with KPTI enabled are not affected by EntryBleed");
350-
kasld_skip_reason("AMD with KPTI enabled (not affected)");
351-
exit(KASLD_EXIT_UNAVAILABLE);
349+
exit(kasld_disp_mitigation("kpti", "AMD with KPTI enabled (not affected)"));
352350
}
353351

354352
int kernel = detect_kernel_version();
@@ -368,7 +366,8 @@ static unsigned long get_kernel_addr_entrybleed(void) {
368366
if (addr != leak_syscall_entry(offset)) {
369367
addr = 0;
370368
kasld_err("Inconsistent results. Aborting ...");
371-
kasld_skip_reason(
369+
kasld_disposition(
370+
DISP_INCONCLUSIVE, NULL,
372371
"inconsistent results (noise); a quieter run may resolve");
373372
break;
374373
}

src/components/kernelsnitch.c

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -769,8 +769,7 @@ int main(void) {
769769
if (!getenv("KASLD_EXPERIMENTAL")) {
770770
fprintf(stderr, "[-] kernelsnitch: experimental component; "
771771
"set KASLD_EXPERIMENTAL=1 to enable\n");
772-
kasld_skip_reason("experimental (set KASLD_EXPERIMENTAL=1)");
773-
return KASLD_EXIT_UNAVAILABLE;
772+
return kasld_disp_disabled("experimental (set KASLD_EXPERIMENTAL=1)");
774773
}
775774

776775
kasld_info("trying KernelSnitch (futex hash timing) ...");
@@ -785,8 +784,8 @@ int main(void) {
785784
if (prctl(PR_FUTEX_HASH, PR_FUTEX_HASH_GET_SLOTS, 0, 0, 0) >= 0) {
786785
fprintf(stderr, "[-] kernelsnitch: CONFIG_FUTEX_PRIVATE_HASH is enabled; "
787786
"attack not possible\n");
788-
kasld_skip_reason("CONFIG_FUTEX_PRIVATE_HASH enabled");
789-
return KASLD_EXIT_UNAVAILABLE;
787+
return kasld_disp_mitigation("CONFIG_FUTEX_PRIVATE_HASH",
788+
"CONFIG_FUTEX_PRIVATE_HASH enabled");
790789
}
791790

792791
/* Determine futex hash table size. */
@@ -806,8 +805,7 @@ int main(void) {
806805

807806
/* Phase 1: Pile-up. */
808807
if (create_pileup() < 0) {
809-
kasld_skip_reason("could not create the futex pile-up");
810-
return 0;
808+
return kasld_disp_inconclusive("could not create the futex pile-up");
811809
}
812810

813811
/* Phase 2: Find collision addresses. */
@@ -816,7 +814,8 @@ int main(void) {
816814
if (find_collisions(collisions, &num_collisions, hashsize) < 0) {
817815
fprintf(stderr, "[-] kernelsnitch: insufficient collisions; "
818816
"timing signal too noisy?\n");
819-
kasld_skip_reason("insufficient collisions (timing too noisy)");
817+
kasld_disposition(DISP_INCONCLUSIVE, NULL,
818+
"insufficient collisions (timing too noisy)");
820819
cleanup_pileup();
821820
return 0;
822821
}
@@ -872,8 +871,8 @@ int main(void) {
872871
if (!result) {
873872
fprintf(stderr, "[-] kernelsnitch: brute-force failed to find "
874873
"mm_struct address\n");
875-
kasld_skip_reason("brute-force did not find the mm_struct address");
876-
return 0;
874+
return kasld_disp_inconclusive(
875+
"brute-force did not find the mm_struct address");
877876
}
878877

879878
/* The brute-forced address is the current task's mm_struct, allocated

0 commit comments

Comments
 (0)