KASLD's architecture is a simple contract: each component is a standalone executable that probes one data source and prints tagged lines to stdout. The orchestrator discovers, runs, and post-processes components automatically — no registration, no linking, no Makefile changes. The inference engine runs after collection, narrowing kernel layout quantities from the merged observations.
This document covers the actionable mechanics a component author or rule author needs. For how the system works as a whole — the layered engine and its fixpoint, the data-flow seams, cross-region derivation, and KASLR runtime states — see docs/architecture.md. For end-user material, see README.md and docs/usage.md.
To report a security vulnerability in this project, use the private process in SECURITY.md rather than the issue tracker.
- Architecture in brief
- Writing a component
- Writing a rule
- Adding an architecture
- Changing the JSON document
- API reference
KASLD is a three-stage pipeline. Standalone components probe data sources
and print tagged lines; the orchestrator runs each as an isolated child
process (fork() + execl(), per-component timeout, exit code signalling its
relationship with its data source) and merges the results by
(type, region, name); the inference engine then resolves the kernel layout
from the merged evidence and reports each value with provenance. Components are
fully decoupled — drop a .c file in src/components/ and the build discovers
it; no registration or Makefile change. A component that segfaults, hangs, or
errors cannot affect the others.
The full conceptual reference — component lifecycle and phases, the three-layer engine and its fixpoint, the store-vs-read seam, the tagged-line protocol, cross-region derivation, and the three KASLR runtime states — lives in docs/architecture.md. The rest of this document is the actionable mechanics of writing a component or rule.
Components emit results as tagged lines on stdout — but never by hand. Call one of the five emitter helpers (see Emitter API below); each prints the correct wire shape and rejects malformed inputs at the source. The full protocol — the field grammar, a field-by-field anatomy of a line, and the region/confidence vocabularies — is documented in docs/architecture.md → The tagged-line protocol.
Beyond the address records (P/V), two more tagged kinds share the channel —
scalar facts (S) and dispositions (R); the orchestrator parses all three and
ignores anything else, so a component can freely print diagnostic messages
(progress, errors, explanations). A component may emit zero, one, or multiple
tagged lines.
These are independent axes:
posdescribes whatsamplerepresents (base / top / interior). It does NOT say the base is known — that is a question about whetherlois set, not aboutpos. UseHAS_LO(r)for that.confis a trust ranking of how the address was obtained. It does NOT describe precision — precision lives in the width of[lo, hi]. A CONF_PARSED record withlo–hispanning 64 MB is "trustworthy but imprecise"; a CONF_HEURISTIC record withlo == hiis "precise but weak evidence".
Region constants describe what kind of kernel memory is at the address.
The vocabulary is a closed enum, grounded in standard Linux memory
concepts. Subsystem-specific reservations (CBMEM, RMTFS, ION, …)
collapse to a standard concept (REGION_RESERVED_MEM, REGION_PMEM,
…); the discovery method is captured by the orchestrator-filled
origin.
Adding a new component should normally require zero new region constants.
The complete vocabulary is defined in
src/include/kasld/api.h:
| Group | Constants |
|---|---|
| Physical landmarks | REGION_RAM, REGION_DMA, REGION_DMA32, REGION_INITRD, REGION_CMDLINE, REGION_CMDLINE_MEMMAP, REGION_RESERVED_MEM, REGION_SWIOTLB, REGION_VMCOREINFO, REGION_CRASHKERNEL, REGION_PMEM, REGION_ACPI_TABLE, REGION_ACPI_NVS, REGION_EFI_MEMMAP, REGION_EFI_LOADER_IMAGE, REGION_NUMA_NODE, REGION_MMIO, REGION_PCI_MMIO |
| Kernel image | REGION_KERNEL_TEXT, REGION_KERNEL_TEXT_BAND, REGION_KERNEL_DATA, REGION_KERNEL_BSS, REGION_KERNEL_IMAGE, REGION_MODULE, REGION_MODULE_BAND |
| Direct-map / virtual landmarks | REGION_DIRECTMAP, REGION_DIRECTMAP_BAND, REGION_PAGE_OFFSET, REGION_VMALLOC, REGION_VMEMMAP |
Edge-ness (RAM_BASE vs. RAM_TOP, DMA_TOP, etc.) is encoded via the
emitter helper (kasld_result_base vs. kasld_result_top), not via
distinct region constants.
Three regions come in pairs — KERNEL_TEXT / KERNEL_TEXT_BAND, MODULE /
MODULE_BAND, DIRECTMAP / DIRECTMAP_BAND — and the choice between them
records how the component knows, not how confident it is:
- The bare constant asserts the source established membership: a resolved
symbol, a sampled instruction pointer, an ELF program header, a slab pointer,
a
/proc/iomemline. - The
_BANDconstant says only that the value landed inside the region's address window.
The distinction is load-bearing because the windows overlap. A text window is
the KASLR-admissible range rather than the image's extent, so on most
architectures it also contains the linear map, the module band, or both; the
arm64 module band spans most of the kernel address space. Rules that bound a
quantity from module or text membership therefore read only the bare constant —
a range-classified address carries no information about which region it belongs
to. Tagging a direct-map pointer as KERNEL_TEXT asserts
image_base <= sample, which can carve the true base out of the guaranteed
window.
Emit the _BAND form whenever the address came from a range test, and whenever
in doubt: it is the weaker, always-safe tag. Where a component holds a bare
pointer and wants it classified, call kasld_addr_classify(), which returns the
_BAND form wherever the windows are not exclusive. tests/check-text-provenance
enforces this for text claims at or above the sound floor.
Confidence ranks the trustworthiness of how the address was obtained, not
its precision. Highest to lowest: parsed > derived > inferred >
heuristic > timing > brute. Pick the value that matches how the
component produced the address:
| Value | When |
|---|---|
CONF_PARSED |
Read from a structured source (kallsyms, /proc/iomem, sysfs, dmesg) |
CONF_DERIVED |
Computed from another parsed address via a documented kernel offset |
CONF_INFERRED |
Multi-step inference from several parsed/derived results |
CONF_HEURISTIC |
Pattern match / fingerprinting — best-effort but not guaranteed |
CONF_TIMING |
Side-channel timing measurement |
CONF_BRUTE |
Brute-force probe |
The orchestrator weights conflicting claims by conf: a parsed address
beats a timing address when they disagree.
Confidence and the two windows. The engine resolves twice: a guaranteed
window from signals at or above a sound floor (CONF_INFERRED), and a likely
window from all signals (see
Two-window resolution).
So the level chosen decides which window an emission can reach. Before picking
one, classify the value:
- A fact — derived from an observation (a parsed address, a value computed
from one) — is
CONF_INFERREDor higher and may shape the guaranteed window. - A guess — a bootloader convention, a standard-config default, a
fingerprint, a timing estimate — is
CONF_HEURISTICor lower, so it refines only the speculative likely window.
Emitting a guess at CONF_INFERRED or above puts it in the guaranteed window,
where a wrong guess excludes the truth on a legitimate non-default kernel — the
one thing that window must never do. A value not computed from an observation is
a guess; when in doubt, emit it below the floor.
Components emit results via five intent-revealing helpers from
src/include/kasld/api.h. Each picks the wire shape
that matches what the component actually knows. There is no _exact
helper — "exact" was a precision conflation; precision lives in trust
(conf) plus bounds width.
| Helper | Use when |
|---|---|
kasld_result_range(type, region, lo, hi, name, conf) |
Both bounds known (full extent — e.g. a /proc/iomem entry) |
kasld_result_sized(type, region, lo, sz, name, conf) |
Base and size known; emits lo, hi = lo + sz - 1 |
kasld_result_base(type, region, lo, name, conf) |
Lower bound known, upper unknown |
kasld_result_top(type, region, hi, name, conf) |
Upper bound known, lower unknown |
kasld_result_sample(type, region, addr, name, conf) |
A representative interior point — no extent claim |
A range variant, kasld_result_extent(type, region, lo, hi, name, conf),
emits the same lo+hi but as pos=extent — one member of a complete,
single-source covering of the region (a whole RAM map: every E820 / device-tree
/memory / online hotplug extent). The value lives in the gaps between
extents, so it makes no positional claim: floor rules ignore it (they require
pos=base), and the orchestrator routes it out of the cross-source merge into
the engine's coverings[] so the map stays faithful and per-source. Only emit it
from a source that reads the whole map — a partial map would synthesize false
gaps, which tests/check-extent-callers guards against.
All helpers return 1 on emit, 0 on rejection (with a stderr
warning). Rejection happens for: CONF_UNKNOWN, invalid type, invalid
region, helper-specific preconditions (e.g. _sized overflow,
_range with lo > hi).
Pass name = NULL (or "") when the leak establishes only "somewhere in
this kind of memory" and not the specific instance. Pass a real name
when the exact occupant of the address is known — a kernel symbol
(hypercall_page), an ACPI OEM ID (Cpu0Ist), a module
(nf_conntrack), a device (0000:00:14.0).
A component that detects KASLR being switched off (or unsupported, or having
failed to randomize) emits scalar facts via kasld_emit_scalar() instead of an
address; which facts, and how the engine consumes each, are documented in
docs/architecture.md → KASLR runtime states.
A bound a component computes but cannot state as a located address goes on a
third channel: kasld_emit_constraint(quantity, op, value, conf) emits a C
line naming a quantity from the engine's own vocabulary. It lives in
kasld/constraint.h, which api.h does not pull in — a component using it
includes that header too. perf's lowest sampled instruction pointer is the
worked case — it sits below _text, so it bounds the image base from below
while being no address in the text region. The channel carries the two
inequality ops only, C_LOWER_BOUND and C_UPPER_BOUND; an exact value belongs
on an address record, and any other op is rejected at the call rather than
emitted. Because a constraint is not an address, the anchor rules never read
one, so a sub-_text bound cannot be mistaken for a text anchor.
A leak or probe that ends without a tagged result can report why with a
disposition — a short R line in a closed category that refines the exit
code (recorded metadata, never engine evidence). The category carries the
distinction the exit code cannot: an unavailable technique blocked by a
defensive control on the target versus one that merely lacks a prerequisite
on this host, and an empty run that is a deliberate opt-out versus an honest
"ran, no clean signal, cannot prove why". The typed emitters emit the line and
return the exit code the category implies, so the two channels cannot disagree:
| Emitter | Meaning | Returns |
|---|---|---|
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 |
kasld_disp_mitigation_denied(gate, msg) |
A control denied the source (the access-denied variant) | KASLD_EXIT_NOPERM |
kasld_disp_absent(msg) |
An attacker prerequisite is missing on this host | KASLD_EXIT_UNAVAILABLE |
kasld_disp_disabled(msg) |
Deliberate operator opt-out (needs a flag/env) | KASLD_EXIT_UNAVAILABLE |
kasld_disp_inconclusive(msg) |
Ran, no clean signal, cannot prove why | 0 |
Use kasld_disposition(cat, gate, msg) (no return value) where the exit code is
decided elsewhere — inside a helper or a loop. A mitigation with no gate is a
bug and emits nothing. Emit a disposition only when it soundly classifies the
null result beyond the exit code — above all, a confirmed mitigation; a
component that merely found no matching entry emits nothing. A mitigation
disposition is confirmed active in the hardening report; every disposition is
listed under --verbose and appears per-component in JSON.
Two channels, kept separate (include/kasld/cli.h):
-
stdout is the machine channel — only the
P/V/S/Rwire lines the emitter helpers print. Never write a human message to stdout (socomponent 2>/dev/nullis clean, parseable output). -
stderr is the human channel — every diagnostic, through the levelled logger, never a bare
printf/fprintf:Macro Prefix Use kasld_info(fmt, …)[.]normal progress kasld_debug(fmt, …)[.]firehose detail — printed only under verbose kasld_err(fmt, …)[-]failure / data unavailable kasld_errno(fmt, …)[-]failure, with strerror(errno)appendedkasld_found(fmt, …)[+]a leak was produced Never
fprintf(stderr, …),perror(), or a write to fd 2. They reach the right channel by the wrong route: the[-]gets copied by hand rather than applied, the line cannot be demoted to the verbose-only level, and nothing that changes how diagnostics are emitted can reach it.perror("[-] open x")iskasld_errno("open x"). Which errno a probe got is the difference between a source that is not there and one that is refused, so a failed read reported without it makes the reader guess what the component already knew.The logger emits whole lines, so a line built across several calls — a table row assembled in a loop — is assembled with
snprintfand logged once.The
info/debugsplit matters: verbose means different things per component (a couple of lines forproc_iomem, a per-collision firehose forkernelsnitch). Demote firehose lines tokasld_debugso a normal run — andkasld -v— stay readable; they surface only under the component's own verbose.tests/check-component-outputenforces this: any component printing a diagnostic to stdout fails the build.
Announce before the first operation that can fail. A run prints a header per component and then whatever that component emitted, so a component that emits nothing leaves its header standing over a blank space — and a blank space cannot say which of three things happened: the technique does not apply here, the source was there and access was refused, or it ran and found nothing. The middle one is hardening, and it is the one a reader most needs to see.
So the first kasld_info goes above the source check, not after it:
int main(void) {
kasld_info("reading /proc/modules for loaded module base addresses ...");
if (kasld_access("/proc/modules", R_OK) != 0)
return (errno == EACCES || errno == EPERM) ? KASLD_EXIT_NOPERM
: KASLD_EXIT_UNAVAILABLE;Printed first, the line survives an early return, a denial, a kill on timeout
and a crash. Printed after the checks, it appears only on the runs that were
going to succeed anyway. Report the outcome too, in the terms the reader needs:
kptr_restrict masking every address in /proc/kallsyms is a fact about the
target, and a silent exit 77 is not that fact.
tests/check-component-announces enforces both halves — that every component
source calls kasld_info at least once, and that every component still writes
something when run against a tree holding nothing.
Options are optional and manual (testing/debugging — the orchestrator
passes none and sets no env). If a component takes any, parse them with
kasld_cli(argc, argv) rather than hand-rolling argv — it gives every
component the same -v / --verbose, -t SECS / --time (the component's own
probe budget, in seconds — not kasld's kill timeout), and -h / --help. A
component then reads kasld_verbose (or kasld_is_verbose()) and kasld_time_s
as it cares; one with no options stays int main(void). kasld_is_verbose()
also honours $KASLD_VERBOSE, so a main(void) component is debuggable without
an argc/argv conversion.
Components signal their outcome to the orchestrator via exit code:
| Exit code | Constant | Meaning |
|---|---|---|
| 0 | — | Ran successfully (results, if any, are in tagged output) |
| 69 | KASLD_EXIT_UNAVAILABLE |
Data source or hardware feature not present on this system |
| 77 | KASLD_EXIT_NOPERM |
Access denied to data source |
The orchestrator classifies each component's outcome using this priority:
- SUCCESS — component emitted at least one tagged line
- TIMEOUT — component was killed by the timeout
- ACCESS_DENIED — exit code 77
- UNAVAILABLE — exit code 69
- NO_RESULT — ran successfully but found nothing
The exit code answers "what was the relationship with the data source?" — not "were results found". A component that accessed its data source and found no matching data should exit 0, not 69 or 77. The orchestrator already knows whether results were found from the tagged output.
The constants are defined in
src/include/kasld/api.h and
follow the <sysexits.h> convention (EX_UNAVAILABLE = 69,
EX_NOPERM = 77).
69 and 77 describe different facts about the target: one is how it was built,
the other is how it is defended. open(), access(), stat() and opendir()
report both by failing, and only errno separates them — so returning 69 from a
failed probe without consulting it records a denial as a missing prerequisite,
and the report then overlooks hardening the target actually has. Under a
mandatory access control policy this is routine rather than rare: a denied path
fails lookup, presenting exactly as a missing one.
kasld_exit_for_errno() in
src/include/kasld/api.h returns the constant the
failure implies. Call it immediately after the failing probe, before any other
library call can overwrite errno, and only where a single candidate path was
tried — after a helper that walks several, errno belongs to the last one
attempted rather than to the most informative, so those components record a
denial across the walk instead.
tests/check-absence-vs-denial enforces this floor: a component that probes a
filesystem source and can reach an absence verdict must consult errno
somewhere. It cannot prove the reason is the right one, so it is a floor and not
a proof — a component whose verdict rests on no probe at all belongs in the
check's allowlist with its reason.
A complete, real component. It searches the kernel log for the
free_reserved_area() messages that pre-v4.10 kernels printed when freeing init
memory, parses the leaked address, and emits it. The shape — find a line, parse
an address, emit one tagged result — is the one most components share.
// src/components/freeing.c — free_reserved_area() leak (pre-v4.10 kernels)
#define _GNU_SOURCE
#include "include/dmesg.h"
#include "include/kasld/api.h"
#include <stdlib.h>
#include <string.h>
/* dmesg_search() invokes this for every log line containing "Freeing".
* Old kernels print:
* Freeing unused kernel memory: 1476K (ffffffff81f41000 - ffffffff820b2000)
* The address inside the parentheses lies within the kernel image. */
static int on_match(const char *line, void *ctx) {
(void)ctx;
const char *paren = strchr(line, '(');
if (paren == NULL)
return 1; /* v4.10+ prints no address — keep scanning */
unsigned long addr = strtoul(paren + 1, NULL, 16);
if (!kasld_addr_is_kernel_text(addr))
return 1;
/* The exact position within the image is unknown (an interior point), and
* the value is parsed from a structured log line: pos=interior, conf=parsed. */
kasld_result_sample(KASLD_TYPE_VIRT, REGION_KERNEL_IMAGE, addr, NULL,
CONF_PARSED);
return 1; /* keep scanning for further "Freeing" lines */
}
int main(void) {
if (dmesg_search("Freeing ", on_match, NULL) < 0)
return KASLD_EXIT_NOPERM; /* dmesg_restrict blocked the read */
return 0;
}Place the file in src/components/. Run make — the build system
automatically discovers every .c file in that directory and compiles each
into a standalone binary under build/<arch>/components/. No Makefile edits
required.
Run it directly to see the tagged result it prints to stdout:
$ ./build/x86_64-linux-musl/components/freeing
V kernel_image pos=interior conf=parsed sample=0xffffffff81f41000
That single V … line is the component's entire contract with the engine — the
orchestrator reads it from stdout and the rest is automatic. KASLD ships a fuller
version of this technique as dmesg_free_reserved_area.c, which additionally
classifies the address by range and derives the physical address on coupled
architectures. To see this exact result flow through a rule, the engine, and the
rendered output, follow
the end-to-end walkthrough.
Components that leak a physical address with a known extent (e.g. a
/proc/iomem region) should use kasld_result_range to convey both
bounds in a single call:
kasld_result_range(KASLD_TYPE_PHYS, REGION_INITRD, phys_start, phys_end,
NULL, CONF_PARSED);On coupled architectures, the same logical region exists in both spaces
— emit both records and let the merge pass link them by
(region, name):
kasld_result_range(KASLD_TYPE_PHYS, REGION_INITRD, phys_lo, phys_hi,
NULL, CONF_PARSED);
#ifdef phys_to_directmap_virt
kasld_result_range(KASLD_TYPE_VIRT, REGION_INITRD,
phys_to_directmap_virt(phys_lo),
phys_to_directmap_virt(phys_hi),
NULL, CONF_DERIVED);
#endifThe #ifdef guard compiles the derivation out on arches where the
direct-map projection is unsound at compile time — x86_64 with
CONFIG_RANDOMIZE_MEMORY (direct-map base randomized), arm64 / riscv64
/ s390 (text and direct map at independent runtime offsets). On those
arches the macro is undefined, so forgetting the guard fails to compile
rather than silently emitting a wrong observation. See
docs/architecture.md → Cross-region derivation
for the full picture.
Each component embeds two optional pieces of metadata via dedicated macros:
KASLD_EXPLAIN(text) — a plain-text explanation of the technique,
stored in a .kasld_explain ELF section. Displayed by --explain mode.
KASLD_EXPLAIN("Searches dmesg for 'Freeing ... memory' messages from "
"free_reserved_area() that print kernel virtual addresses.");KASLD_META(text) — machine-readable key:value metadata, stored in a
.kasld_meta ELF section. The orchestrator reads this to determine the
component's leak primitive, address type, applicable mitigations, and
CVE associations. Used by the --hardening assessment.
KASLD_META(
"method:parsed\n"
"phase:inference\n"
"discloses:virtual\n"
"sysctl:dmesg_restrict>=1\n"
"bypass:CAP_SYSLOG\n"
"fallback:/var/log/dmesg\n"
"patch:v4.10\n"
);Supported metadata keys:
| Key | Description | Example |
|---|---|---|
method |
Required. Technique category, used by the hardening report | parsed, heuristic, inferred, timing, brute, detection |
discloses |
Required. What the technique leaks | virtual, physical, both, facts |
source |
Required. Where the technique's own inputs come from; the orchestrator schedules on it against a captured tree | files, live, hybrid |
phase |
Scheduling phase | inference (default when omitted), probing |
sysctl |
Runtime sysctl gate | dmesg_restrict>=1, kptr_restrict>=1 |
bypass |
Condition that bypasses the gate | CAP_SYSLOG, adm group |
fallback |
Alternative data source | /var/log/dmesg |
lockdown |
Blocked by kernel lockdown | integrity, confidentiality |
hardware |
CPU feature or erratum the technique needs, placing it in the hardware side-channel section | prefetch side-channel (mitigated by KPTI) |
config |
Kernel compile-time config dependency | CONFIG_E820_TABLE |
cve |
Associated CVE identifier | CVE-2022-4543 |
patch |
Kernel version where the leak was patched | v4.10, v6.2 |
status |
Opt-in gate; the component runs only with -x |
experimental |
method, discloses and source are mandatory — tests/check-component-meta
fails the build without them, and rejects a value outside each key's list above.
discloses names what the technique leaks, a static property of the component,
as distinct from what a given run observed. The JSON publishes every component's
metadata block, and the compile-time-surface and hardware-side-channel sections
of the hardening report fall back to discloses because they list a component
whether or not it produced anything.
source names where the technique's own inputs come from, and decides whether it
runs when the analysis reads a captured tree instead of the running kernel:
files— every input is a fact file, so the technique replays unchanged.live— the result derives from live runtime state of the executing kernel/CPU, which no capture carries; the component does not run there.hybrid— a live step and a captured-file read, so it runs in either mode with the live step suppressed.
It describes the component's own code. Where a shared header already answers
the same question from a file under a capture — the kernel log via dmesg.h, the
process identity via the environment gatherer — the component is files: the
abstraction holds the branch, not the component.
status is optional, since most components are not gated, but a component that
declares it must also call kasld_skip_experimental("<name>") at the top of
main() and return what it hands back — the orchestrator will not schedule the
component without -x, and that call is what refuses a direct invocation, which
has no orchestrator to ask. tests/check-live-probes pairs the two. Its value is
held to the list above all the same. The gate reads the key by presence, so a
misspelling still holds the component back — what it breaks is every reader of
the value: the JSON publishes it, this table documents it, and the sample
readouts are checked against a count of the literal declaration.
Each component should also include structured comment blocks in its file header documenting the leak primitive and mitigations:
// Leak primitive:
// Data leaked: kernel virtual addresses (freed memory section boundaries)
// Kernel subsystem: mm — free_reserved_area()
// Address type: virtual (kernel text / initrd)
// Method: parsed (dmesg string)
// Status: removed in v4.10
//
// Mitigations:
// Removed in v4.10. Access gated by dmesg_restrict.The inference engine is the unit-tested core (tests/test_engine.c,
tests/test_engine_integration.c, and the estimate/evidence suites).
Components are thin parse-and-emit shims, and most are covered end to end by
running the real binary over captured real systems:
tests/replayruns each architecture'skasldover the captured/proc+/systrees intests/fixtures/— crash coverage of the parse and render paths on real data.extra/validate-bundleruns offline against a captured bundle and asserts the engine's resolved ranges contain the ground truth (soundness).
A component therefore does not get its own unit test by default. Add a hermetic parser test only when the component is fixture-unreachable — when its input cannot appear in a captured tree:
- it requires specific hardware or firmware a normal capture will not have (CXL, coreboot, an active IOMMU, NVDIMM, UIO, a Qualcomm modem, …), or
- its input is too large or absent on the build host (e.g. the multi-megabyte
/sys/kernel/btf/vmlinux).
Such parsers must route their reads through the kasld_* wrappers
(kasld_opendir, kasld_fopen, …) so a test can stage a KASLD_SYSROOT
fixture in place of the live system. The test then #includes the component
with its main renamed, drives it over hand-built fixture files reproducing the
exact kernel ABI (text format, units, endianness), and checks the emitted wire
line. tests/test_sysfs_parsers.c is the pattern; tests/test_btf.c covers the
oversized-input case.
Live probes. A component whose result comes from live runtime state of the
executing kernel/CPU — a perf syscall, a CPU instruction, a timing side-channel,
a set-uid helper, or a self-referential /proc/self pseudo-file — cannot be
reproduced from a captured tree: under KASLD_SYSROOT it would describe the
analysis host, not the target. Such a component must (1) declare source:live
in KASLD_META, so the orchestrator filters it under KASLD_SYSROOT, and (2)
call kasld_skip_live_probe("<name>") at the top of main() (returning when it
returns non-zero), so a direct standalone run skips itself too.
A component that needs a live step but still has something to say from the
capture declares source:hybrid and brackets the live step alone:
if (kasld_fact_source() == KASLD_FACTS_LIVE) {
/* trigger the module load / measure the running kernel */
}
/* read the captured files either way */The tests/check-live-probes guard holds each declaration to the code: a
files component must contain no live primitive, and a live or hybrid one
must carry its guard. Reach for kasld_fact_source() rather than testing
KASLD_SYSROOT; tests/check-fact-source keeps the question to one spelling.
Hermetic tests are regression guards against parser code changes, not a way to
detect kernel-side ABI drift — a frozen fixture cannot track a moving kernel.
Drift is caught by widening the real-capture corpus under tests/fixtures/ and
by source review against new kernel releases.
One component carries a hermetic test for a different reason:
dmesg_mem_init_kernel_layout(tests/test_dmesg_layout.c) is reachable via the dmesg captures, but its test exists to validate the parser across every width and endianness undertests/test-cross. That is a deliberate exception to the fixture-reachability rule above.
Engine rules are pure functions in src/rules/. Adding one is a new file plus a
single registry line:
- Create
src/rules/<name>.cwith the rule signature:Readint rule_<name>(const struct evidence_set *ev, const struct estimate *est, struct constraint *out, int out_max);
ev(observations + scalar facts) and the currentestarray; emit constraints intoout[0..out_max)and return the count. A rule does no I/O and has no side effects. For curation, write a verdict rule that emitsV_INVALIDto drop an observation from the effective set. - Register it: add the prototype and one entry to
k_rules[](ork_vrules[]for a verdict rule) insrc/engine_rules.c— the single registry shared by the orchestrator and the test suite. - Add unit tests in
tests/test_engine.cproving soundness: truth stays inside the estimate, and an adversarial observation cannot push it past truth. The per-rule unit test is the soundness gate.
Estimates only narrow — never emit a constraint that would widen a quantity past
its honest top. The fixpoint re-runs every rule, so depend only on ev and
est, never on rule order.
This complete rule turns an interior leak into a sound ceiling on the kernel
image base — the rule the
end-to-end walkthrough traces.
(The shipped range_from_interior is this plus the parallel physical quantity.)
// src/rules/text_ceiling_from_interior.c
#include "include/kasld/engine_rules.h"
#include "include/kasld/regions.h"
#include <limits.h>
#include <string.h>
int rule_text_ceiling_from_interior(const struct evidence_set *ev,
const struct estimate *est,
struct constraint *out, int out_max) {
(void)est; /* depends only on the evidence, not the current estimate */
/* Lowest virtual address seen inside the kernel image. _text cannot lie
* above it, so it is a sound upper bound on the image base. */
unsigned long ceil = ULONG_MAX;
uint32_t src = 0;
enum kasld_confidence conf = CONF_UNKNOWN;
for (int i = 0; i < ev->n_obs; i++) {
const struct observation *o = &ev->obs[i];
if (!o->valid || o->eff_type != KASLD_TYPE_VIRT)
continue;
if (o->eff_region != REGION_KERNEL_IMAGE || !HAS_SAMPLE(o))
continue;
if (o->sample < ceil) {
ceil = o->sample;
src = o->id;
conf = o->conf;
}
}
if (ceil == ULONG_MAX || out_max < 1)
return 0; /* no qualifying observation — emit nothing */
memset(&out[0], 0, sizeof(out[0]));
out[0].q = Q_VIRT_IMAGE_BASE;
out[0].op = C_UPPER_BOUND; /* image base <= ceil */
out[0].value = ceil;
out[0].conf = conf;
out[0].derived_from[0] = src;
out[0].lineage_count = 1;
snprintf(out[0].origin, ORIGIN_LEN, "text_ceiling_from_interior");
return 1;
}The reasoning is the soundness argument: _text cannot lie above an address
known to be inside the image, so the lowest such sample is a valid upper bound.
The rule reads only ev, ignores est, and emits one C_UPPER_BOUND — so it is
order-independent and can only narrow.
A constraint names a quantity, an op, a value (and value2 for the ranged
ops), and a confidence. Pick the op for what the evidence actually proves:
| Op | Meaning | Emit when |
|---|---|---|
C_LOWER_BOUND |
q >= value |
a floor — the quantity cannot be below value |
C_UPPER_BOUND |
q <= value |
a ceiling — the quantity cannot be above value |
C_EQUALS |
q == value |
a pin — the exact value is known |
C_AT_LEAST_ALIGN |
q divisible by value |
the quantity is known to be at least value-aligned |
C_EXCLUDE |
q not in [value, value2] |
a forbidden sub-range |
C_STRIDE |
q ≡ value (mod value2) |
the quantity lands on a fixed grid |
C_EXCLUDE and C_STRIDE carry a second bound in value2; the others use
value alone. Interior C_EXCLUDE holes are carved at read time, not stored —
see
Estimate narrowing and the store-vs-read seam.
A component's confidence answers "how was this address obtained". A rule's answers a different question, because a rule obtains nothing: how much doubt does this rule's own reasoning add, over and above its inputs?
The trust of the inputs is not the rule's to carry. A constraint is capped at the least confident entry in its lineage where the engine receives it, so a bound drawn from a timing witness cannot be reported, or prioritised against a rival, as though it had been parsed — whatever the rule wrote. Which observations can arrive weakly is a fact about the component set rather than anything a rule can see from where it stands, which is why the cap lives at the seam and not in each rule.
That leaves one question for the rule, and CONF_INFERRED is the answer for
most of them:
| Value | When a rule emits it |
|---|---|
CONF_PARSED |
A pass-through: the value is one the target itself stated, and the arithmetic between input and output is identity |
CONF_DERIVED |
A computed certainty — an architectural invariant, or independent sources agreeing — below CONF_PARSED so a direct reading outranks it |
CONF_INFERRED |
The default. Sound reasoning over admitted evidence, adding no doubt of its own |
CONF_HEURISTIC or lower |
The reasoning is itself a guess, however good the input |
CONF_INFERRED is the default because it is the weakest grade that still
reaches the guaranteed window (the sound floor is exactly CONF_INFERRED), and
sound reasoning over admitted evidence belongs in the sound answer. It is a
ceiling rather than a claim: a poor witness pulls the emitted value down on its
own.
Lower it when the rule's reasoning is a guess even given a perfect witness —
a convention, an assumed default layout, a classification that rests on a
window test rather than on structural provenance. The cap cannot do this for a
rule: an exact address plus guessy reasoning is still a guess.
directmap_page_offset_bounds is the worked example. The same witness quality
yields CONF_INFERRED for an established REGION_DIRECTMAP tag and
CONF_HEURISTIC for a range-classified one, because the second inference is a
guess about what the address implies even when the address is exact.
Grade per path, not per rule, wherever the paths differ.
kernel_image_phys_bound states its raw-witness ceiling at CONF_INFERRED —
sound whatever the alignment turns out to be — and the tighter,
alignment-assuming ceiling at CONF_HEURISTIC in the same function.
randomize_memory_page_offset pins at CONF_INFERRED from a same-origin
matched pair and at CONF_HEURISTIC from a cross-origin reconstruction that a
mispairing could place wrongly.
A pin is where this matters most. A C_EQUALS at or above the floor collapses
a quantity to one value inside the guaranteed window, which is sound only where
the pinned value is derived from evidence or a structural fact; a default or a
convention pinned there excludes the truth on a legitimate non-default kernel.
tests/check-confidence-floor will ask about any new pin — it flags every
collapsing constraint whatever confidence the rule writes, and requires each to
be listed with the review that admits it, rather than trying to read the
confidence out of the source and exempt the ones that look safe.
The per-rule unit test is what guarantees the engine never excludes the truth.
For the rule above, test_engine_interior_ceiling in tests/test_engine.c is
the pattern: seed one interior observation, run the rule through the engine, and
assert the estimate's ceiling lands exactly on the sample (truth retained) while
the floor is untouched. A complete test also adds an adversarial observation and
shows it cannot push the estimate past the truth.
What that test must establish is stated formally as obligation S in
docs/soundness.md,
and the quantifiers are the part worth reading before writing the test. Two of
them are easy to lose: the constraint must hold under every configuration the
architecture still admits, not just the common one, and it must hold when est
has already been narrowed by other rules — which is why a rule that reads its
own quantity's estimate is treated as a special case.
The engine model and the existing rule catalogue are described in docs/architecture.md → The inference engine and Cross-region derivation.
A new architecture is one header under src/include/kasld/arch/. It answers a
fixed set of questions about how that architecture lays memory out, and api.h
refuses to compile a header that leaves any of the mandatory ones unanswered —
the seven listed under Mandatory axes below. Nothing here is
inferred from a neighbouring header: an answer copied from the closest-looking
architecture is the failure this section exists to prevent.
Several axes look like restatements of one another and are not. The reason they are kept apart is asymmetric risk: an axis answered too restrictively costs precision — a window stays wider than it needed to be — while one answered too permissively licenses an operation that is not sound, and the arithmetic that follows has nothing in it to notice. Collapsing two questions into one flag picks a side for whichever architecture arrives next.
"The linear map's base holds still at runtime" (DIRECTMAP_STATIC), "kernel
text rides at a fixed offset inside the linear map" (TEXT_TRACKS_DIRECTMAP),
"this build can know the target's base" (PAGE_OFFSET_KNOWN_AT_BUILD, derived
from PAGE_OFFSET_MIN == PAGE_OFFSET_MAX) and "the anchor the kernel actually
used is recoverable" (LINEAR_MAP_ANCHOR) are four different claims. arm64
answers them differently from each other, which is what forced them apart.
DIRECTMAP_STATIC and TEXT_TRACKS_DIRECTMAP hold the same value on every
architecture in the tree today, so no existing header demonstrates the
difference and neither can be read off the other by example. Pick by the
question being asked.
PAGE_OFFSET_MIN / PAGE_OFFSET_MAX — the bracket containing every
linear-map base the architecture admits, stated as literals because they appear
in #if arithmetic. Enumerate what the architecture really allows: every
VMSPLIT variant, VA-width and paging-level configuration, not the one a
typical distro ships. Equal values mean the analysing binary knows the target's
base, which is what gates the compile-time projection macros; a bracket that is
too narrow excludes a legitimate kernel from its own window.
PAGE_SIZE_MIN / PAGE_SIZE_MAX — the bracket containing every page size
the architecture admits, taken from the HAVE_PAGE_SIZE_* selectors in the
kernel's own arch/<arch>/Kconfig rather than from what a distro ships. Six of
the twelve supported architectures fix one size and six do not: arm64,
loongarch64, mips and 64-bit powerpc reach 64 KiB, and 32-bit powerpc
reaches 256 KiB. Equal
values mean the analysing binary knows the target's page size, which is what
gates pfn_to_phys() — a page-frame number counts the target kernel's pages,
so converting one to a byte address with this build's constant is wrong by up to
64x where the two differ. Where they differ the multiplier must come from the
SF_PAGE_SIZE observation, and a rule with neither declines rather than
guessing: the quantities built on such a span are bounds, and an understated
span moves a bound past the truth it is supposed to contain.
LINEAR_MAP_ANCHOR — where the physical address the kernel maps at
PAGE_OFFSET comes from, since a rule pairing a direct-map virtual with a
physical reconstructs the base as virt - phys + anchor. LM_ANCHOR_PHYS_OFFSET
where the kernel maps physical 0 at the linear-map base, so the compile-time
constant is right by construction. LM_ANCHOR_DRAM_BASE where the kernel takes
the anchor from the base of DRAM at boot, so only evidence supplies it.
LM_ANCHOR_UNKNOWABLE where it is displaced by an amount no unprivileged
observation recovers — rules then decline, and there is no one-sided fallback
because the displacement has no fixed direction. This axis has no default
precisely because a missing answer would become PHYS_OFFSET and reintroduce
the substitution it exists to prevent.
MODULES_ANCHOR — what the module band's position is fixed to, as one of
four alternatives rather than a set of booleans, so the exclusivity is
structural. MOD_ANCHOR_FIXED for a fixed range independent of image and map;
MOD_ANCHOR_PAGE_OFFSET for a fixed delta from PAGE_OFFSET, which also needs
MODULES_START_FOR / MODULES_END_FOR; MOD_ANCHOR_TEXT where the band slides
with text KASLR; MOD_ANCHOR_BRACKETS_TEXT for a window centred on the image,
MODULES_BRACKET_TEXT wide either side.
TEXT_TRACKS_DIRECTMAP — whether a physical bound may propagate to the
virtual text base. DIRECTMAP_STATIC — whether a direct-map base
reconstructed from a leak may be pinned rather than kept as a window. Neither
gates the compile-time projections; that is PAGE_OFFSET_KNOWN_AT_BUILD's job,
since projecting requires knowing the base here, not merely that the target
holds it still.
IMAGE_BASE_RESIDUE_FIXED — whether _text's residue modulo
KASLR_VIRT_ALIGN is an architectural constant. Answer 1 only if the linker
fixes it for every configuration the architecture admits. Where the residue is
config-dependent the grid-snap rule must stay inert, because snapping on a
residue even one page out raises a floor past the true base and drops the truth
out of the guaranteed window.
Every other axis an architecture header may define is optional, and api.h
supplies a default when it is omitted. Each default is the conservative answer
— the weakest module-band level, "the projection is not exact", a zero head gap
— so leaving one out costs precision and never soundness. Read the #ifndef
block for the contract each answers before overriding it.
Add the header to the #if defined(...) dispatcher in api.h, which selects
one arch header per build. Cite the kernel source each answer comes from in a
comment beside it: an axis whose justification is not traceable to
arch/<arch>/ in the kernel tree cannot be re-checked when the kernel moves.
Then, by exit status rather than by reading output:
make cross— the header compiles under every target, and a mandatory axis left out fails here rather than later.make test-cross— the per-architecture windows hold under emulation. This is what settles a cross-architecture change.tests/check-property-arches— containment and floor properties for each supported architecture.tests/check-arch-axes— the mandatory set and its documentation agree.
A window that is too wide passes every test a correct one does, so green tests do not by themselves establish that a narrowing answer was right. State what makes each restrictive answer sound, in the header, next to the answer.
-j is a published contract. docs/kasld.schema.json
describes it, consumers validate against it and generate types from it, and
tests/check-json-schema fails the build if the schema and the emitter drift
apart — so a key added to src/render/json.c or src/render/hardening.c is
three edits, not one.
Declare the key in the schema. An emitted key the schema does not name
fails the guard. Every object is additionalProperties: false, which is what
makes that possible.
Decide required from the emitter, not from a test run. A key printed
inside an if is optional however reliably it shows up: groups[].hi appears
in every document the corpus produces and is still conditional. The guard
cannot catch this one — a wrongly-required key passes until it reaches the
machine that omits it, and by then the schema has told a consumer the key is
guaranteed. Roughly two fifths of the document's key paths are
absent from at least one architecture, so required is the intersection across
all of them, never what this host emits.
Bump KASLD_JSON_SCHEMA_VERSION in src/include/kasld/internal.h, and the
matching const in the schema. Adding a key is additive: bump the MINOR. Bump
the MAJOR only when a key is removed or renamed, a value's type or meaning
changes, or a key that was unconditional becomes conditional — and then the
three shipped consumers in extra/ need their SCHEMA_MAJOR raised with it,
which the guard checks. Nothing enforces the bump itself; it is a judgement
about what changed, which is why it is written down here.
A key that no fixture can reach — live-only evidence, a speculative sub-window — goes in the guard's unexercised list with the others, and is removed again if the corpus ever starts producing it.
The complete component API is in src/include/kasld/api.h
— emitter helpers, enums, address-layout constants, and the exit codes.
src/include/kasld/internal.h is
orchestrator-internal; components don't include it.
Emitter helpers — pick the one matching what is known:
| Helper | Use |
|---|---|
kasld_result_range(type, region, lo, hi, name, conf) |
Both bounds known (full extent) |
kasld_result_sized(type, region, lo, sz, name, conf) |
Base and size known |
kasld_result_base(type, region, lo, name, conf) |
Lower bound only |
kasld_result_top(type, region, hi, name, conf) |
Upper bound only |
kasld_result_sample(type, region, addr, name, conf) |
Interior point sample |
All return 1 on emit, 0 on rejection (stderr warning is written).
Enums:
| Symbol | Values |
|---|---|
enum kasld_addr_type |
KASLD_TYPE_UNKNOWN, KASLD_TYPE_PHYS, KASLD_TYPE_VIRT |
enum kasld_region |
REGION_KERNEL_TEXT, REGION_RAM, REGION_INITRD, REGION_PCI_MMIO, … (see kasld/api.h for the full list) |
enum kasld_confidence |
CONF_PARSED > CONF_DERIVED > CONF_INFERRED > CONF_HEURISTIC > CONF_TIMING > CONF_BRUTE |
ELF metadata:
| Symbol | Purpose |
|---|---|
KASLD_EXPLAIN(text) |
Embed a technique explanation (.kasld_explain ELF section) |
KASLD_META(text) |
Embed machine-readable metadata (.kasld_meta ELF section) |
Exit codes (defined in kasld/api.h):
| Symbol | Purpose |
|---|---|
KASLD_EXIT_UNAVAILABLE |
Exit code 69: feature/hardware not present |
KASLD_EXIT_NOPERM |
Exit code 77: access denied |
Address-layout constants (per-arch, from arch/<arch>.h):
| Symbol | Purpose |
|---|---|
KERNEL_VIRT_TEXT_DEFAULT |
Default (non-randomized) kernel text base |
KERNEL_VIRT_VAS_START, KERNEL_VIRT_VAS_END |
Kernel virtual address space bounds |
KERNEL_VIRT_TEXT_MIN, KERNEL_VIRT_TEXT_MAX |
Plausible kernel text range (validation) |
KASLR_VIRT_TEXT_MIN, KASLR_VIRT_TEXT_MAX |
KASLR randomization window (slot counting) |
KASLR_VIRT_TEXT_MIN_WIDE |
Conservative widened floor (admits non-default Kconfigs) |
PAGE_OFFSET |
Direct-map base (compile-time default) |
PHYS_OFFSET |
Physical RAM base address |
TEXT_TRACKS_DIRECTMAP |
1 on arches where text + directmap move together |
DIRECTMAP_STATIC |
1 where the directmap projection is sound at compile time |
phys_to_directmap_virt(p) |
Convert phys → directmap virt (defined only on sound arches) |
directmap_virt_to_phys(v) |
Inverse — same gate as above |
Mandatory axes — api.h refuses to compile an arch/<arch>.h that omits
any of these, and the enum-valued ones are _Static_asserted to their listed
values, so a typo fails the build rather than expanding to 0. Each names a
separate question: a permissive answer to one does not license the others.
| Symbol | Values | Answers |
|---|---|---|
PAGE_OFFSET_MIN / PAGE_OFFSET_MAX |
literal addresses | Which linear-map bases the architecture admits — equal values mean this build knows the target's base |
PAGE_SIZE_MIN / PAGE_SIZE_MAX |
literal sizes | Which page sizes the architecture admits — equal values mean this build knows the target's page size, and only then may a page-frame number be converted with a constant |
LINEAR_MAP_ANCHOR |
LM_ANCHOR_PHYS_OFFSET / LM_ANCHOR_DRAM_BASE / LM_ANCHOR_UNKNOWABLE |
Where the physical address that maps to PAGE_OFFSET comes from |
MODULES_ANCHOR |
MOD_ANCHOR_FIXED / MOD_ANCHOR_PAGE_OFFSET / MOD_ANCHOR_TEXT / MOD_ANCHOR_BRACKETS_TEXT |
What the module band's position is fixed to |
TEXT_TRACKS_DIRECTMAP |
0 / 1 | Whether kernel text slides with the linear map |
DIRECTMAP_STATIC |
0 / 1 | Whether the compile-time direct-map projection holds at runtime |
IMAGE_BASE_RESIDUE_FIXED |
0 / 1 | Whether _text's residue modulo KASLR_VIRT_ALIGN is an architectural constant rather than config-dependent |
A larger set of arch axes is optional: api.h supplies a default when the
header omits one, and every default is the conservative answer — the weakest
module-band level, "the projection is not exact", a zero head gap. Omitting one
therefore costs precision, never soundness. They are defined alongside the
mandatory set in api.h, each with the contract it
answers.