Skip to content

Mte/tcf enforcement - #285

Merged
hodgesds merged 9 commits into
mainfrom
mte/tcf-enforcement
Sep 7, 2026
Merged

Mte/tcf enforcement#285
hodgesds merged 9 commits into
mainfrom
mte/tcf-enforcement

Conversation

@hodgesds

@hodgesds hodgesds commented Sep 7, 2026

Copy link
Copy Markdown
Owner

No description provided.

hodgesds and others added 9 commits September 4, 2026 12:02
`Mte::enter_domain` now sets TCF to Sync and `exit_domain` restores the
saved value, so a tag mismatch inside a BPF domain scope raises a
synchronous Data Abort. MTE enforces something for the first time.

The no-op it replaces carried a real objection: flip TCF with tags
inconsistent and the next access faults, the handler re-faults, and the
CPU loops with nothing on the console. That reasoning treated tag checking
as CPU-wide. It is a property of the page — only `ATTR_TAGGED` pages are
checked, and only the arena's are mapped that way, so the kernel stack,
text, heap and page tables stay unchecked whatever TCF says. The blast
radius is exactly the arena, which is what makes the flip survivable.

Two fixes had to land with it, neither in `enter_domain`:

The interpreter addressed the arena untagged. `ProgArena::resolve`
returned `arena.kva() + off`, and the interpreter dereferences that from
inside the scope. It is the more dangerous of the two paths: the EL1 abort
handler's exception-table lookup is keyed on ELR_EL1 with no DFSC gate, so
a tag fault in JIT'd text recovers into the arena-fault epilogue and is
reported, whereas an interpreter fault has no entry, falls past
`probe::consume`, and is fatal by design. `resolve` now returns the tagged
alias, giving the interpreter parity with the JIT's tagged slot base.

`write_sctlr_el1` issues only compiler fences. A system-register write is
not context-synchronising, so without an `ISB` the first accesses after
the flip run under the old mode — unchecked where they were meant to be
checked, and still checked after the scope was supposed to end. Both
directions now synchronise.

`smoke_bpf_arena_untagged_access_faults_in_scope` is three controls and an
assertion, because each guards a different way this could pass while
broken: the untagged read must work with TCF=Ignore (else the page is
merely unmapped), the *tagged* read must work inside the scope (a fault
there is a regression, not enforcement), the untagged read inside the
scope must fault with DFSC 0b010001 specifically rather than any abort,
and the same access outside the scope must still succeed with TCF back to
Ignore — without which the test would pass equally on a system where
checks were simply always on.

That test also answers a question the PCID work could not: QEMU's TCG does
implement MTE tag checking, so this path genuinely executes on an
available runner rather than being reviewed-and-unrun.

Step 5 is deliberately not included. Reporting `Mte` would overclaim: the
flip is wired into `bpf::domain::enter` only, while driver domains go
through `modules::domain::enter`, which gates on PKS or PCID — both false
on aarch64. What is enforced is arena-vs-not-arena for BPF, not
driver-domain isolation, and naming an enforcer that does less than its
name implies is the defect this whole specification exists to prevent. The
doc records the choice at v0.4 rather than deciding it here.

aarch64 6072 pass / 0 fail / 39 skip; x86_64 8099 / 0 / 77.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Steps 1-4 made MTE enforce something, so on the original plan `Mte` would
now be the reported enforcer. It would still overclaim.

The TCF flip is wired into `bpf::domain::enter` and nowhere else. Driver
domains are entered through `modules::domain::enter`, which gates on PKS
or PCID -- both false on aarch64 -- so a module's `init()` and `exit()`
run with no confinement at all. What is enforced is arena-vs-not-arena for
BPF, which is what `Arena::tag`'s own documentation says the tag buys. It
is not driver-domain isolation, and `DomainBackend::Mte` claims the
latter: an operator reading `domain enforcer: mte` concludes their driver
domains are isolated.

So the backend stays `Unenforced` and boot prints the scope on a second
line: driver domains are not isolated because no enforcer is wired into
module domain entry, and MTE tag checking is active for the BPF arena.
Two lines rather than one sentence, because they describe different scopes
and folding them together invites reading the second as a qualifier
softening the first. A reader needs both -- the first is what they must
not rely on, and without the second the `mte=on` in the feature line
directly above looks like dead configuration.

