Skip to content

Commit ca56b08

Browse files
committed
orchestrator: seed the arch's KASLR facts before components run
On a !KASLR_SUPPORTED arch the orchestrator synthesises SF_VIRT_KASLR_DISABLED + SF_PHYS_KASLR_DISABLED so the engine takes the arch-off signal the way it takes a detector's. They were appended at summary time, after every component had had its turn at a 64-slot table, and a full table dropped them with no record — the one evidence loss that never reached the discard ledger. JSON then reported kaslr.disabled false on an arch where KASLR cannot exist. The facts depend on nothing observed, so they are seeded first, into an empty table, where they cannot be crowded out. Any later overflow belongs to a component, where capture_scalar() already caps and records it. This splits inject_kaslr_defaults() at the seam between its two times: seeding what the arch settles before anything is observed, and projecting the collected facts onto the summary afterwards, which must still run last because its scan reads the whole table. The bounds test stays, now recording instead of returning quietly. It no longer guards a full table — it guards a future caller moving this after capture, where unconditional writes would run off the end of one.
1 parent 3c9cfdd commit ca56b08

6 files changed

Lines changed: 173 additions & 28 deletions

File tree

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,7 @@ lint :
729729
@$(TEST_DIR)/check-discard-ledger
730730
@$(TEST_DIR)/check-discard-accounting
731731
@$(TEST_DIR)/check-discard-report
732+
@$(TEST_DIR)/check-scalar-seed-order
732733
@$(TEST_DIR)/check-vantage-coverage
733734
@$(TEST_DIR)/check-test-staging
734735
@$(TEST_DIR)/check-truncation

docs/testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ and `make` halts on the first.
132132
| `check-extent-callers` | only reviewed whole-map components call `kasld_result_extent` (the covering-completeness contract; a partial map would carve a false gap) |
133133
| `check-discard-accounting` | the shipped binary, with its worker pool running, counts discards exactly — N components x M bad wire records must yield total N*M in N kinds, repeated. The unit tests are single-threaded and their build defines no `HAVE_PTHREAD`, so nothing else exercises the ledger's mutex; probabilistic, so a failure is conclusive and a pass is evidence |
134134
| `check-discard-report` | the ledger's two renderings agree with a store actually full — a component overflows `MAX_SCALAR_FACTS`, the ledger is driven past its own `MAX_DISCARDS`, and the component directory past `MAX_COMPONENTS`; `--verbose` and `-j` must name the same total, reason and source, the capacity detail sentence must be printed, and the total must keep counting after the breakdown caps. Counts are differential, since the absolute overflow depends on what else populated the store — which varies by build, not by tool |
135+
| `check-scalar-seed-order` | the arch's compile-time KASLR-off facts are seeded into `scalar_facts[]` before the phase loop, and only `capture_scalar()` and `seed_arch_kaslr_facts()` append to it — appended at summary time instead, the pair competed with components for a 64-slot table and a full table dropped it with no ledger entry; the ordering is invisible to the suite, which stays at full marks with the call moved |
135136
| `check-vantage-coverage` | every filesystem source `kasld_gather_vantage()` reads is staged by a test, the suite actually calls the gatherer, and the absent direction is asserted — the gatherer was once constrained by nothing at all, a `memset` stub leaving the suite green, because the tests named "vantage" asserted on the formatters over a hand-filled struct |
136137
| `check-test-staging` | every test binary stages its filesystem through `test_sysroot.h`, which names the root after the binary and registers its own removal — fifteen tests each carried a private `mkdtemp`, of which eleven removed nothing, so a passing suite left a tree per binary under `/tmp` to accumulate indefinitely, with nothing ever failing |
137138
| `check-discard-ledger` | every reason in the discard vocabulary has a wire name, no layer keeps a private drop-counter beside the ledger, and the renderers read it through its accessors — a run that discarded evidence resolved from a subset of what was available, so a consumer unable to see the discard reads a bounded answer as a complete one |

src/include/kasld/internal.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,9 @@ extern int num_constraint_facts;
903903
const char *meta_get(const struct component_meta *m, const char *key);
904904
int meta_get_all(const struct component_meta *m, const char *key,
905905
const char **values, int max_values);
906-
void inject_kaslr_defaults(struct summary *s);
906+
/* Seeded before any component runs; projected onto the summary after. */
907+
void seed_arch_kaslr_facts(void);
908+
void summarize_kaslr_state(struct summary *s);
907909
void compute_component_stats(struct summary *s);
908910
/* Declared at file scope: first naming these inside the parameter list below
909911
* would give them PROTOTYPE scope -- a distinct type from the definition in

src/orchestrator.c

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,7 +1242,7 @@ int num_results;
12421242

12431243
/* scalar_fact_record + scalar_facts[]/num_scalar_facts declared in
12441244
* include/kasld/internal.h; the engine bridge copies these to OBS_SCALAR
1245-
* observations and inject_kaslr_defaults / render also read them directly. */
1245+
* observations and summarize_kaslr_state / render also read them directly. */
12461246
struct scalar_fact_record scalar_facts[MAX_SCALAR_FACTS];
12471247
int num_scalar_facts;
12481248

