kasld recovers a running kernel's memory layout — primarily the kernel text
base — from a local process, using as much as its vantage (privileges, groups,
capabilities, system configuration, and any container confinement) allows. Run
it with no arguments and it prints an answer: the recovered (or narrowed)
virtual and physical image base, the direct map base, and the leaks the answer
was derived from.
Underneath, kasld gathers evidence from many small leak components (each a
standalone technique that probes one source) and feeds it to an inference engine
that narrows the layout to the smallest set of placements the evidence supports,
reporting every value with its provenance and any residual entropy. In normal use
that machinery is invisible — a single invocation prints the result. This
document covers the output modes and command-line options; for how the pieces fit
together see architecture.md.
- Quick start
- Vantage
- Command-line options
- Output modes
- Explain mode
- Hardening assessment
- Continuous integration
sudo apt install libc-dev make gcc binutils git
git clone https://github.com/bcoles/kasld
cd kasld
make
./build/<arch>/kasldThe build/<arch>/ directory is self-contained and can be deployed to a
target system:
build/<arch>/
kasld <- run this
components/ <- leak components
A hardened configuration (kernel.dmesg_restrict=1, kernel.kptr_restrict=1,
kernel.perf_event_paranoid=2 or higher, kernel.unprivileged_bpf_disabled=1,
and %pK pointer hashing, on by default) narrows the filesystem-oracle path. It is one axis of the vantage
only: side-channel, weak-entropy, and capability-granted techniques are
independent of these sysctls. For testing purposes, the
extra/weaken-kernel-hardening script
can temporarily relax these settings (requires root).
What kasld recovers depends on the process's vantage — three independent
axes, not a single privilege ladder: privileges and capabilities, system
configuration, and confinement. The hardened sysctls above are only one of
them. A leak source is reachable only where every axis that gates it permits, so
more privilege is not a superset of less — configuration can deny a source to
root, and the side-channel and capability-granted techniques bypass the sysctls
entirely:
The -v, -j, and -m outputs report the detected vantage (container,
confinement, readable oracles, and the capability-gated leaks reachable from the
current capabilities). A richer vantage lets kasld attempt more and gather more
evidence, but the guaranteed (sound) window reflects only the evidence actually
proven — never the privilege that gathered it — so it stays trustworthy whatever
the vantage. See limitations.md.
-j, --json Machine-readable JSON output
-1, --oneline Single-line summary output (shell-pipeable)
-m, --markdown Markdown table output (for issue trackers)
-c, --color Colorize text output (auto-detected for TTYs)
-a, --ascii ASCII-only output: no Unicode glyphs or banner
(auto-enabled in a non-UTF-8 locale)
-q, --quiet Suppress banner, progress, and warnings
-v, --verbose Add banner, system-config block, per-component logs,
per-region Results table, KASLR analysis, and the
virtual + physical memory-layout maps. The default
text mode prints a tight ~15-line answer-first
readout; -v restores the full detail.
-e, --explain Show technique explanations before each component
-f, --fast Use 2s per-component timeout (fast scan mode)
-w, --workers N Parallel component workers (default: nproc; 0 = sequential)
-x, --experimental Enable experimental components
-s, --skip PATTERN Skip matching components (glob, comma-separated; multiple --skip flags accumulate)
-H, --hardening Append the hardening assessment to text/markdown output
--map Draw the address-space diagram (implied by --verbose)
-t, --timeout N Per-component timeout in seconds (default: 30)
-V, --version Print version and exit
-h, --help Show this help
Single-dash short flags may be bundled: -fq is -f -q, -vj is -v -j. A
value-taking flag (-s/-t/-w) may appear only as the last flag in a bundle,
taking the next argument — -fqt 2 is -f -q -t 2.
The text output uses a few Unicode glyphs (✓ ⚠ ✗ →) and a box-art banner
under -v. These are auto-disabled in a non-UTF-8 locale (LANG=C, legacy
consoles), and --ascii (-a) forces plain ASCII regardless — for screen
readers on a UTF-8 system, or non-UTF-8 pipelines. The JSON (-j) and one-line
(-1) formats carry no glyphs and are the fully accessible, machine-readable
interfaces.
Colour follows the standard environment conventions: NO_COLOR (any value)
disables it, CLICOLOR=0 disables it, and CLICOLOR_FORCE (non-empty, not 0)
forces it on even when piped. An explicit --color overrides NO_COLOR.
The default text mode prints a tight answer-first readout: a one-line header, the Layout table, the virt/phys coupling note, the leaks the answers were derived from, and a hint about the verbose mode. No banner, no system config, no memory-layout diagram.
The Layout table carries one row per quantity and basis:
| Column | What it holds |
|---|---|
Quantity |
what is being located — always a base, a single address, not a region |
Certainty |
guaranteed (proven; contains the true base) or likely (the all-signals estimate, a subset of the guaranteed window, and may be wrong) |
Window |
the addresses: a window, a single address where the quantity is pinned, or a one-sided >= / <= bound. A concrete base carries its slide from the compile-time default |
Candidates |
how many placements remain, against the set the row narrows — a guaranteed row against the window the kernel randomized over, a likely row against the guaranteed count printed directly above it. Reported whether or not evidence narrowed it, so a baseline run states the size of the problem. A guaranteed row states that set as N of M; a likely row states the count alone, because its set is the line above and the two are read vertically. N of M appears whenever the set is modelled, including N of N, which says the set is known and evidence excluded nothing from it. A bare N means the opposite: no set is modelled for this quantity, so there is nothing to state the count against — as on the memory-KASLR regions outside x86_64, and on any row whose modelled set is smaller than the count and therefore cannot serve as its denominator. - means no window is modelled at all, and - of N means no count could be taken — the quantity sits on no modelled grid, or its admissible set was carved away entirely — while N is still the set the row narrows |
Grain |
the spacing the candidates sit on, which is what reconciles the count with the window. Prefixed >= where it is only a lower bound, so a coarser true spacing means fewer real candidates than stated and the count beside it is a ceiling. Unprefixed where the spacing is the pitch itself and the count is exact — either because the architecture fixes it, as the memory-randomization grid does, or because the run resolved it: an image base's slot granularity is a build option (CONFIG_PHYSICAL_ALIGN on x86), so a run that read it reports an exact grain and one that did not reports the architectural minimum as a floor. Whether this column is prefixed is therefore a property of the run, not only of the target |
A key naming a quantity reports one of exactly three things: 0xADDR where the
engine resolved it, [0xLO..0xHI] where it only bounded it (a missing edge is
omitted, as [..0xHI]), or na where nothing is known. A bare address is the
signal to act on, and the absence of a [ is how to test for it — a window's
floor is not the base, and translating an address through one gives a wrong
answer rather than an approximate one. Keys carrying a measurement or a property
of the run rather than an answer — arch, kaslr, entropy, pentropy,
dram, results — are always present and follow their own forms.
The likely basis is deliberately conservative — "may be wrong" is a worst-case
caveat, not a coin toss. KASLD is build-agnostic: it never trusts a version string
or fingerprint to pin a base, so a signal that would fix the base on a specific
build — a leaked symbol pointer read as the base (e.g. perf_event_open), a
matched compile-time default — is still held below the sound floor to likely,
because the engine cannot assume it is looking at that build. Against a known
target (known hardware, known kernel and configuration) those deterministic
likely results are in practice effectively certain; the genuinely probabilistic
part is the hardware side channels (prefetch, entrybleed, …), whose success
varies from run to run. So a likely row is a floor on confidence, read together
with the source that produced it: a parsed leak is near-certain on a known target;
a timing oracle is the part that can miss.
Every quantity the architecture randomizes gets a row whether or not the engine bounded it, so the set of rows is a property of the machine rather than of the run — rows do not appear and vanish between boots. A row the engine never bounded says so rather than printing the architectural window, which would put a compile-time constant where a reader expects a measurement.
Quantities the architecture does not randomize follow a second rule: they get a row once the engine has bounded them, and none otherwise. Two are in this class.
The module region exists everywhere, and is resolved from whichever directions
the architecture affords. Where its placement is fully determined the row is a
single address: a fixed segment base (MIPS), a base computed from the hardware
virtual-address width (LoongArch), one selected by the translation mode and
page size (ppc64), or one derived from a resolved PAGE_OFFSET (arm32, ppc32,
riscv32). Where something is randomized or unknown it is a
window instead, narrowed by the architecture's module band, by the allocator's
own placement window (on x86_64 the base sits within 1024 pages of
MODULES_VADDR), by the resolved text base where the region is anchored to the
image (riscv64, s390), and by the lowest module address a leak discloses, which
caps where the region can start.
No leak is required for any of that: with no module address in evidence at all,
the sample below still narrows the row from the architectural band to the 1025
positions the allocator's window admits — as a likely row, since that window
is the allocator's habit rather than a bound the architecture enforces.
The direct-map base is likewise fixed off x86_64, but still resolved: on a
32-bit kernel it is the VMSPLIT, read from the boot config or narrowed by an
mmap probe. Neither quantity is randomized there, so neither sits in a
randomization window — but each still has an architectural bracket, so both rows
are always present and state it. Where the pitch is not a modelled randomization
granule the Grain column reads - rather than inventing a grid, and the search
space follows it.
Addresses are never zero-padded: a 16 MiB physical address would otherwise wear the costume of a 64-bit kernel pointer. They are right-aligned instead, so the endpoints still form columns.
KASLD 0.4.2-dev -- Kernel Address Space Layout Derandomization
Target: x86_64 / 7.0.0
Running 117 of 120 components (3 experimental skipped; use -x to enable)...
[####################] 100% 117/117 40.9s
1 component timed out after 30s and was killed (prefetch_directmap)
Quantity Certainty Window Candidates Grain
------------------- ---------- --------------------------------------- ---------------- -----
Virtual Image Base guaranteed 0xffffffff81000000 - 0xffffffffbd400000 483 of 512 2 MiB
Virtual Image Base likely 0xffffffff93400000 slide +0x12400000 1 2 MiB
Physical Image Base guaranteed 0x1000000 - 0x3d400000 474 2 MiB
Physical Image Base likely 0x1000000 - 0x3c29d000 474 2 MiB
Direct Map Base guaranteed 0xffff800000000000 - 0xffffa4aa80000000 37,547 1 GiB
Vmalloc Base guaranteed 0xffff898000000000 - 0xffffd6d580000000 79,191 of 79,191 1 GiB
Vmemmap Base guaranteed 0xffffa98040000000 - 0xfffffd0000000000 85,504 1 GiB
Module Region Base guaranteed 0xffffffffa0000000 - 0xffffffffff000000 389,121 4 KiB
Module Region Base likely 0xffffffffc0000000 - 0xffffffffc0400000 1,025 4 KiB
Paging Level guaranteed 48 1 of 2 -
Note: physical and virtual text randomize independently
Note: 1 sub-range excluded from the windows above; the counts
already reflect them (-v lists the ranges).
Evidence (1 finding, 2 components)
Region Position Address Sources
----------------- -------- ------------------ -------
virt kernel image base 0xffffffff93400000 2
[-v: detailed results, memory map, system info] [-H: hardening assessment]
The Evidence table says where the answers came from: one row per region
per kind of observation, Position distinguishing a witness to the region's
base from an interior sample that only bounds it, and Sources counting the
components that independently produced the finding. The components are named
individually under -v.
The markdown report (-m) carries a section under the same heading that answers
a different question, and the difference is deliberate. It reports one row per
section — the address range observed for it, with the contributing
components named rather than counted — over a wider set: the readout lists only
regions whose position is a resolved quantity, so physical DRAM and MMIO extents
appear in the markdown report and in the -v address-space diagram but not in
the readout's table. Where a markdown row spans a resolved base and an interior
sample it states the extent those two together witness, which is not a range the
engine resolved; the readout splits the same evidence into a base row and an
interior row because it is answering about position rather than extent.
Terms in this readout (slide, directmap, coupling, slot, search space) are defined in the kaslr.md glossary; the engine vocabulary behind them (quantity, estimate, honest top) is in the architecture.md glossary.
-v (--verbose) restores the full banner, system-config block,
per-component logs, per-region "Results" table, KASLR analysis section,
and the virtual + physical address-space diagram (also available on its own
via --map). The KASLR analysis section draws the same Layout table the
default readout does, then adds only what its columns cannot carry: _stext
where a quantity has one, the compile-time default a slide is measured from,
and the residual expressed in bits.
The system-config block reports the recon vantage: whether the process is
containerized, which mandatory-access-control policy is in force, the security
context and the uid/gid/groups this process runs under, and — when it is
confined — its seccomp / capability / no-new-privs state, plus which leak
sources are readable here: the /proc oracles, the system logs, debugfs, and
the System.map and kernel config for the running release.
A source that is not readable says which of two different things happened. no (denied) is a refusal, which is the target's hardening; no (absent) is a
source that is not there, which says nothing about it. unknown is neither —
the state where nothing observed separates them, which is what a probe failing
for any other reason reports, and what a replayed capture reports for a source
it does not hold. A capture cannot answer from the tree alone: an absent source
and a refused one are both simply not in it, so only a capture that recorded a
refusal at collection time renders one.
Under a mandatory-access-control policy the common case is that a refusal never
reaches the report as one. SELinux withholds getattr on the type rather than
returning EACCES on the open, so the source reads as missing. A vantage
where nearly every oracle is unknown and most components are denied or
unavailable is what a policy doing its job looks like from inside — not a run
that failed.
Group names come from /etc/group in the tree being analysed, so an offline
replay names that tree's groups rather than the analysing host's; the ids kasld
knows gate one of its own sources are named even where the tree cannot name
them, which is the case on Android, where the file exists but is empty and the
ids live inside the C library.
Each block below is a contiguous excerpt of the run named above it, replayed from a capture in the test corpus. What falls between them — the per-component stream above all — is not shown, and no block is a whole run.
Click to expand verbose examples
An unprivileged Android shell under an enforcing policy. The /etc/group in
that tree is empty, so the ids kasld knows gate one of its own sources are the
only ones carrying a name:
F=tests/fixtures/x86_64/android-13-5.15.119-android13-8-00034-gd34029c8258b-ab10871489-shell
extra/prepare-bundle $F /tmp/r && KASLD_SYSROOT=/tmp/r ./build/<arch>/kasld -v ▄█ ▄█▄ ▄████████ ▄████████ ▄█ ████████▄
███ ▄███▀ ███ ███ ███ ███ ███ ███ ▀███
███▐██▀ ███ ███ ███ █▀ ███ ███ ███
▄█████▀ ███ ███ ███ ███ ███ ███
▀▀█████▄ ▀███████████ ▀███████████ ███ ███ ███
███▐██▄ ███ ███ ███ ███ ███ ███
███ ▀███▄ ███ ███ ▄█ ███ ███▌ ▄ ███ ▄███
███ ▀█▀ ███ █▀ ▄████████▀ █████▄▄██ ████████▀
▀ ▀ v0.4.2-dev
Kernel release: 5.15.119-android13-8-00034-gd34029c8258b-ab10871489
Kernel version: #1 SMP PREEMPT Wed Sep 27 18:42:24 UTC 2023
Kernel arch: x86_64
Fact source: replayed capture
kernel.kptr_restrict: (unavailable)
kernel.dmesg_restrict: (unavailable)
kernel.panic_on_oops: (unavailable)
kernel.perf_event_paranoid: -1
Kernel lockdown: (unavailable)
Container: none
LSM: selinux (enforcing)
Security context: u:r:shell:s0
Identity: uid=2000 gid=2000
Supplementary groups: 1004,1007(log),1011,1015,1028,1078,1079,3001,3002,3003,3006,3009(readproc),
3011,3012(readtracefs)
Seccomp: none
Effective capabilities: none
No new privileges: no
Readable /proc/kallsyms: unknown
Readable /proc/kcore: unknown
Readable /proc/iomem: unknown
Readable /proc/modules: yes
Readable /var/log/dmesg: unknown
Readable /var/log/kern.log: unknown
Readable /var/log/syslog: unknown
Readable /var/log/journal: unknown
Readable /run/log/journal: unknown
Readable debugfs: unknown
Readable /boot/System.map: unknown
Readable /boot/config: unknown
The per-region Results table, from a capture whose base the engine resolves.
Each record is listed with the component that produced it, and the ==> line
is what the region resolved to:
extra/prepare-bundle tests/fixtures/x86_64/mainline-7.0.0 /tmp/r
KASLD_SYSROOT=/tmp/r ./build/<arch>/kasld -v========================================
Results
========================================
Kernel text (virtual) / kernel_text [1]:
0xffffffff8ea00000 kernel_text:_stext [base] (proc_kallsyms)
==> 0xffffffff8ea00000 (method: parsed, 1 source)
Kernel text (virtual) / kernel_image [1]:
0xffffffff8ea00000 kernel_image:_text [base] (proc_kallsyms)
==> 0xffffffff8ea00000 (method: parsed, 1 source)
----------------------------------------
Kernel modules (virtual) / module [2]:
0xffffffffc0400000 module [interior] (proc_modules)
0xffffffffc0401000 module [interior] (proc_modules)
==> spans 0xffffffffc0400000 - 0xffffffffc0401000 (method: parsed; 2 samples, 1 source; 4.0 KiB)
Further down the same run, the KASLR analysis and the two address-space diagrams:
KASLR analysis:
Quantity Certainty Window Candidates Grain
------------------- ---------- --------------------------------------- ---------------- -----
Virtual Image Base guaranteed 0xffffffff8ea00000 slide +0xda00000 1 of 491 2 MiB
Physical Image Base guaranteed 0x19600000 slide +0x18600000 1 of 491 2 MiB
Direct Map Base guaranteed 0xffff880000000000 - 0xffffa4aa80000000 29,355 of 29,355 1 GiB
Vmalloc Base guaranteed 0xffff898000000000 - 0xffffd6d580000000 79,191 of 79,191 1 GiB
Vmemmap Base guaranteed 0xffffa98040000000 - 0xfffffd0000000000 85,504 of 85,504 1 GiB
Module Region Base guaranteed 0xffffffffc0000000 - 0xffffffffc0400000 1,025 of 1,025 4 KiB
Paging Level guaranteed 48 1 of 2 -
Compile-time default: 0xffffffff81000000
Virtual entropy: ~0 of 9 bits
Physical entropy: ~0 of 9 bits
Direct map entropy: ~15 of 15 bits
----------------------------------------
Candidates within each resolved window (to scale):
Direct Map Base 29,355 of 29,355 candidates · ~15 of 15 bits · 1 GiB grain
0xffff880000000000 │████████████████████████████████████████████████│ 0xffffa4aa80000000
Module Region Base 1,025 of 1,025 candidates · ~11 of 11 bits · 4 KiB grain
0xffffffffc0000000 │████████████████████████████████████████████████│ 0xffffffffc0400000
Virtual address space (decoupled, not to scale):
0xffffffffffffffff ─┐
╎ 1020 MiB gap
0xffffffffc0401000 ─┤
█ modules
█ leak hi: 0xffffffffc0401000
█ leak lo: 0xffffffffc0400000
0xffffffffc0400000 ─┤
╎ 794 MiB gap
0xffffffff8ea00000 ─┤
█ kernel text (pinned) -- leak 0xffffffff8ea00000
0xffffffff8ea00000 ─┤
╎ 120 TiB gap
╎ ^ extent unknown
│ direct map (base is a lower bound)
0xffff880000000000 ─┤
╎ 63.9 PiB gap
0xff00000000000000 ─┘ (user space + non-canonical hole below)
Physical address space (not to scale):
0x3ffdefff ─┐
█ in DRAM
0x3ee04000 ─┼ [dram] initrd
0x1bdb3000 ─┼ [bss] kernel_bss:kernel_bss
0x1b200000 ─┼ [data] kernel_data:kernel_data
0x19600000 ─┼ [text] kernel
0x20000 ─┼ [dram] cmdline
0x1000 ─┼ [dram] ram
0x0 ─┘
The slot and bit figures in the KASLR-analysis block are computed as
slots = range / alignment-step and bits = ceil(log2(slots)) — the count of
alignment-aligned positions spanning the inferred range, and the rounded-up
base-2 logarithm of that count. The bits are an upper bound on residual
entropy: they assume every surviving slot is equally likely, whereas real KASLR
placement is slightly non-uniform (see
bypass-techniques.md — Weak entropy), so the
true entropy is marginally lower. 0 bits (a single surviving slot) means the
value is fully recovered.
These figures bound what KASLD recovered from the current vantage. A wide residual or an empty result is not a security assurance — it reflects what the implemented techniques could do here and now, not that the base is beyond reach. See limitations.md for what a negative or partial result does and does not imply.
-1 (--oneline) produces a single shell-pipeable line with a fixed
key set — every key below appears on every line, in this order, so a
scraper can match field= unconditionally. A value that is unresolved
or not applicable to the arch/run renders the sentinel na (never a
fabricated, defaulted, or leaked value):
arch=x86_64 kaslr=on text=0xffffffffa2e00000 stext=na slide=+0x21e00000(568328192) slots=1 ptext=[0x1000000..0x3ffdefff] pstext=na pslide=na pslots=480 dmap=0xffff800000000000 vmalloc=na vmemmap=na module=[0xffffffffc0000000..0xffffffffc0400000] vabits=na pagesize=4096 structpage=64 dram=[0x0..0x3ffdefff] results=27 replay=no
| Key | Meaning |
|---|---|
arch |
kernel machine (uname), or unknown |
kaslr |
on | off | unsupported | failed (failed = randomization failed at boot: effective 0 bits, deterministic per boot — distinct from off, a deliberate opt-out at the link-time default) |
text |
virtual image base (_text); engine-resolved, never a leak. This is the best concrete answer, which may rest on a sub-floor signal; the line carries no speculative marker, so read slots alongside it — slots=1 means one surviving candidate, i.e. proven |
stext |
virtual _stext, when it differs from the image base |
slide |
virtual KASLR slide, signed ±0xHEX(decimal) |
slots |
virtual residual slot count over the guaranteed window: the exact number of image-base placements still admissible. Present whenever a window was resolved — an unpinned window reports its count, a pin reports 1. na only when KASLR is off/unsupported. Bits of entropy are ceil(log2(slots)), left to the reader |
ptext |
physical image base (_text) |
pstext |
physical _stext, when it differs from the physical image base |
pslide |
physical KASLR slide (decoupled arches only) |
pslots |
physical residual slot count (same window / na rule as slots) |
dmap |
direct-map base (PAGE_OFFSET), per the value grammar below; never the compile-time constant |
vmalloc |
vmalloc base, same. na where the architecture does not randomize the region, so there is no such unknown |
vmemmap |
vmemmap base, same |
module |
module region base, same |
vabits |
address-space size in bits (the paging level): the value once one candidate remains, otherwise the candidates as a comma list (48,57). na where the architecture admits only one size, so nothing is unknown |
pagesize |
kernel page size in bytes, decimal. A fact, not a resolved unknown: it states a value the run knows — from the architecture where it admits a single page size, from a probe where it admits several — so na means only that nothing established it, never that it was pinned all along |
structpage |
sizeof(struct page) in bytes, decimal, on the same terms. na without BTF, which is the only thing that reports it |
dram |
physical DRAM extent, [0xLO..0xHI]. The span's size is not printed beside it: it is derivable from the edges, and a bracketed size carried a space, which made this the one value a whitespace tokenizer could not split into a key and a value |
results |
count of merged result records (not the raw component count) |
replay |
yes | no — whether the facts were read from a captured tree (KASLD_SYSROOT) rather than the running system. A replayed line names the captured kernel throughout, so nothing else on it separates a capture from a live snapshot |
na carries the same "no value asserted" guarantee the human formats
express by omitting a row; extra/ksymoff anchors on text=0x…, so a
text=na line is correctly treated as having no derivable base.
-j (--json) emits the complete structured view: every block
documented below is always present, whatever the other flags — a machine
consumer keys on presence-of-key, not on which flags were passed. This makes
one -j blob the full, self-contained posture snapshot a fleet/CI layer can
diff against a baseline or aggregate across hosts. (--verbose is the sole
addition: it appends each component's raw stdout output lines.) See
docs/exploitation.md for how the JSON plugs into an exploit —
control-flow and data-only strategies, a pwntools template, and ksymoff.
A JSON Schema for the document ships as
docs/kasld.schema.json — validate an ingested document
against it, or generate types from it, rather than working from the prose here.
tests/check-json-schema holds the two in step over every captured
architecture, so the schema cannot fall behind the emitter.
Five top-level fields identify the document before anything it measured, so a stored blob is routable without parsing the analysis.
| Key | Meaning |
|---|---|
schema_version |
the format's version, MAJOR.MINOR — see the compatibility rule below |
version |
the kasld binary's version. A -dev suffix is not unique to a commit |
replay |
whether the facts were read from a captured tree (KASLD_SYSROOT) rather than from the running system. A replayed document names the captured kernel in every field, so nothing else in it separates a capture from a live snapshot — a fleet or CI layer that handles both keys on this rather than inferring it |
host |
the machine the facts describe, as it names itself; inside a UTS namespace, the container's name. null on a replay: a capture states no name of its own, and the analysing host's would name a machine that was never measured |
generated_utc |
when the document was written, YYYY-MM-DDTHH:MM:SSZ. On a live run that is also when the facts were read; on a replay replay says the facts are older, and the capture's own time stays with its bundle. null where the clock could not be read |
Compatibility rule. schema_version is deliberately separate from
version: the tool's version changes for reasons that leave the document alone,
so a consumer keying on it has to re-test against releases that changed nothing
it reads. MINOR increments are additive only — a new key, a new member of an
open vocabulary, or a conditional key becoming unconditional. MAJOR increments
where a key is removed or renamed, a value's type or meaning changes, or an
unconditional key becomes conditional. So: dispatch on MAJOR, ignore unknown
keys, and do not reject a higher MINOR. make bump-version does not touch it.
The shipped schema describes exactly one version and pins it as a const, so it
rejects a document of any other; the schema for an earlier release ships with
that release.
The KASLR object reports two windows plus a headline base. The key names differ from the text labels; the mapping is:
| Concept | JSON key | Text label |
|---|---|---|
| Guaranteed window (sound floor; contains the true base) | inferred / inferred_physical |
guaranteed in the Layout table (readout and -v draw the same one) |
| Likely window (all signals; a subset of the guaranteed window; may be wrong) | likely / likely_physical (with "speculative": true) |
likely in the Layout table |
| Headline concrete base | virtual / physical → image_base |
"Virtual / Physical image base" |
| Paging level (a set of sizes, not a window) | va_bits → values |
Paging Level in the Layout table |
A window's range_min/range_max are its convex hull, not its candidate
set: the engine carves sub-ranges out of the interior, and slots already
counts only what survives. Where any were carved, excluded lists the ranges
the report retains and excluded_total says how many there were — so a
consumer can tell a truncated list from a complete one, and can skip addresses
already ruled out rather than working through the whole hull. The ranges are
disjoint and ascending — several forbidden regions commonly rule out
overlapping bands of bases, and those are merged into the holes they describe
before being reported — so they can be measured or summed directly. The readout notes
that they exist, -v and the markdown report list them.
So inferred* is the guaranteed window and likely* is the speculative
best-guess, always contained within it. Memory-KASLR regions
(memory_kaslr) carry the same guaranteed min/max and an optional
nested likely object.
Which quantities appear is a property of the machine, not of the run: an
architecture that does not randomize its vmalloc base has no such unknown and
reports none, while one that does reports it whether or not this run narrowed it
— an un-narrowed quantity states the architectural window, which is itself an
answer. So a key is absent only where the target has no such unknown, and its
presence never depends on how much a particular run learned. va_bits follows
the same rule: it appears only where the architecture admits more than one
address-space size.
Where this build does not model the kernel it is running on, the report is
still written and a warning is printed on stderr naming both machines. Replaying
is not checked that way: uname describes the host doing the analysis rather
than the machine a capture came from, so pairing a capture with the build that
models it is the job of extra/validate-bundle and tests/replay, which read
the capture's own record and run the right binary.
The environment object is the recon vantage. lsm, selinux and
security_context are null when this vantage cannot read them, which is not
the same as their being absent — an enforcing policy commonly hides its own
state, and mac_enforcing is the only one of the four that asserts anything.
| field | value |
|---|---|
container |
the container runtime detected, or null |
seccomp |
the process's seccomp mode, or null |
lsm |
the active LSM list, or null |
selinux |
enforcing or permissive, or null when unavailable |
security_context |
the process's security context, or null |
mac_enforcing |
true only where a policy was observed actively confining this process |
uid / euid / gid / egid |
the process's ids, null together when they could not be read — 0 is a real uid, so no value in the field could mean "unknown" |
groups |
supplementary gids, or null when unreadable; groups_truncated is present and true when the process holds more than the report keeps |
group_gated_sources |
those groups kasld knows gate a source it reads, as {gid, name, gates}; the numeric groups list stays the authority |
user_namespace |
which user namespace that capability set is effective in: initial, nested, or unknown where /proc/self/uid_map could not be read. The kernel checks that gate these sources resolve against the initial namespace, so nested and unknown both mean no capability is credited |
capabilities |
the process's capability set, or null. Carries a qualifier where the set is not the one those checks consult — "full (user namespace only)" |
no_new_privs |
the no_new_privs bit, or null |
readable_oracles |
a map of the sources probed for readability — the /proc oracles, the system logs, the two systemd journal trees, debugfs, and the /boot System.map and config, the last two keyed by the path including the running kernel release. A journal tree is keyed by the tree but probed by the active system journal inside it: the tree's directories are listable by every account, while the records' gate is on the files, whose path carries the machine ID and is never printed. Each entry carries readable (false for every state that is not a successful read) and status, one of readable, denied, absent or unknown — the same distinction the text readout draws |
cap_reachable_leaks |
the capability-gated leak sources the effective capability set unlocks, as {capability, source} |
The same
caveat applies to the oracle map: under such a policy a refusal is commonly
indistinguishable from an absence at the point of the probe, so a document whose
oracles are nearly all unknown describes a target that is confined, not a
collection that went wrong.
The groups array carries the leak evidence, one object per (type,
section, region) — the same split the text readout prints as separate
blocks.
| field | value |
|---|---|
type / section / region |
the group's key; display carries the section's printed name |
consensus |
the most base-like address in the group |
consensus_method |
the method of the consensus record, or unknown |
consensus_sources |
how many distinct sources contributed |
conflicts |
how many records disagree with the consensus |
interior_only |
true when the group carries only interior samples and no edge. consensus is then the lowest sample and lo/hi bound the corroborated span rather than a resolved base, and conflicts is 0 — interior samples do not compete |
lo / hi |
the group's span |
results |
the group's own records, each carrying raw, aligned, pos, region, name, origins, its method, and valid — whether the record passes the layout bounds check |
Every aggregate above is computed over the named region's records alone, never
across the other regions sharing its section. Several regions routinely share
one section — dram alone spans ram, initrd, cmdline, acpi_table and
more, whose bases are unrelated — so section is not a unique key, and
region is what tells the groups apart.
The components array holds one record per component.
| field | value |
|---|---|
name |
the component's name |
exit_code |
the exit status it returned |
elapsed_ms |
wall milliseconds it ran for, over the span the per-component timeout bounds — what shows where a run spends its time, and which techniques sit close enough to their budget that a slower machine would cut them short. Wall, not cost: a component run in a parallel phase reports time it spent waiting as well as working, so -w 0 is what attributes a figure to the component alone |
outcome |
what the orchestrator made of that status |
disposition |
optional — why a component produced no tagged result: category, one of mitigation / absent / disabled / inconclusive, plus, for a mitigation, the gate it confirmed and an optional message |
meta |
the parsed KASLD_META, including the cve / patch / config / sysctl keys |
explain |
optional — the component's KASLD_EXPLAIN text, present where it embeds one |
output |
the component's own output lines; emitted under --verbose only |
The hardening object is described under
Hardening assessment.
The excluded_components array names what the run held back and why —
{name, reason}, with reason one of skip_pattern (a --skip pattern named
it), live_under_capture (its result comes from live runtime state, and the
facts came from a captured tree) or experimental (opt-in, -x enables it). It
is always present and empty when everything ran, so a reader can tell a
component this build does not have from one this run chose not to run — and can
answer what -x would add without running it.
A per-component patch worklist — {component, cve, fixed_in, leaked_here}
— is a direct projection of the components array:
kasld -j | jq '[.components[]
| select(.meta.cve or .meta.patch)
| {component: .name, cve: .meta.cve, fixed_in: .meta.patch,
leaked_here: (.outcome == "success")}]'-m (--markdown) formats the summary for issue trackers (GitHub / GitLab
markdown tables). It carries the same Layout table as the text readout,
with the same five columns and the same rows — both are rendered from one row
model, so the two views cannot describe the same resolved state differently.
Beneath it a short table holds what those columns have no room for: _stext
where a quantity has one, the compile-time default a slide is measured from,
and the Phys/Virt coupling classification. When the kernel-text function
order is reordered (FG-KASLR / LTO / AutoFDO / Propeller), a Caution note
warns that a leaked address no longer resolves the rest of the symbols via a
generic System.map.
When KASLR is disabled or unsupported there is no slide,
so the resolved quantities themselves are the answer: the same rows the Layout
table would carry, written as lines rather than a table because with nothing
randomized the Candidates and Grain columns hold nothing. They are drawn
from the same row model, so a posture that reports fewer quantities than another
format is not possible. A remark follows where the compile-time default is not
the resolved base, saying whether the evidence rules the default out.
The leak table credits the component(s) that produced each address. With
--verbose, a ## Address space section
embeds the virtual and physical ASCII address-space maps in a fenced code
block (the same diagrams the text readout draws). An ## Environment
section reports the recon vantage (container / confinement / readable
oracles). With -H it also appends the hardening assessment (see below).
The --explain (-e) flag prints a brief technique explanation before
each component runs. Each component embeds a plain-text explanation in a
dedicated ELF section (.kasld_explain) via the KASLD_EXPLAIN() macro.
The orchestrator reads this section from the binary without executing it
and displays it inline.
This mode implies --verbose.
$ ./kasld --explain
...
[dmesg_free_reserved_area]
Searches dmesg for 'Freeing ... memory' messages from free_reserved_area()
that print kernel virtual addresses. These messages were removed in v4.10.
On older kernels, they reveal kernel text and init section virtual addresses.
Access is gated by dmesg_restrict.
-> unavailable (feature/hardware not present)
...
The --hardening (-H) flag appends a post-run hardening assessment that
evaluates the system's KASLR defenses based on the component results and
their machine-readable metadata. It opens with Confirmed active mitigations
— controls a component observed to defeat its leak this run, keyed by the gate
(kpti, an MDS hardware fix, a hardening CONFIG); this is the
runtime-observed complement to the sysctl gates, and appears in json as
hardening.confirmed_mitigations. The section is always present, empty
included: it lists only controls that stopped a technique and named
themselves, so it is never a survey of the target's defenses, and an empty list
says no technique was stopped that way rather than that nothing is defending the
kernel. It is followed by seven analysis sections:
-
KASLR posture (only when degraded) — surfaces a runtime KASLR state that downgrades effective slot entropy to 0 bits. Fires on "randomization failed" boot conditions (the boot stub attempted KASLR but could not produce a random offset: missing entropy seed, no PRNG, insufficient memory). The kernel still relocates but lands at a firmware-/boot-stub-deterministic position rather than the link-time default — meaningfully different from a deliberate opt-out, which the main results banner already reports. The full set of runtime states is catalogued in kaslr.md — KASLR runtime states. Omitted when KASLR is healthy or opted out.
-
Active defenses — runtime security settings detected on the system (
dmesg_restrict,kptr_restrict,perf_event_paranoid,unprivileged_bpf_disabled,%pKpointer hashing, lockdown mode) and their current values, with the components each one blocked and those that got past it — as a bypass, or as exempt where they went through a privilege of this account's own that the gate's own check accepts. -
Available hardening — actionable suggestions for settings that are not currently active but would block one or more successful components (e.g. "Set
kernel.dmesg_restrict= 1" if dmesg-based leaks succeeded). When the engine resolves a guaranteed base window, each suggestion is scored by re-resolving it: the section anchors on the current versus fully-hardened residual entropy, then reports, per suggestion, how much of that gap it is load-bearing for.verdict meaning load-bearing — omitting forfeits N bits closing the others is not enough without this one recovers nothing the gate governs components, but none leak the base speculative window only the leaks do not narrow the guaranteed base — the honest reading on a host whose guaranteed posture is already maxed not required the base is recoverable, but the remaining suggestions already reach the same guaranteed posture The numbers are deliberately non-additive: redundant leaks each read as load-bearing because closing any single one still leaves the base pinned.
-
Patched vulnerabilities — components that target known CVEs. Shows how many are patched (returned no result or unavailable) versus unpatched (successfully leaked), with CVE identifiers and patch versions.
-
Compile-time attack surface — successful components that exploit kernel features enabled at compile time (e.g.
CONFIG_E820_TABLE,CONFIG_EFI), grouped by address type (physical vs. virtual). A component that depends on several options is listed once, carrying all of them: the options are a conjunction, so the count is of components and not of dependencies. In json,hardening.compile_time_surfacekeeps one object per (component, option) pair, which a consumer can group either way. -
Hardware side-channels — successful components that exploit CPU microarchitectural side channels (prefetch, EntryBleed, ZombieLoad, etc.), grouped by hardware mitigation status.
-
No known mitigation — successful components with no known sysctl gate, lockdown restriction, CVE, or kernel config dependency. These represent leak vectors that cannot be blocked by runtime hardening alone.
When the kernel-text function order can be determined, the assessment also
prints a Function layout block above these sections: text ordering
(canonical, or reordered static / per-boot) and symbol resolution (whether a
generic System.map resolves symbols, or only this build's does). Reordered
text is the FG-KASLR / reordered-text class
(LTO, AutoFDO, Propeller, or FG-KASLR): functions no longer sit at a constant
offset from _text, so a leaked address pins only its own symbol and a generic
System.map no longer locates the rest.
In JSON, the assessment is the top-level hardening object, with fields
exposure, kaslr_posture (state is one of
active / disabled / unsupported / randomization_failed),
active_defenses, confirmed_mitigations, lockdown,
available_hardening, patched_vulnerabilities, compile_time_surface,
hardware_side_channels, no_mitigation, and text_order — the last present
when the kernel-text order is determined, carrying class and
symbol_resolution (the JSON form of the Function-layout block above).
Each active_defenses and available_hardening entry carries a
surface — the enforcement lever the change lives on (sysctl,
boot_param, lsm, mac, file_permissions, or seccomp) — so a report
can route each item to the team that owns it. Kernel lockdown and a MAC policy
are both LSMs but are different levers, so they carry lsm and mac
separately.
A denial is credited to the mac surface only when the component declares
sysctl gates and none of them accounts for it, so an ordinary file-permission
denial is never reported as a policy decision.
Those checks resolve against the initial user namespace — capable() is
ns_capable(&init_user_ns, …) — so a capability set held only in a nested
namespace grants none of them, however full it looks. A rootless container
reports every capability and holds none that count here. KASLD reads
/proc/self/uid_map to tell the two apart, reports which in
environment.user_namespace, qualifies the capability string ("full (user
namespace only)"), and credits no exemption and no capability-reachable leak
where the namespace is nested or could not be established — reporting a bypass,
which overstates exposure, rather than an exemption the kernel refused.
A gate restricts what its own check restricts, and several of these checks let
a holder of a particular capability past. A component that succeeded because
the account holds one is counted exempt rather than as having got around
the gate, and the row says why — "this account holds CAP_PERFMON". What each
check accepts is read from the kernel and differs per gate: CAP_SYSLOG lifts
kptr_restrict at 1 but not at 2, where the pointer is zeroed for
everyone, and only while the reader's effective ids equal its real ones;
CAP_PERFMON or CAP_SYS_ADMIN lift perf_event_paranoid, and CAP_BPF or
CAP_SYS_ADMIN lift unprivileged_bpf_disabled, at any value they are active
at; nothing lifts pointer hashing. A component is credited only where it
declares the capability that bypasses it, so a technique that reached the same
data another way — through a set-uid helper, say — stays a bypass.
kernel.dmesg_restrict is the one gate whose source has copies on disk, so its
exemption can also be owed to a file: the copies grant root and the log-reader
groups read by design, and its row names what was read as well ("read the ring
buffer with CAP_SYSLOG", "read /var/log/dmesg as a member of adm"), naming a
group by number ("gid 1234") where nothing in the analysed tree names it. Only
a copy every account can read is a way around that sysctl: reads through one
are bypasses, and Available hardening flags every kernel log copy readable by
any account under file_permissions — from any vantage, and whatever
dmesg_restrict is set to. Whether a copy is read through a group or by the
account itself comes from its owner, mode and ACL on a live run, and only where
the identity can be compared with the kernel's own: a nested user namespace
maps a window of the ids and reports the rest as the overflow id on the file
and the account alike, so no group is named from there either. Whether any
account can read a copy is settled from its mode, which no mapping touches, so
that is answered from every vantage.
In JSON each gate carries components_exempt and an exemption object:
{"kind": "capability", "capability": ...}, {"kind": "group", "groups": [{"gid": ..., "name": ...}]} with name null for an unnamed group, or
{"kind": "account"} for the account's own access to the file.
When the engine resolves a guaranteed base window, each available_hardening
entry also carries silences (base-leaks it removes) and a projected
object (the residual entropy with every other suggestion applied, and the
bits forfeited by omitting this one), and a top-level projected_posture
reports the current and all_suggestions_applied postures.
Markdown output (-m -H) appends the same assessment as a
## Hardening Assessment section; each Available-hardening suggestion
ends with its enforcement surface as a trailing [`lever`] tag
(e.g. [`sysctl`], [`file_permissions`]).
KASLD has no built-in pass/fail flag: it measures, and the CI script
decides. A single -j blob carries every value a policy would key on —
including the whole hardening object — so a regression gate is a one-line
jq predicate, more flexible than a baked-in threshold, and it composes into
any policy. A distro or kernel builder can fail the build when a freshly
built kernel's KASLR posture regresses below policy. The same blob is the unit
a fleet layer baselines and diffs: capture one per host, compare a later run,
and alert on any guaranteed-bit regression.
The fields a gate keys on:
.kaslr.inferred.entropy_bits— the guaranteed residual entropy of the kernel base: the bits KASLD could not strip with certainty. Gate here for a sound "provably ≥ N bits" policy..kaslr.likely.entropy_bits— the speculative residual (the narrower best-guess window). Gate here to also fail when a speculative technique could plausibly recover the base, accepting that this window is unproven.slots_initial/entropy_bits_initial— the set the residual is measured against, in both units:slotsout ofslots_initialis the ratio the readout prints (1,025 of 1,025). An upper bound on the set the kernel drew from, not an identity: the count rests on an alignment this build can only bound from below, and a derived window (the direct map's) is evaluated generously because its true size turns on build options a userspace binary cannot read. The slack runs one way, so a residual stated against it never overstates what is left to search — but the starting entropy, and so the apparent reduction, can be flattering. The paging level is a set of admissible sizes rather than a window, so it carriescandidates_initialbesidecandidatesfor the same purpose —1 of 2says one of the two levels this target could be running. Both are omitted where no such set is modelled for the quantity, which is the same thing a bare count means in the readout. The physical image base is the notable omission, and permanently: where a kernel lands in physical memory is set by where the board puts DRAM, so no window is declared for it and the set the engine starts from is an address width rather than a set of placements. A gate reads itsslotsandentropy_bitsalone. The virtual one is omitted too on an architecture with no KASLR, where the same constants bound where a bootloader may have put the image rather than a set the kernel drew from. Take the ratio from the slots, not the bits:ilog2rounds, so2^entropy_bits_initialis 2,048 where the set holds 1,025.slots_upper_bound— beside everyslotsfigure:truewhere the grain the count stands on is a lower bound, so a kernel aligned more coarsely than the engine could prove sits on fewer placements thanslotssays. A gate readingslotsas an exact search space should treat atruehere as "at most this many". It is the machine-readable twin of the>=in the readout'sGraincolumn and follows the same rule, so it can differ between two runs against one host: a run that read the slot granularity reportsfalsewhere a run that could not reportstrue..kaslr.disabled/.kaslr.unsupported— booleans: KASLR opted out, or not applicable to the arch/config..hardening.patched_vulnerabilities.possibly_unpatched— CVE-class components that succeeded. A behavioural signal, not a version check: KASLD never trustsuname, so this is "a CVE-class leak worked here", never "the kernel is version X". Empty means none did.
To ask which placement the kernel actually chose — for a distribution across
boots, say — divide .kaslr.virtual.slide_bytes by the quantity's alignment.
That counts from the un-randomized base, so it describes the target. A position
counted from .kaslr.inferred.range_min instead describes how far KASLD
narrowed the window and from which direction: for a leak that bounds from
above, the base sits at that window's last slot by construction.
The exit status reports whether the scan ran, not whether the host passed:
0— ran to completion; at least one leak produced a result.1— ran to completion; no leak produced a result. This is a healthy host, not an error: the KASLR window analysis and, with-H, the hardening report are still written.2— usage error, conflicting output-format flags, or the component directory could not be found.3— the target kernel addresses memory more widely than this build can represent, which is a 32-bit build against a 64-bit kernel. No analysis is attempted, and the output carries no layout or KASLR fields.
So 0 and 1 both mean the scan is valid, and 2 and 3 mean there is no
scan. A gate takes its verdict from the JSON and reads the status only to tell
those two bands apart. Capture it with || rc=$?: under set -e, which is on
by default for a GitHub Actions run: step, a plain rc=$? on the following
line never runs, because the failing command has already killed the script.
Each gate below exits non-zero on a policy breach. One scan feeds all of them, so every gate judges the same moment rather than re-running the analysis:
set -euo pipefail
# Exit 1 is a healthy host; only 2 and 3 mean the scan did not happen.
rc=0; kasld -j > kasld.json || rc=$?
[ "$rc" -le 1 ] || { echo "kasld did not complete (exit $rc)"; exit "$rc"; }
# Fail if the guaranteed base entropy drops below 12 bits.
bits=$(jq '.kaslr.inferred.entropy_bits' kasld.json)
[ "$bits" -ge 12 ] || { echo "KASLR regressed: $bits guaranteed bits"; exit 1; }
# Fail if KASLR is off (disabled, or unsupported for this arch/config).
if jq -e '.kaslr.disabled or .kaslr.unsupported' kasld.json >/dev/null; then
echo "KASLR not active"; exit 1
fi
# Fail if any CVE-class leak succeeded.
n=$(jq '.hardening.patched_vulnerabilities.possibly_unpatched | length' kasld.json)
[ "$n" -eq 0 ] || { echo "$n CVE-class leak(s) succeeded"; exit 1; }Run KASLD unprivileged in the target environment for the posture a real
unprivileged attacker sees; running it as root additionally exposes root-gated
sources (e.g. /proc/iomem), which measure a different threat model.
To gate on drift over time rather than an absolute threshold, save a -j
snapshot as a baseline and compare a later run against it with
extra/posture-diff — a small jq-based helper that
exits non-zero if the KASLR posture regressed:
# Both captures use the status convention above: 0 and 1 are valid snapshots.
rc=0; kasld -j > baseline.json || rc=$? # once, when the host is known-good
[ "$rc" -le 1 ] || exit "$rc"
# … later, or in CI …
rc=0; kasld -j > current.json || rc=$?
[ "$rc" -le 1 ] || exit "$rc"
extra/posture-diff baseline.json current.json || echo "posture regressed"It compares only the boot-stable, security-relevant posture — guaranteed
residual entropy (virtual and physical), the KASLR posture state, unpatched
CVE-class leaks, and which defenses are off. The per-boot volatile values
(the resolved base address, slide, direct-map base) are never compared, so a
healthy reboot — which re-randomizes all of them — is not flagged; only a
genuine posture regression is. Exit 0 = no regression, 1 = regression
(findings printed), 2 = error. Snapshots can be live -j or replayed from an
extra/collect bundle
(extra/prepare-bundle <bundle> DIR && KASLD_SYSROOT=DIR kasld -j), so a
baseline captured on one host can be checked from another — provided both
snapshots are taken the same way. A
replay reaches only the components a captured tree can answer, so pairing one
with a live run measures a change of vantage rather than of posture, and
typically as an improvement, which a gate would pass. The top-level replay
flag is what separates them, and a mismatched pair is refused with exit 2
rather than diffed. A pair whose snapshots name different architectures is
refused the same way: the KASLR grid and the residual entropy are measured per
architecture, so subtracting one from the other reports a change of scale as
drift.
To scan a whole estate at once, extra/posture-summary
rolls up many -j snapshots into one table — one row per host — instead of
reading N separate reports:
# one snapshot file per host (the filename is the host label)
for h in $(cat hosts); do ssh "$h" 'kasld -j' > "snap/$h.json"; done
extra/posture-summary snap/*.jsonhost arch kernel src kaslr vbits pbits leaks defenses cves top-fix
cache03 x86_64 6.12.81 live active 9b 31b 2/71 1 0 Set kernel.perf_event_paranoid = 2
db02 aarch64 6.12.90 replay active 16b 16b 0/47 3 0 -
web01 x86_64 6.15.6 live active 9b 31b 1/70 2 1 Enable kernel lockdown (confidentiality mode)
Each row carries only the boot-stable posture — the fact source, KASLR state,
guaranteed residual entropy (virtual/physical), leaks succeeded/total, how many
distinct hardening controls were observed foiling a leak, unpatched CVE-class
count, and the most load-bearing hardening action still available. The host
label is the snapshot's filename, so each file must be named after its host at
collection time; this tool does no collection or transport itself. The filename
rather than the document's own host field, because the label is the
operator's to choose — a fleet's asset id need not be the machine's UTS name —
and because a replayed snapshot states no host of its own. Output is an aligned
text table by default, or --markdown (issue trackers), --csv
(spreadsheets), or --json (further tooling). A file that is not a valid
kasld -j snapshot is skipped with a warning rather than aborting the report.
src is the snapshot's fact source — live, replay, or ? for a snapshot
predating the field. It qualifies every column to its right: a replay reaches
only the components a captured tree can answer, so db02 above scores its 0
leaks against 47 techniques where a live row scores against 70. A mixed fleet
therefore reads as a difference of vantage, not of posture, and the column is
what separates the two. A gate cannot present the distinction and carry on, so
posture-diff refuses the same pairing
outright rather than diffing it.