The more interesting finding is that **nothing in this tree asserted
anything about `effective_backend()`**. `smoke_arch_backend` checks the
compile-time `BACKEND` constant, which is the architecture's nominal
backend and is allowed to differ from the runtime report -- that
difference is the entire point of having both. Nothing checked the report.

That absence is why the same mistake landed three times: PCID selected
with CR4.PCIDE clear, PKS and PCID documented as giving the same
correctness, and this aarch64 arm naming an enforcer twice over. Each was
caught by someone reading code, never by a test.

`smoke_aarch64_report_matches_enforcement` closes that. It pins both
halves of what boot claims -- the report is `Unenforced`, and on a CPU
with MTE `enter_domain` really does set TCF=Sync with `exit_domain`
restoring it. Asserting only the first would let the pair drift into
honestly reporting NONE on a machine where the flip had silently stopped
happening, which is right about the enforcer and wrong about the arena.
Changing the report to `Mte` now fails a test rather than a review.

The alternative stays on the record in the spec: extending the flip to
`modules::domain::enter` would make `Mte` truthful, but needs its own
access audit, since module code touching any tagged page inside its scope
would begin to fault and there is no single chokepoint to tag the way BPF
has one.

Spec updated to v0.5; steps 1-5 complete.

aarch64 6073 pass / 0 fail / 39 skip; x86_64 8099 / 0 / 77.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extending MTE enforcement from the BPF arena to driver domains was
implemented and backed out. The page-level half works and was verified;
the addressing half does not, and the two cannot land separately. Recording
it at v0.6 so the next attempt starts from the blocker rather than
rediscovering it.

Three findings worth the write-up.

x86 module isolation is real — `protection_key` is wired through
`leaf_flags(domain)` into `alloc`, so module pages genuinely carry pk(D).
This is aarch64 catching up, not a tree-wide overclaim.

The tag space is one short and the obvious sacrifice is wrong. Fifteen
usable tags (15 is what every untagged kernel pointer reads as) must cover
sixteen domains, so one domain goes untagged. It has to be FRAME, mapping
D to D-1: FRAME is the TCB, its memory is not ATTR_TAGGED anyway, and
`enter_domain` leaves it reachable from everywhere regardless. The identity
mapping looks equivalent and is not — `DomainId::SCRATCH` is 15, a real
driver domain and the one the module tests use, so sacrificing it made the
only test that verifies tagging skip instead of run. The mechanism looked
delivered while doing nothing where it would most likely be exercised.

The blocker is the relocator. For a module's accesses to match its tagged
granules it must run at a tagged VA, and relocating against
`tagged_base()` breaks loading: `relocator.rs` computes
`(target - place) >> 2` for CALL26/JUMP26 where `target` is an untagged
kernel symbol (tag 15) and `place` sits at tag D-1, so the difference
carries (15 - (D-1)) << 56, every call overflows the ±128 MiB bound, the
PLT exhausts, and a real rustc-built .ko is rejected. This is the
addressing-contract question the spec already asks, arriving for real: for
the JIT it dissolved because nothing compares addresses, but the relocator
compares constantly. The contract it needs — displacements computed on
untagged addresses, stored absolute addresses tagged — spans CALL26,
JUMP26, ADRP/ADD and PREL32, and is the actual content of step 6.

The halves cannot land separately: TCF is per-CPU and checks are per-page,
so while a BPF scope holds TCF=Sync, an interrupt into driver code
touching its own tagged data through an untagged pointer faults fatally,
with no extable entry for module code.

Code reverted to the committed state; this commit is documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scoping pass for step 6, over `apply_aarch64` and the veneer pre-check,
before any code moves. The question per type is what it does with `val`
and `place`: with the image relocated against a tagged base, `place`
carries tag D-1 while a kernel `val` carries the untagged 15.

Five of eighteen arms change -- PREL64, PREL32, CALL26, JUMP26 and
ADR_PREL_PG_HI21, all of which compute a displacement between the two.
ADRP is in the list because masking the low 12 bits does not clear bit 56,
which is easy to miss when reading it. Plus one site outside the table and
it is the one that actually failed: `relocator.rs:188` duplicates the
CALL26/JUMP26 range check to decide on a PLT veneer, so left tagged every
call appears to overflow, every call demands a veneer, and the PLT
exhausts.

