Mte/tcf enforcement - #285
Merged
Merged
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.