@@ -3664,17 +3664,20 @@ void compute_component_stats(struct summary *s) {
36643664
}
36653665

36663666
/* -------------------------------------------------------------------------
3667-
* Pre-computation: detect KASLR state and inject default address
3667+
* Pre-computation: what the arch settles before anything is observed
36683668
* -------------------------------------------------------------------------
36693669
*/
3670-
void inject_kaslr_defaults(struct summary *s) {
3671-
/* "Unsupported" is a compile-time property of the arch; no runtime signal
3672-
* needed. Surface it for the renderer banner, and seed the informational
3673-
* default address from the statically-initialised layout
3674-
* (= KERNEL_VIRT_TEXT_DEFAULT). */
3675-
s->kaslr.unsupported = !KASLR_SUPPORTED;
3676-
s->kaslr.default_addr = layout.virt_image_base_default;
36773670

3671+
/* Seed the facts the architecture settles at compile time, before any
3672+
* component runs.
3673+
*
3674+
* Called first because these depend on nothing observed: a component's output
3675+
* cannot change whether the arch supports KASLR. Seeding into an empty table
3676+
* also means they cannot be crowded out of it — appended last, they competed
3677+
* for whatever room components had left, and a full table dropped them with no
3678+
* record, the one evidence loss that did not reach the ledger. Any overflow
3679+
* now belongs to a component, where capture_scalar() caps and records it. */
3680+
void seed_arch_kaslr_facts(void) {
36783681
#if !KASLR_SUPPORTED
36793682
/* Surface the compile-time arch-off as SF_VIRT_KASLR_DISABLED +
36803683
* SF_PHYS_KASLR_DISABLED so the engine sees it like any runtime detector
@@ -3686,20 +3689,50 @@ void inject_kaslr_defaults(struct summary *s) {
36863689
* states the contract and what obliges an arch to stay at the default. An
36873690
* arch at the default gets the renderer's "KASLR not supported" banner and
36883691
* default-addr line while the engine refuses to pin; an arch that raises one
3689-
* pins through the same rule path a runtime detector uses. */
3690-
if (num_scalar_facts + 1 < MAX_SCALAR_FACTS) {
3691-
struct scalar_fact_record *fv = &scalar_facts[num_scalar_facts++];
3692-
fv->fact = SF_VIRT_KASLR_DISABLED;
3693-
fv->value = 1;
3694-
fv->conf = CONF_PARSED;
3695-
fv->origin = ORIGIN_ARCH_SYNTH;
3696-
struct scalar_fact_record *fp = &scalar_facts[num_scalar_facts++];
3697-
fp->fact = SF_PHYS_KASLR_DISABLED;
3698-
fp->value = 1;
3699-
fp->conf = CONF_PARSED;
3700-
fp->origin = ORIGIN_ARCH_SYNTH;
3692+
* pins through the same rule path a runtime detector uses.
3693+
*
3694+
* The pair is a single claim -- a virt disable without its phys partner says
3695+
* something else -- so both slots are taken together or neither is.
3696+
*
3697+
* Room is settled by the call ordering, not by the test below: seeded into an
3698+
* empty table, the pair always fits. The test is kept for what it protects
3699+
* against, which is not a full table but a future caller moving this after
3700+
* capture -- unconditional writes would then run off the end of one. It
3701+
* records rather than returns quietly, so a broken ordering shows up in the
3702+
* ledger instead of corrupting memory.
3703+
*
3704+
* __extension__ silences -Wpedantic: _Static_assert is a C11 keyword gcc
3705+
* accepts under -std=c99 as an extension, matching REGION_FIELD_CAP above. */
3706+
__extension__ _Static_assert(MAX_SCALAR_FACTS >= 2,
3707+
"the arch-off pair is seeded into an empty "
3708+
"scalar_facts[] and must always fit");
3709+
if (num_scalar_facts + 1 >= MAX_SCALAR_FACTS) {
3710+
kasld_discard_record(DISCARD_CAPACITY, DSRC_SCALARS);
3711+
return;
37013712
}
3713+
struct scalar_fact_record *fv = &scalar_facts[num_scalar_facts++];
3714+
fv->fact = SF_VIRT_KASLR_DISABLED;
3715+
fv->value = 1;
3716+
fv->conf = CONF_PARSED;
3717+
fv->origin = ORIGIN_ARCH_SYNTH;
3718+
struct scalar_fact_record *fp = &scalar_facts[num_scalar_facts++];
3719+
fp->fact = SF_PHYS_KASLR_DISABLED;
3720+
fp->value = 1;
3721+
fp->conf = CONF_PARSED;
3722+
fp->origin = ORIGIN_ARCH_SYNTH;
37023723
#endif
3724+
}
3725+
3726+
/* Project the collected facts onto the summary's KASLR state. Runs at summary
3727+
* time, after components: the scan below reads the whole table, so running it
3728+
* earlier would see only what the arch seeded and miss every detector. */
3729+
void summarize_kaslr_state(struct summary *s) {
3730+
/* "Unsupported" is a compile-time property of the arch; no runtime signal
3731+
* needed. Surface it for the renderer banner, and seed the informational
3732+
* default address from the statically-initialised layout
3733+
* (= KERNEL_VIRT_TEXT_DEFAULT). */
3734+
s->kaslr.unsupported = !KASLR_SUPPORTED;
3735+
s->kaslr.default_addr = layout.virt_image_base_default;
37033736

37043737
/* "Disabled" is a runtime signal from any detector that observed virtual
37053738
* KASLR off (nokaslr cmdline, no CONFIG_RANDOMIZE_BASE, dmesg "KASLR
@@ -5003,7 +5036,7 @@ static void report_killed_components(void) {
50035036
static void emit_summary(void) {
50045037
struct summary s = {0};
50055038
compute_component_stats(&s);
5006-
inject_kaslr_defaults(&s);
5039+
summarize_kaslr_state(&s);
50075040
/* Resolution runs HERE, at the single caller, not inside the summary builder:
50085041
* a function named for building a summary should not also own when the engine
50095042
* runs. compute_kaslr_info() is then a pure layout+snapshot -> summary
@@ -5373,6 +5406,10 @@ int main(int argc, char *argv[]) {
53735406
#endif
53745407
}
53755408

5409+
/* Before the first component: what the arch settles at compile time goes in
5410+
* while the table is empty and cannot be crowded out of it. */
5411+
seed_arch_kaslr_facts();
5412+
53765413
for (int p = 0; p < (int)(sizeof(phases) / sizeof(phases[0])); p++)
53775414
run_phase(&phases[p]); /* merges results after each phase */
53785415

tests/check-discard-report

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@
3434
# THE COUNTS ARE DIFFERENTIAL, and deliberately. The absolute overflow is
3535
# (emitted - MAX_SCALAR_FACTS) only when nothing else populated the store, which
3636
# is true of this host's build and NOT a property of the tool:
37-
# inject_kaslr_defaults() adds two facts under #if !KASLR_SUPPORTED. Asserting
38-
# the absolute count would be asserting what this build happens to lack. Two
39-
# runs differing by a known delta must differ in discards by exactly that delta,
40-
# whatever the store already held -- which is the claim worth making anyway,
41-
# that the counter keeps counting past the cap.
37+
# seed_arch_kaslr_facts() fills two slots before any component runs on a
38+
# !KASLR_SUPPORTED build. Asserting the absolute count would be asserting what
39+
# this build happens to lack. Two runs differing by a known delta must differ in
40+
# discards by exactly that delta, whatever the store already held -- which is
41+
# the claim worth making anyway, that the counter keeps counting past the cap.
4242
#
4343
# WHAT THIS DOES NOT COVER: eight of the eleven capacity sources. The engine's
4444
# seven arrive through discard_project_engine() from saturation bits that no

tests/check-scalar-seed-order

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/bin/sh
2+
# This file is part of KASLD - https://github.com/bcoles/kasld
3+
#
4+
# check-scalar-seed-order — the arch's compile-time facts are seeded into
5+
# scalar_facts[] before any component can fill it.
6+
#
7+
# On a !KASLR_SUPPORTED arch the orchestrator synthesises SF_VIRT_KASLR_DISABLED
8+
# and SF_PHYS_KASLR_DISABLED, because the engine takes the arch-off signal the
9+
# same way it takes a detector's. Those two facts used to be appended at summary
10+
# time, after every component had had its turn at a 64-slot table, and a full
11+
# table dropped them with no record -- the one evidence loss that never reached
12+
# the discard ledger.
13+
#
14+
# The fix is ordering, not accounting: seeded first, into an empty table, they
15+
# cannot be crowded out, and any later overflow belongs to a component where
16+
# capture_scalar() already caps and records it.
17+
#
18+
# Ordering is invisible to the suite. Moving the call back after the phase loop
19+
# leaves `make test` at full marks and `make lint` clean -- measured, not
20+
# assumed -- because the affected arches are the three that compile
21+
# !KASLR_SUPPORTED, and nothing in the suite runs a component flood on one. So
22+
# the invariant is checked where it is legible: in the source.
23+
#
24+
# 1. seed_arch_kaslr_facts() is called exactly once, and before the first
25+
# run_phase() call. After it, the drop returns.
26+
# 2. Only capture_scalar() and seed_arch_kaslr_facts() append to the table. A
27+
# third appender is how the divergence started -- two writers with
28+
# different capacity behaviour, one of them silent.
29+
#
30+
# WHAT THIS DOES NOT PROVE: that seeding works. It proves nothing moved the call
31+
# or added a writer. The behaviour is verified by running a !KASLR_SUPPORTED
32+
# build against a flooded component directory, which needs qemu and a staged
33+
# sysroot and is not something grep can do.
34+
#
35+
# Pure text, so it needs no build.
36+
# ---
37+
# <bcoles@gmail.com>
38+
39+
set -u
40+
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
41+
ORCH="$ROOT/src/orchestrator.c"
42+
43+
if [ -t 1 ]; then
44+
RED=$(printf '\033[31m')
45+
GREEN=$(printf '\033[32m')
46+
RESET=$(printf '\033[0m')
47+
else
48+
RED=
49+
GREEN=
50+
RESET=
51+
fi
52+
53+
fail=0
54+
note() {
55+
printf '%scheck-scalar-seed-order: FAIL%s — %s\n' "$RED" "$RESET" "$1" >&2
56+
fail=1
57+
}
58+
59+
[ -f "$ORCH" ] || {
60+
note "orchestrator.c is missing"
61+
exit 1
62+
}
63+
64+
# --- 1. seeded before the first phase runs -----------------------------------
65+
# The definition line is excluded so only the call site counts.
66+
calls=$(grep -n 'seed_arch_kaslr_facts()' "$ORCH" | grep -cv '^\([0-9]*\):void ')
67+
if [ "$calls" -ne 1 ]; then
68+
note "seed_arch_kaslr_facts() is called $calls times; expected exactly once"
69+
fi
70+
71+
seed_ln=$(grep -n '^ seed_arch_kaslr_facts();' "$ORCH" | head -1 | cut -d: -f1)
72+
phase_ln=$(grep -n 'run_phase(&phases\[p\]);' "$ORCH" | head -1 | cut -d: -f1)
73+
74+
if [ -z "$seed_ln" ]; then
75+
note "no top-level call to seed_arch_kaslr_facts() in main()"
76+
elif [ -z "$phase_ln" ]; then
77+
note "could not find the run_phase() loop; the ordering cannot be checked"
78+
elif [ "$seed_ln" -ge "$phase_ln" ]; then
79+
note "seed_arch_kaslr_facts() (line $seed_ln) runs at or after the phase loop (line $phase_ln)"
80+
printf ' Seeded after capture, the arch-off pair competes with components for\n' >&2
81+
printf ' slots and a full table drops it -- the loss this ordering removes.\n' >&2
82+
fi
83+
84+
# --- 2. only the two known writers append ------------------------------------
85+
# awk tracks the enclosing top-level function so an append is attributed rather
86+
# than merely counted.
87+
bad=$(awk '
88+
/^[a-z_].*\(.*\)[ ]*\{[ ]*$/ { fn = $0; sub(/\(.*/, "", fn); sub(/.* /, "", fn) }
89+
/^(static )?[a-z_].*[a-z_]+\(/ && /\{[ ]*$/ { fn2 = $0 }
90+
/scalar_facts\[num_scalar_facts\+\+\]/ {
91+
if (fn != "capture_scalar" && fn != "seed_arch_kaslr_facts")
92+
print NR ": " fn
93+
}' "$ORCH")
94+
95+
if [ -n "$bad" ]; then
96+
note "scalar_facts[] is appended outside capture_scalar() and seed_arch_kaslr_facts():"
97+
printf '%s\n' "$bad" | sed 's/^/ /' >&2
98+
fi
99+
100+
if [ "$fail" -ne 0 ]; then
101+
exit 1
102+
fi
103+
printf '%scheck-scalar-seed-order: OK%s (seeded at line %s, before the phase loop at %s; two known writers)\n' \
104+
"$GREEN" "$RESET" "$seed_ln" "$phase_ln"

0 commit comments

Comments
 (0)