The absolute forms are deliberately left alone. ABS64 and MOVW_UABS_G3 are
not oversights to fix later, they are the mechanism: an in-module symbol
relocated absolutely yields a tagged pointer, which is what makes a
module's own data accesses match its granules, while a kernel symbol
yields an untagged one, which is equally right because kernel pages are
ATTR_NORMAL and unchecked.

ADRP at runtime looks like a problem and is not, so the doc says why. The
relocation encodes a displacement computed untagged, but the CPU adds it
to the live PC, which is tagged because the module executes at a tagged
VA. In-module targets resolve tagged and match; kernel targets resolve to
a kernel address carrying a stray tag, which nothing checks. Only the
link-time displacement needs untagging.

Also noted outside the relocator: `is_module_va` range-checks against
MODULE_VA_BASE and would reject a tagged PC, so address-to-module
attribution needs to untag. Diagnostic-only, but it fails silently.

Marked proven versus inferred rather than presenting the table as uniform
fact. Only CALL26 is demonstrated -- it is what the failing load hit. The
rest are read off the arithmetic, and PREL32/PREL64 may not appear in a
real .ko at all; the existing comment in `reloc.rs` records that MOVW_UABS
was added only because a rustc-built module used it where a synthesized
test ELF did not. The first task in step 6 is to log which types the
reference module actually emits, so an untestable fix is marked as such
instead of counted as covered.

Documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prerequisite for tagged module images, and a correctness fix on its own
terms: `apply_aarch64` subtracts `place` from `val` in five forms, and
those two operands are not required to live in the same tag space.

With a module image relocated at a VA carrying its MTE domain tag, `place`
carries tag D-1 while a kernel symbol carries the untagged 15. The
difference is then off by `(15 - tag) << 56` -- not a rounding error but a
57-bit one. Every CALL26 overflows its ±128 MiB bound, every call demands
a PLT veneer, the PLT exhausts, and a real rustc-built .ko is rejected.
That is exactly how the first attempt at tagged module images failed.

`untag_kernel` canonicalises the top byte so both operands land in the
same space, and feeds the five displacement forms: PREL64, PREL32,
CALL26, JUMP26 and ADR_PREL_PG_HI21. ADRP is in that list because masking
the low 12 bits does not clear bit 56 -- it is the arm that most looks
like it already sanitises its input.

The absolute forms deliberately keep the tag. ABS64 and MOVW_UABS_G3 are
not oversights deferred to later, they are the mechanism: an in-module
symbol relocated absolutely yields a tagged pointer, which is what will
make a module's own data accesses match its granules, while a kernel
symbol yields an untagged one, equally right because kernel pages are
ATTR_NORMAL and unchecked.

`relocator.rs`'s veneer pre-check applies the identical rule to the
identical operands. It duplicates the CALL26/JUMP26 range test to decide
whether a veneer is needed, so the two must agree or a load fails for a
reason the relocation itself would not have hit.

Deliberately not applied to `apply_x86_64`, which has the same
subtractions two functions above. It would be a no-op there -- x86 kernel
VAs already have bits 63:48 set -- but the paths should not look like they
share a contract they do not. Worth stating because the naive edit is a
tree-wide replace: the pattern occurs five times in this file and two of
them are x86's.

Tested by property rather than by module load. A census of the reference
.ko shows it emits only CALL26 and JUMP26 of the five affected forms --
PREL32, PREL64 and ADR_PREL_PG_HI21 do not appear in it at all -- so a
load-driven test would leave three of five fixes unexercised while looking
like coverage. `smoke_reloc_displacements_ignore_the_place_tag` calls
`apply_aarch64` directly, twice per form, and requires byte-identical
output whether or not the place is tagged. Two guards against a vacuous
pass: the untagged run must succeed, or both could fail identically; and a
control asserts ABS64 is NOT tag-invariant, since a blanket untagging that
also stripped it would break the mechanism while satisfying every other
assertion.

aarch64 6074 pass / 0 fail / 39 skip; x86_64 8099 / 0 / 77.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`encode_veneer` computes `(dst & !0xFFF) - (pc & !0xFFF) >> 12` and bounds
it to ADRP's ±4 GiB. `dst` is a kernel symbol carrying the untagged 15;
`pc` is a veneer address inside the module image, which carries the
module's MTE domain tag once images are tagged. The difference is
`(15 - tag) << 44` pages out, so every veneer refuses to encode,
`veneer_for` returns None, and the load dies as `PltExhausted` -- an
arena-full error for a PLT that is empty.

Third site in the same class, after `apply_aarch64` and the relocator's
veneer pre-check. The audit that found the first two missed this one
because it went file by file through the two places the arithmetic was
expected to live, rather than grepping for the arithmetic itself. Worth
recording: the pattern, not the location, is what identifies these.

Correct at run time as well as link time. The CPU adds this displacement
to the live tagged PC, so x16 ends up holding the right address with a
stray tag in bits 59:56, and `BR` is an instruction fetch, where TBI
ignores the top byte. The paired ADD uses `dst & 0xFFF` and never sees the
tag at all.

Also makes two failures say what they are, because diagnosing the above
took two full suite runs to get from "rejected" to "PltExhausted":

  * `sys_init_module` failures discarded the error entirely and reported
    "rejected a real rustc-built .ko" -- indistinguishable between a
    malformed file, an unresolved symbol, a relocation overflow and a
    exhausted PLT. Each `LoadError` and `RelocatorError` variant now names
    itself. This is what pointed at `plt.rs`.
  * A relocation that fails to apply now prints its type and both
    operands. `ApplyFailed(Overflow)` alone says a relocation did not fit
    and nothing about which one, which is most of the work.

No behaviour change without tagged module images: untagging an already
untagged address is the identity.

aarch64 6077 pass / 0 fail / 39 skip; x86_64 8099 / 0 / 77.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…force

Driver domains were unconfined on aarch64. x86 has carried real isolation
for a while -- `protection_key` is wired through `leaf_flags(domain)` into
`alloc`, so module pages genuinely hold pk(D) -- while aarch64's arm
dropped the domain outright: "the domain does not travel in the leaf
here". `modules::domain::enter` gated on PKS or PCID, both false here, so
a module's init() and exit() ran with nothing narrowed.

aarch64 has no per-page key, so the domain travels as the memory *type*.
Module images map `ATTR_TAGGED` and `alloc` writes the domain's tag to
every granule; the loader relocates against `ModuleImage::tagged_base()`,
so absolute relocations carry the tag and ADRP-computed addresses inherit
it from the executing PC; and `modules::domain::enter` flips
`SCTLR_EL1.TCF` to Sync. A pointer into an image derived from any other
domain then faults. `base` stays untagged for this module's own
bookkeeping -- page indices, teardown extents, the VA bitmap -- the same
split `ArenaGroup` makes.

Three details that are easy to get wrong and were:

`ATTR_TAGGED` replaces `ATTR_NORMAL` rather than being added to it.
AttrIndx is a 3-bit field, not a bitmask; passing both ORs two indices
into a third.

Granule tagging runs after the trap-fill, not before. The fill goes
through the untagged VA, and a store via a non-tagged alias may leave a
granule's tag UNKNOWN.

`domain_tag` maps D to D-1, making FRAME the untagged domain. MTE has 16
tags and NARF has 16 domains, but tag 15 is what every untagged kernel
pointer reads as, so fifteen usable tags must cover sixteen domains and
exactly one goes untagged. FRAME is the right sacrifice: it is the TCB,
its memory is not ATTR_TAGGED anyway, and `enter_domain` leaves it
reachable from everywhere by design. The identity mapping looks equivalent
and is not -- `DomainId::SCRATCH` is 15, a real driver domain and the one
the module tests use. Choosing it protected nothing where the mechanism
was most likely to be exercised, and made the only test that verifies
tagging skip rather than run.

`is_module_va` untags before its range check. A module PC now carries a
tag, and without this a module frame silently stops being recognised as
one in a backtrace.

Verified by readback rather than by absence of failure, because a
memory-attribute bug is invisible under QEMU -- a wrongly-attributed page
still reads and writes. `smoke_module_text_pages_are_tagged_for_their_
domain` reads the leaf's whole AttrIndx field and `ldg`s the tag from byte
0 and a mid-page granule; `smoke_module_text_domains_get_distinct_tags`
pins the mapping and that only FRAME is exempt; and
`smoke_module_domain_untagged_access_faults_in_scope` is the enforcement
itself -- untagged pointer in scope faults with DFSC 0b010001, tagged
pointer in scope works, untagged pointer outside the scope works, TCF
restored on exit. The last three controls matter: without them the test
would pass equally on a machine where tag checking was simply always on.

aarch64 6077 pass / 0 fail / 39 skip; x86_64 8099 / 0 / 77.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend reported `Unenforced` because the TCF flip reached only
`bpf::domain::enter` -- MTE guarded the BPF arena while driver domains ran
unconfined, so naming `Mte` would have told an operator their modules were
isolated when they were not. Module images now carry per-domain tags and
`modules::domain::enter` enforces, so the claim holds and the report says
so.

`smoke_aarch64_report_matches_enforcement` is inverted rather than
deleted, and that is the intended shape. It asserted `Unenforced`
*because* enforcement was partial; it now asserts the report tracks the
hardware -- `Mte` where MTE exists, `Unenforced` where it does not. The
report and the enforcement behind it move together, so a regression in
module confinement fails this test instead of quietly widening the claim.
It remains the only test in the tree that asserts anything about
`effective_backend()`, which is why the same overclaim landed three times
before it existed.

Boot prints the exemption on its own line: FRAME is untagged, because
fifteen usable tags cannot cover sixteen domains when tag 15 is what every
untagged kernel pointer already reads as. Giving up the TCB is the right
trade -- its memory is not Tagged Normal and `enter_domain` leaves it
reachable by design -- but it is still an exemption, and an operator
should not have to read the source to find it.

Spec to v1.0, with two things recorded rather than smoothed over: the
relocation audit missed `plt.rs` because it searched the two files where
the arithmetic was expected instead of searching for the arithmetic, and
what MTE still does not give -- isolation against a *derived* pointer, not
a capability boundary, with the module heap, the exported ABI globals and
the kernel stack all plain Normal and unchecked.

aarch64 6077 pass / 0 fail / 39 skip; x86_64 8099 / 0 / 77.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`narf_arch_current_domain` returned a hardcoded 0, so `current_domain()`
answered `FRAME` no matter what was running. That is not a harmless stub.
`block::encrypted` asserts it runs as `DomainId::KEYS` -- its constructor
exists to enforce that -- and the assertion has been passing by comparing
FRAME against FRAME. Same shape as the other three overclaims this branch
has corrected: a check that reads as protection and verifies nothing.

Domain scopes now record themselves. A per-CPU `CURRENT_DOMAIN`, written
by the enter/exit pairs in `modules::domain` and `bpf::domain`, which are
the only two places a scope opens.

Deliberately not derived from IA32_PKRS or SCTLR_EL1.TCF. Those registers
say what is *permitted*, and on a CPU where no backend is active they say
nothing at all, whereas the question here is which domain's code is
running. That also means the answer is correct on hardware without PKS or
MTE, which is why the test is arch-neutral rather than gated.

`enter` returns the displaced domain and `exit` restores it rather than
resetting to FRAME, because scopes nest: a BPF program invoked from inside
a module's `init()` opens a second one. The test covers the nesting
explicitly -- resetting to FRAME looks correct in any single-level test.

Also the prerequisite for per-domain heap tagging, which has to know whose
allocation it is making.

A note on the test, because it nearly shipped as a false positive. It
first landed nested inside an aarch64-gated function -- a nested `fn`,
which still compiles and still registers through `kernel_test_in!`. It ran
and passed on aarch64 while never existing on x86, so a test whose own doc
comment claims arch-neutrality was verifying one architecture. What caught
it was the x86 count not moving when an arch-neutral test was added, and
the name being absent from the x86 log. Now at module level and running on
both.

aarch64 6078 pass / 0 fail / 39 skip; x86_64 8100 / 0 / 77 (+1 each).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hodgesds
hodgesds merged commit 671e1e9 into main Sep 7, 2026
5 checks passed
@hodgesds
hodgesds deleted the mte/tcf-enforcement branch September 7, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant