Target: the ID-mapping algebra and admission policy of
kernel/user_namespace.c, plus the shared struct uid_gid_map machinery it
exposes to fs/mnt_idmapping.c.
Reference tree: ~/linux-full at v7.2-rc7-687-ge172eee25cfe. Every line
citation is against that commit.
Status: specification only. No Rust written, no proof attempted, no theorem
proved. Statements below are goals, and are marked with the assumptions they
will rest on.
Notation: 2^32 is written W. u32 arithmetic is mod W unless stated.
⊥ denotes the failure sentinel (u32)-1 = W - 1.
An extent is a triple over u32:
Extent := { first : u32, lower_first : u32, count : u32 }
An extent denotes the affine partial function on u32
⟦e⟧ : u32 ⇀ u32
⟦e⟧(x) = (x - e.first) + e.lower_first if e.first ≤ x < e.first + e.count
undefined otherwise
with the upper interval U(e) = [e.first, e.first + e.count) as its domain
and the lower interval L(e) = [e.lower_first, e.lower_first + e.count) as
its image. Both are intervals over the integers, not over u32 — §1.3
(NOWRAP) is what makes that identification legitimate.
An abstract map is a finite sequence of extents:
Map := List Extent -- order matters: it is the C array order
The two directions the kernel computes:
down⟦m⟧(x) = ⟦e⟧(x) for the unique e ∈ m with x ∈ U(e); ⊥ if none
up⟦m⟧(y) = ⟦e⟧⁻¹(y) for the unique e ∈ m with y ∈ L(e); ⊥ if none
"the unique" is a claim, discharged by WF-DISJ-UPPER / WF-DISJ-LOWER below.
Range forms take a count and require the whole range to land in one extent —
this is cmp_map_id's id2 = key->id + key->count - 1 (255) plus the
four-way containment test at 267–268:
downR⟦m⟧(x, n) = ⟦e⟧(x) for the unique e with [x, x+n) ⊆ U(e); ⊥ if none
upR⟦m⟧(y, n) = ⟦e⟧⁻¹(y) for the unique e with [y, y+n) ⊆ L(e); ⊥ if none
map_id_down (338) is downR(·, 1); map_id_up (404) is upR(·, 1).
WellFormed : Map → Prop is the conjunction of the following. Each clause is
tagged with the C line that establishes it, so the predicate is auditable
against the write path rather than invented.
| Tag | Statement | Established at |
|---|---|---|
WF-LEN |
`1 ≤ | m |
WF-COUNT |
∀ e ∈ m. e.count ≥ 1 |
implied by WF-NOWRAP |
WF-NOWRAP-U |
∀ e ∈ m. e.first + e.count ≤ W - 1 |
map_write 1031 — see §1.2.1 for why the bound is W-1, not W |
WF-NOWRAP-L |
∀ e ∈ m. e.lower_first + e.count ≤ W - 1 |
map_write 1033 |
WF-NOSENT |
∀ e ∈ m. e.first ≠ ⊥ ∧ e.lower_first ≠ ⊥ |
map_write 1024 (redundant given WF-NOWRAP; see §1.2.1) |
WF-NOSENT-IV |
∀ e ∈ m. ⊥ ∉ U(e) ∧ ⊥ ∉ L(e) |
corollary of WF-NOWRAP-U/L, not a separate check |
WF-DISJ-UPPER |
∀ i ≠ j. U(mᵢ) ∩ U(mⱼ) = ∅ |
mappings_overlap 771–774, called at 1038 |
WF-DISJ-LOWER |
∀ i ≠ j. L(mᵢ) ∩ L(mⱼ) = ∅ |
mappings_overlap 776–779 at 1038 — but see §1.5: only for pre-remapping values |
WF-REVERSE-LIVE |
` | m |
map_write 1031 reads, in u32 arithmetic:
if ((extent.first + extent.count) <= extent.first)
goto out;Working out what this accepts, rather than assuming it means "no wrap":
first + count < W(no wrap): the sum exceedsfirstiffcount ≥ 1.first + count = W: the sum is0, and0 ≤ first— rejected. (first = 0cannot reach this case, sincecount ≤ W-1.)first + count > W: the sum isfirst + count - W ≤ first— rejected.
So the check accepts exactly count ≥ 1 ∧ first + count ≤ W - 1.
Brute-forced over the boundary values plus 200 000 random (first, count)
pairs: the characterisation holds with no exceptions. Three consequences:
WF-NOWRAPis≤ W - 1, not≤ W. I had it as≤ W, which is the weaker "no wrap" reading. The real bound is one tighter.⊥is structurally excluded from every interval (WF-NOSENT-IV). SinceU(e) = [first, first+count)andfirst + count ≤ W - 1 = ⊥, the sentinel is never inU(e); the same forL(e)via 1033. Verified by brute force over 200 000 accepted extents. This eliminates the sentinel ambiguity entirely for any map installed throughmap_write— see §4.3, where it was previously flagged as an open question.- 1024 is redundant given 1031 for
first:first = ⊥withcount ≥ 1forcesfirst + count ≥ W, which 1031 rejects. Same forlower_firstvia 1033. 1024 runs first and is defense-in-depth, not the load-bearing check. Worth stating because a reader (or a future patch) might otherwise take 1024 to be what excludes the sentinel; it is not.
WF-COUNT also comes out of this same comparison rather than a separate check:
count = 0 makes the sum equal first, so the <= fires. The kernel's comment
at 1028–1030 claims both facts for the one comparison, and it is right.
WellFormed is established by map_write, but that is not the only way a
uid_gid_map comes to exist. Enumerated, because a property proved about
map_write's output covers a map only if map_write produced it:
| Source | Map value | Covered? |
|---|---|---|
map_write (980–1102), via proc_{uid,gid,projid}_map_write 1120/1137/1154 |
validated, WellFormed |
yes — this is the spec's subject |
init_user_ns (kernel/user.c 37–68) |
static (first=0, lower_first=0, count=4294967295), nr_extents=1 |
yes, and it satisfies WellFormed: 0 + (W-1) = W-1 ≤ W-1. Note count is W-1, not W, so the identity map deliberately does not map ⊥. |
copy_mnt_idmap (219), via alloc_mnt_idmap (287, 298–300) |
a copy of a map_write-installed userns map |
yes, inherited — alloc_mnt_idmap copies mnt_userns->{uid,gid}_map, which came from map_write |
nop_mnt_idmap / invalid_mnt_idmap (fs/mnt_idmapping.c 31, 40) |
nr_extents = 0; both are kzalloc-shaped statics with only .count set |
no, and they never reach the algebra: make_vfsuid short-circuits on pointer identity at 86–89 before touching uid_map, returning the kuid unchanged or INVALID_VFSUID. The initial_idmapping(fs_userns) test at 90 likewise bypasses from_kuid. |
The last row matters for the model's entry conditions: the algebra is only ever
called on a map with nr_extents ≥ 1, because the two nr_extents = 0 statics
are filtered by pointer comparison upstream. That is an entry-condition
hypothesis of F1, not something the algebra itself enforces —
map_id_range_down on an empty map would simply return ⊥, which is safe but
would be the wrong answer for nop_mnt_idmap (identity), and the reason it is
never asked.
The C is a union (include/linux/user_namespace.h 25–35): the first 16 bytes
of extent[5] alias forward/reverse, while nr_extents sits at offset 60,
outside the aliased region. Two candidate Rust models:
Model F (flat). { extents: [Extent; 340], nr_extents: u32 }. The abstract
map is extents[0 .. nr_extents]. Simple, certainly translatable, and
conditionally faithful — the condition being A-UNION (§3).
Model T (two-armed). Mirror the union:
{ base: [Extent; 5], heap: [Extent; 340], reverse: Option<[Extent; 340]>, nr_extents: u32 },
with insert_extent's migration modelled including the clobber of base[0]
and base[1].first, and every read dispatching on nr_extents <= 5 exactly as
the four C readers do (map_id_range_down_base 300, map_id_range_up_base
349, mappings_overlap 761–764, verify_root_map 899–902).
Recommendation: Model T, falling back to Model F. Under Model T the union
side condition stops being an assumption and becomes a theorem
(NO-STALE-READ, §4.6): no read ever observes a clobbered slot. That converts
the single largest faithfulness risk in the target into a proof obligation,
which is the whole point of the exercise. The cost is more Rust and a real
chance Aeneas rejects it — insert_extent's migration is a memcpy-then-alias
pattern, and the clobber is the construct that #1199 is about. Phase 3 attempts
Model T first, as a bounded experiment, and falls back to Model F with
A-UNION as an explicit hypothesis if Aeneas refuses. Which way it went will
be reported in VERIFY-REPORT.md; the fallback is not silent.
The abstract down/up are implemented twice:
|m| ≤ 5: linear scan over unsorted extents —map_id_range_down_base(300),map_id_range_up_base(349).|m| > 5: binary search overforward(sorted byfirst) andreverse(sorted bylower_first) —map_id_range_down_max(282),map_id_range_up_max(372), throughbsearchwithcmp_map_id(251).
Their agreement is §4.4, the theorem where a real defect would live.
sort_idmaps (856) runs at map_write 1089, after the re-mapping loop
(1063–1083) has overwritten every lower_first at 1082 with a
kernel-global id from map_id_range_down(parent_map, ...) (1072).
mappings_overlap ran at 1038, before that. Therefore:
cmp_extents_forward(823) sorts byfirst, which is never rewritten. Distinctness offirstfollows directly fromWF-DISJ-UPPER. Premise.cmp_extents_reverse(838) sorts by the post-remappinglower_first.WF-DISJ-LOWERas established at 1038 is about the pre-remapping values and says nothing about these. Distinctness here is exactlyPRESERVE-LOWER(§4.5), which needs the parent map's injectivity. Theorem, not premise.
So the sort contract is stated as: the result is a permutation of the input,
sorted by the key. Uniqueness of that result — needed for "any correct sort
agrees with lib/sort.c's heapsort" — is SORT-UNIQUE-FWD (free) and
SORT-UNIQUE-REV (a corollary of PRESERVE-LOWER).
Labelled separately, as requested. Safety = the reimplementation cannot misbehave regardless of input (no panic, no UB, no nontermination, no out-of-bounds). Functional correctness = the reimplementation computes what the C computes, and the C computes what the isolation model requires.
| # | Property | Class |
|---|---|---|
| S1 | TOTAL-SAFETY — every entry point is ok / controlled fail / div, never a fourth outcome |
safety |
| S2 | NO-PANIC — on a WellFormed map, every lookup returns ok; no index or arithmetic panic |
safety |
| S3 | TERMINATION — the linear scan, the binary search, the overlap loop and the sort all terminate |
safety |
| S4 | NO-OVERFLOW — no u32 operation in first + count or id + count overflows on a WellFormed map |
safety |
| S5 | NO-STALE-READ — (Model T only) no read observes a slot clobbered by insert_extent's migration |
safety |
| F1 | DOWN-FUNCTIONAL / UP-FUNCTIONAL — the C's lookups implement down/up |
functional |
| F2 | INJECTIVITY — down is injective on a WellFormed map |
functional |
| F3 | ROUNDTRIP — up ∘ down is the identity or ⊥, never an unrelated id |
functional |
| F4 | AGREEMENT — the ≤5 scan and the >5 binary search compute the same function |
functional |
| F5 | PRESERVE — the validated write path takes a WellFormed parent to a WellFormed child |
functional |
| F6 | ADMISSION — the admission policy admits exactly the maps it is documented to admit |
functional |
S4 is listed as safety because in the Rust reimplementation an overflow is a panic; the same statement about the C is functional correctness, since the C wraps silently. The two are proved once and read twice.
These appear as explicit hypotheses in the theorem statements, not only
here and not only in OPEN_CHALLENGES.md. §5 maps every property to the
assumptions it consumes.
| Tag | Statement | Discharged how |
|---|---|---|
A-PUB |
The map under consideration is one that some completed map_write installed, and is not concurrently mutated. |
Not discharged. Requires a memory model; see §7. Consumed by every property. |
A-UNION |
(Model F only) No read observes extent[] after forward is set. |
By inspection of the four readers. Under Model T this becomes theorem S5 instead. |
A-SORT |
sort_idmaps' sort returns a permutation of its input, sorted by the comparison key. |
Contract of lib/sort.c; tested by the differential harness, not proved. |
A-BSEARCH |
The monomorphized binary search is semantics-equivalent to bsearch + the indirect cmp_map_id call. |
Argued (unique static callback target) + tested by the harness. |
A-CAPORACLE |
ns_capable, file_ns_capable, cap_raised and the cred/ns fields they read behave as an opaque oracle whose answers are inputs. |
By construction: they are parameters of the model. Only F6 consumes it. |
A-PARENT-WF |
The parent namespace's map is already WellFormed when the child's is written. |
Induction on namespace depth; base case init_user_ns, whose map is the near-identity extent (0, 0, W-1) (§1.2.2, §7.10) — W-1, not W. |
A note on how A-PUB and A-UNION will appear in Lean, because "state it as a
hypothesis" has a limit worth being honest about. A-PARENT-WF and A-SORT
are usable hypotheses — the proofs consume them. A-PUB and A-UNION are
claims about the C, and Lean has no C to quantify over; carrying them as
opaque propositional arguments makes them visible in every theorem statement
(which is the requirement) but they will be unused in the proof terms, and
an unused hypothesis is a documentation device, not a logical one. Two
consequences I will not paper over: such a hypothesis does not show up in
#print axioms, so the axiom audit cannot be the place a reader discovers it;
and A-UNION disappears entirely if Model T succeeds. VERIFY-REPORT.md will
carry a table of theorem × assumption alongside the axiom audit, because the
axiom audit alone would understate what the theorems rest on.
theorem no_overflow_upper (m : Map) (hpub : A_PUB m) (hwf : WellFormed m)
(e : Extent) (he : e ∈ m) :
(e.first.toNat + e.count.toNat) ≤ 2^32 - 1and the same for lower_first. Plus, for the range forms, the fact
cmp_map_id depends on at 255:
theorem no_overflow_range (m : Map) (x n : U32)
(hwf : WellFormed m) (hhit : ∃ e ∈ m, [x, x+n) ⊆ U(e)) :
x.toNat + n.toNat ≤ 2^32 - 1Rests on: A-PUB, WF-NOWRAP-U/L.
Counterexample means: first + count wrapping makes U(e) an interval that
"wraps past zero", so the containment test id >= first && id <= last with
last < first is false for every id — the extent silently maps nothing — or,
worse, for cmp_map_id's id2 = id + count - 1 computed on a caller-supplied
range, a wrapped id2 makes the three-way comparison non-monotone and the
binary search unsound. Operationally for a sandbox: a mount idmap installed
via mount_setattr whose extent covers the top of the id space could look
empty (files appear unowned, chown fails oddly) or, through the range form,
resolve to an extent that does not contain the range — a file appearing to
belong to the wrong host uid. Note the caller-supplied range in
fs/mnt_idmapping.c 373 (statmount_mnt_idmap) comes from an extent that was
itself validated, so the reachable exposure is narrower than the function
signature suggests; that narrowing is part of what F1 must show.
theorem down_injective (m : Map) (hpub : A_PUB m) (hwf : WellFormed m)
(x y : U32) (hx : down m x ≠ sentinel) (hxy : down m x = down m y) :
x = yRests on: A-PUB, WF-DISJ-LOWER, WF-NOWRAP-L, and — because
WF-DISJ-LOWER for an installed map is itself the conclusion of PRESERVE
(§4.5) — A-PARENT-WF. This is the induction whose necessity §1.5 established:
injectivity of the child's map is proved from injectivity of the parent's, with
init_user_ns as the base case.
Why disjointness of the lower intervals and not just distinctness of
lower_first: two extents (0, 100, 10) and (50, 105, 10) have distinct
lower_first but overlapping images, so namespace ids 5 and 50 both map to
global 105. WF-DISJ-LOWER is the right strength.
Counterexample means: two distinct in-container uids resolve to one host uid.
Operationally: the container's uid 1000 and uid 2000 are the same host
principal. Files written by one are owned by the other; setuid between them
inside the container is a no-op on the host side, so a privilege boundary the
workload's own design relies on (say, a supervisor uid and an untrusted worker
uid inside the sandbox) silently does not exist. This is the single most
consequential property in the target and the one a reviewer should test hardest.
The property you asked for, stated in the form the code actually supports:
theorem roundtrip (targ : Map) (hpub : A_PUB targ) (hwf : WellFormed targ)
(x : U32) :
let k := down targ x
k = sentinel ∨ up targ k = xi.e. up (down x) is either x or the lookup failed; it is never a third,
unrelated id. The stronger-looking from_kuid (make_kuid ns u) = u ∨ = ⊥
does not hold as stated across different namespaces — make_kuid uses
ns->uid_map (425) and from_kuid uses targ->uid_map (444), and for
ns ≠ targ there is no round-trip to speak of. The theorem is about one map.
SENTINEL-COLLISION — raised, then resolved, and the resolution simplifies
the theorem. The concern was: if ⊥ ∈ L(e) for some extent, then
down x = ⊥ is ambiguous between "lookup failed" and "succeeded, mapping to
W-1", and the disjunction in ROUNDTRIP would be hiding a real defect rather
than describing normal behaviour.
It cannot happen. §1.2.1 shows the no-wrap check at 1031/1033 accepts only
first + count ≤ W - 1, so ⊥ = W - 1 lies strictly above every accepted
interval — WF-NOSENT-IV. And init_user_ns (kernel/user.c 41–46) uses
count = 4294967295 = W - 1, not W, so the identity map deliberately stops
one short of the sentinel too. Across all four map sources in §1.2.2, no
reachable map has ⊥ in any U(e) or L(e).
So down x = ⊥ unambiguously means "no mapping", and the disjunction in
ROUNDTRIP is exactly the two cases it appears to be. WF-NOSENT-IV becomes a
lemma that the ROUNDTRIP proof discharges from WF-NOWRAP rather than an
open question.
This item was marked [NEEDS EMPIRICAL CHECK] when first written, and the mark
earned its keep immediately. Checking it turned up two things reading had
missed: that the no-wrap bound is W-1 rather than W, and that
init_user_ns.uid_map.extent[0].count is 4294967295 rather than 2^32 — I
had written the initial map as (0, 0, W) in a first draft of §7, which is
wrong and would have made the initial namespace the one place the ambiguity was
unavoidable. The opposite is true: it is excluded everywhere, including there.
theorem scan_bsearch_agree (m : Map) (hpub : A_PUB m) (hwf : WellFormed m)
(hsort : A_SORT) (hbs : A_BSEARCH) (x n : U32) :
down_base m x n = down_max (sortForward m) x n
∧ up_base m x n = up_max (sortReverse m) x nRead carefully, this is three obligations:
cmp_map_idis a valid search predicate. For a fixed key(x, n), the functione ↦ cmp_map_id(key, e)must be monotone along the sorted order:-1for extents above the key,0for the hit,+1for extents below. Inspecting 262–274:first = el->firstfor the down direction (265),last = first + el->count - 1(269), returns0iff bothidandid2lie in[first, last](271–272),-1ifid < first || id2 < first(274), else+1. Monotonicity of this alongfirst-sorted order needsWF-DISJ-UPPERandWF-NOWRAP-U; without disjointness the0region need not be contiguous andbsearchmay miss a hit that a linear scan finds.bsearch's loop is correct.__inline_bsearch(include/linux/bsearch.h9–28) is not textbook: onresult > 0it doesbase = pivot + size; num--;and thennum >>= 1unconditionally. The invariant "if a hit exists it lies in[base, base+num)" must be re-established across both branches; thenum--before the shift is where an off-by-one would live.- The reverse direction uses a different key field.
cmp_map_idselectsel->lower_firstwhenkey->map_upis true (263–266) andreverseis sorted bylower_first, so the same monotonicity argument must be redone againstWF-DISJ-LOWER— which, per §1.5, is the post-remapping property and therefore depends onPRESERVE-LOWER.
Counterexample means: a map with 6 or more extents resolves ids differently
from a map with 5. Operationally: this is the sharpest of all the
properties, because the two paths are exercised by different deployments.
Almost every hand-written container config uses one to three extents and stays
on the linear scan forever; large multi-tenant id allocations and
/etc/subuid-driven runtimes cross into the binary search. A defect here is
invisible in testing at small scale and appears only at the scale where a host
runs many tenants — the worst possible failure profile. If one theorem in this
spike is worth the effort, it is this one.
theorem write_preserves_wf
(parent : Map) (hppub : A_PUB parent) (hpwf : A_PARENT_WF parent)
(lines : List (U32 × U32 × U32)) (oracle : CapOracle)
(m : Map) (hok : map_write parent lines oracle = ok m) :
WellFormed mDecomposed by clause. PRESERVE-UPPER (WF-DISJ-UPPER, WF-NOWRAP-U,
WF-NOSENT, WF-LEN) is local: those fields are never rewritten, so the
checks at 1024/1031/1038/1041 establish them directly and insert_extent (789)
preserves them.
PRESERVE-LOWER is the hard one, and it is the obligation §1.5 uncovered.
After the loop at 1063–1083 replaces each lower_first with
downR⟦parent⟧(lower_first, count) (1072), we must show
∀ i ≠ j. L(m'ᵢ) ∩ L(m'ⱼ) = ∅
given only that the pre-remapping lower intervals were disjoint. The argument:
downR⟦parent⟧(y, n) ≠ ⊥ means [y, y+n) lies wholly inside a single parent
extent p (the four-way test at 267–268), on which ⟦p⟧ is a translation and
hence injective; distinct pre-images that shared a parent extent stay disjoint
by translation, and pre-images in different parent extents stay disjoint by
parent's own WF-DISJ-LOWER. So PRESERVE-LOWER reduces to the parent's
disjointness — A-PARENT-WF — and the induction of §4.2 closes.
Corollaries: SORT-UNIQUE-REV (distinct post-remapping lower_first, hence a
unique reverse sort order, hence A-SORT's abstraction is a non-weakening) and
the WF-DISJ-LOWER conjunct that INJECTIVITY and the reverse half of
AGREEMENT consume.
Counterexample means: a child map that passed every check in map_write is
nonetheless non-injective, because two of its extents were re-mapped into
overlapping global ranges. Operationally: the same collapse as §4.2, but
reached through a map the kernel explicitly accepted — so no amount of
userspace-side validation by the container runtime would catch it.
Added per ROADMAP Amendment 3. Parameterized over A-CAPORACLE; the theorem
says what the policy is, which is the reviewable content.
theorem admission_characterisation
(ns parent : Map) (o : CapOracle) (capSetid : CapId) (m : Map) :
new_idmap_permitted o capSetid ns m = true ↔ Admissible o capSetid ns mwhere Admissible is the disjunction the code implements, read off 1178–1211:
- (root guard, 1178) if
capSetid = CAP_SETUIDthenverify_root_mapmust hold.verify_root_map(890) scans for an extent withlower_first = 0(898–907); if there is none it permits (909–910); otherwise it requiresfile_ns->parent_could_setfcapwhenmap_ns = file_ns(918), elsefile_ns_capable(file, map_ns->parent, CAP_SETFCAP)(925). - (unprivileged self-map, 1184–1197)
|m| = 1 ∧ m₀.count = 1 ∧ ns.owner = cred.euid, and then forCAP_SETUID,make_kuid(parent, m₀.lower_first) = cred.euid(1188–1189); forCAP_SETGID,make_kgid(parent, m₀.lower_first) = cred.egidand¬(ns.flags & USERNS_SETGROUPS_ALLOWED)(1193–1194). - (projid, 1200)
¬cap_valid(capSetid)— unconditionally permitted. - (privileged, 1207–1208)
ns_capable(parent, capSetid) ∧ file_ns_capable(file, parent, capSetid)— both, the caller and the opener of the map file. - otherwise denied (1211).
Three statements worth having as separate named lemmas, because each is a property a sandbox author reasons about:
| Lemma | Statement |
|---|---|
ADMIT-UNPRIV-SINGLETON |
Without any capability in the parent, the only admissible uid map is a single extent of count 1 whose parent id maps to the caller's own euid. (From 1184–1190 being the sole path that returns true when 1200 and 1207 both fail.) |
ADMIT-SETGROUPS-COUPLING |
The unprivileged gid path additionally requires setgroups to be already disabled in ns (1193). So the pair (gid_map written unprivileged, setgroups enabled) is unreachable — the invariant that stops a task from dropping a supplementary group to gain access under a negative-permission ACL. Cross-check against userns_may_setgroups (1292) and proc_setgroups_write (1225). |
ADMIT-ROOT-NEEDS-SETFCAP |
An admissible uid map containing an extent with lower_first = 0 implies the writer held CAP_SETFCAP in the relevant namespace. This is the link to INVENTORY.md §2c: it is what stops a sandboxed process from planting an fscap whose rootid maps to host uid 0. |
In-bounds side condition. 1184–1186 index new_map->extent[0] with no
nr_extents <= 5 dispatch. Under Model F this is in-bounds because
nr_extents == 1 guards it; under Model T it is a read of base[0], in-bounds
for the same reason. Either way it is an explicit hypothesis of the lemma, not
an incidental fact — precisely the sort of guarded raw index that a
representation change can silently invalidate.
Counterexample means: the kernel installs a map it should have refused.
Operationally: ADMIT-UNPRIV-SINGLETON failing is the classic
unprivileged-userns escalation — a task with no privilege maps a parent id it
does not own and acquires that host identity. ADMIT-ROOT-NEEDS-SETFCAP
failing is fscap forging. These are the two consequences that motivate a
sandbox's sysctl kernel.unprivileged_userns_clone=0 posture; the theorems say
what is lost by relaxing it.
TOTAL-SAFETY (S1) and NO-PANIC (S2) follow the pattern established in
~/kernel-c-to-rust-spike/verify/: S1 by exhaustive case split on the Aeneas
Result, S2 from WellFormed plus S4. TERMINATION (S3) has four obligations
— the two for (idx = 0; idx < extents; idx++) scans and the overlap loop are
structural; __inline_bsearch's while (num > 0) needs the measure num,
strictly decreasing because num >>= 1 on num ≥ 1 decreases and the
num-- branch decreases first. NO-STALE-READ (S5) exists only under Model T
and is stated in §1.3.
Which theorems are conditional on which assumption, per item 3 of the brief. This table, not the axiom audit, is the honest summary of what the results rest on.
| Property | A-PUB |
A-UNION (Model F only) |
A-SORT |
A-BSEARCH |
A-CAPORACLE |
A-PARENT-WF |
|---|---|---|---|---|---|---|
S1 TOTAL-SAFETY |
— | — | — | — | — | — |
S2 NO-PANIC |
✔ | ✔ | — | — | — | — |
S3 TERMINATION |
— | — | — | — | — | — |
S4 NO-OVERFLOW |
✔ | ✔ | — | — | — | — |
S5 NO-STALE-READ |
— | n/a (Model T) | — | — | — | — |
F1 DOWN/UP-FUNCTIONAL |
✔ | ✔ | — | ✔ | — | — |
F2 INJECTIVITY |
✔ | ✔ | — | — | — | ✔ |
F3 ROUNDTRIP |
✔ | ✔ | — | — | — | ✔ |
F4 AGREEMENT |
✔ | ✔ | ✔ | ✔ | — | ✔ |
F5 PRESERVE-UPPER |
✔ | ✔ | — | — | — | — |
F5 PRESERVE-LOWER |
✔ | ✔ | — | — | — | ✔ |
F6 ADMISSION + 3 lemmas |
✔ | ✔ | — | — | ✔ | — |
S1 and S3 are unconditional: they are properties of the extracted Rust, true
whether or not the Rust faithfully models the C. Everything else is conditional,
and A-PUB is on all of it.
Recorded here because ROADMAP.md Amendment 2 promised it, and because it is
the one behaviour in the target established by measurement rather than reading.
The five data points, from uidmap-probe.sh / uidmap-probe.txt, written to
/proc/PID/uid_map in a fresh unprivileged user namespace, host uid 1000:
| Input line | Installed | Host kernel measured on |
|---|---|---|
0 1000 1 |
0 1000 1 |
6.8.0-138-generic |
4294967296 1000 1 |
0 1000 1 |
6.8.0-138-generic |
4294967297 1000 1 |
1 1000 1 |
6.8.0-138-generic |
4294967301 1000 1 |
5 1000 1 |
6.8.0-138-generic |
99999999999999999999999 1000 1 |
4135583743 1000 1 |
6.8.0-138-generic |
4135583743 = (10^23 - 1) mod 2^32, exactly — a mod-2^64 wrap signature.
Two statements, labelled by kernel, because they differ:
- On 6.8.0-138-generic (measured). The parser reduces every decimal literal
mod
2^32. The only rejected input is one landing exactly on(u32)-1after truncation. - On
~/linux-fullv7.2-rc7+687 (derived, not measured). The parser is exact below2^64and saturates toULLONG_MAXat or above it; the saturated value truncates to exactly(u32)-1and is rejected at 1024. So the fifth row would read rejected, not4135583743.
The difference is commit 6e30111dbb40 "lib: fix _parse_integer_limit() to
handle overflow" (Dmitry Antipov, 2026-05-19, git describe --contains ->
v7.2-rc1~77^2~64), which introduced the 2^60 guard at lib/kstrtox.c 83
and the saturation at 84–88. 6.8 predates it; the target tree contains it.
The branch structure, and where the truncation actually happens. The
res & (~0ull << 60) test at lib/kstrtox.c 83 selects between two
multiply-adds, not between "checked" and "wrapping": below 2^60 it takes the
cheap unchecked res = res * base + val at 90 (which cannot wrap, per the base
bound above), and at or above 2^60 it takes check_mul_overflow /
check_add_overflow at 84–85, which store the exact result and fall through
to res = ULLONG_MAX at 86 only on a real u64 overflow. So the
exact/saturating boundary is 2^64, not 2^60, and there is no regime in which
a value between 2^60 and 2^64 is rejected or perturbed.
The full chain for the target tree is simple_strtoul (lib/vsprintf.c 126)
-> simple_strntoull (79) -> _parse_integer_limit (lib/kstrtox.c 58). The
KSTRTOX_OVERFLOW bit that 87 sets is discarded by the caller at
lib/vsprintf.c 91, cp += (rv & ~KSTRTOX_OVERFLOW) — the kernel's own
/* FIXME */ sits on line 90 immediately above it — so the saturation is never
reported as an error, only as a value. The actual truncation site is the
u64-into-u32 assignment in map_write at 1004, 1009 and 1014.
Target-tree behaviour, stated once. On
v7.2-rc7-687-ge172eee25cfe, every decimal literal below 2^64 is parsed
exactly and then silently reduced mod 2^32 by the assignment at 1004/1009/
1014. Only literals at or above 2^64 are rejected, and only incidentally:
they saturate to ULLONG_MAX, whose low 32 bits are exactly the (u32)-1
sentinel that the check at 1024 already refuses for unrelated reasons. Nothing
in the parser detects the overflow.
Not a vulnerability, on either kernel. The truncated values (0, 1, 5) are legal ids and every downstream check — sentinel, no-wrap, overlap, parent mappability, admission — still applies. What it is: a documented-nowhere acceptance of malformed input, and a place where the same test suite gives different answers on 6.8 and 7.2. It belongs in the differential harness's corpus for that reason.
Load-bearing consequence for Phase 4. The differential harness must compile
the C against the target tree's lib/kstrtox.c, not the host's libc or a
hand-written simple_strtoul. A harness that got this wrong would "confirm"
the 6.8 behaviour and silently disagree with the tree the theorems are about —
the same class of mistake as the -fno-strict-overflow issue documented in
~/kernel-c-to-rust-spike/fuzz/build.rs.
First on the list, as instructed.
- Barrier discipline and concurrency —
A-PUB. The lock-free read protocol ismap_id_range_down/_upreadingnr_extents, thensmp_rmb()(322, 388), then the extents;map_writewriting extents, thensmp_wmb()(1101), thennr_extents(1102); write-once enforced at 980;copy_mnt_idmap(219) repeating the pattern withREAD_ONCE(222) and refusingnr_extents == 0(235). The invariant — non-zeronr_extentsimplies fully-initialized extent storage — is what makes the whole scheme safe, and a sequential Aeneas model cannot express it. Every functional property above is stated about an already-published, quiescent map. Anyone reading "user-namespace ID mapping verified" must not conclude the barrier discipline was checked. It was not, and this toolchain cannot check it; it needs a memory-model tool (a litmus/herd-style check of the four-access pattern, or a CBMC-with-weak-memory harness).OPEN_CHALLENGES.mdwill name it as a tool gap, not an effort gap. - The union, if Model T fails. Under Model F,
A-UNIONis discharged by inspection. Under Model T it is theorem S5. Which one shipped will be stated inVERIFY-REPORT.md. lib/sort.c's heapsort. Abstracted toA-SORT. The uniqueness lemmas make the abstraction non-weakening given the contract, but the contract itself is tested (differential harness) rather than proved.bsearch's indirect call.A-BSEARCH. The monomorphization is argued and tested, not proved.- The capability oracle.
A-CAPORACLE. F6 says what the policy computes given the oracle's answers. It says nothing about whetherns_capableanswers correctly — that iscap_capable_helper, candidate E, a separate target. SoADMIT-UNPRIV-SINGLETONis conditional on the capability layer being right. - Allocation failure.
insert_extent796 andsort_idmaps866 can return-ENOMEM, andmap_write'sout:path (1108–1115) frees and resets. The fixed-array model has no allocation and therefore no ENOMEM path; the error paths of the C are out of scope. This is a real gap — theout:cleanup resettingmap->nr_extents = 0after a partial write is exactly the kind of code where a use-after-free would live. - The
__userboundary.memdup_user_nul(952) and the*pposhandling (947) are not modelled. - Everything above the map.
make_kuid's callers,capable_wrt_inode_uidgid, the fscap composition insecurity/commoncap.c— all consume these results and none is verified here. - The other two maps' policy differences. The spec covers
uid_mapwithcap_setid = CAP_SETUID.gid_map(proc_gid_map_write1137) andprojid_map(1154) share the algebra but differ in admission (§4.6); only the uid path's lemmas are stated in full. init_user_ns's identity map — checked, and it is not what I assumed. The base case ofA-PARENT-WFis the initial namespace's map. I had written it as the identity extent(0, 0, W). It is actually(first=0, lower_first=0, count=4294967295)withnr_extents = 1(kernel/user.c41–46, and identically forgid_map51–56 andprojid_map61–66) —count = W - 1, one short of the full range. So the initial namespace does not map⊥, which is what makesWF-NOSENT-IVhold there too (§4.3). This is no longer an assumption; it is read off the initializer. What remains out of scope is only that the initializer is the sole writer ofinit_user_ns's maps —map_write980 rejects a second write, but that argument is about the running kernel, not about the model.
Amendment 2 of ROADMAP.md records a three-file chain of source reasoning that
read as convincing and was wrong about the tree it claimed to describe. The
rule adopted, and applied above:
A property derived by reading across files, with neither a machine-checked proof nor a measurement, is marked [NEEDS EMPIRICAL CHECK] and is not presented as settled. A measurement is recorded with the kernel version it was taken on, always — the version label is what turns a contradiction into a resolved discrepancy.
Both items that carried that mark when this document was drafted have since been settled, and settling them changed the specification:
| Item | Outcome |
|---|---|
§4.3 — is ⊥ ∈ L(e) reachable? |
No. Brute-forcing the u32 comparison at 1031 showed it accepts only first + count ≤ W - 1, one bit stronger than the "no wrap" reading. WF-NOWRAP was corrected from ≤ W to ≤ W - 1, WF-NOSENT-IV was added as a corollary, SENTINEL-COLLISION was closed, and the redundancy of the check at 1024 was noted. |
§7 item 10 — init_user_ns's initializer |
Not as assumed. count = 4294967295 = W - 1, not 2^32. My draft had the initial map as (0, 0, W), which would have put ⊥ inside its interval and made the initial namespace the one unavoidable instance of the ambiguity. The opposite holds. |
Two out of two marked items were wrong on the reading and right to check. That
is a poor hit rate for source reasoning and a good one for the rule, and it is
the reason §1.2.2 (enumerating the four ways a uid_gid_map comes into
existence) exists at all — it was written after the init_user_ns correction
made it obvious that "the map the spec is about" needed pinning down.
No items currently carry the mark. Any new one added during Phase 3 goes in
OPEN_CHALLENGES.md as well as here.
Applying the rule honestly to the rest of this document: §1.5's re-derivation
of the map_write step ordering, §4.4's monotonicity analysis of cmp_map_id,
and §4.5's PRESERVE-LOWER argument are all multi-step source reasoning. They
are not marked, because each is a claim about control flow and arithmetic
within a single function that the Lean proof will have to discharge anyway — if
they are wrong, the proof fails, which is the check. The marked items are the
ones where a wrong reading would be silently absorbed into an assumption
instead.
Called out rather than silently edited, per the Phase 3 brief. Three so far, all from writing the model and extracting it; none from a proof yet, because no proof has been attempted.
Writing Model T made an entry condition visible that reading had not.
map_id_range_up_max (372) binary-searches map->reverse. insert_extent
sets map->reverse = NULL when it migrates to the heap arm (807), and only
sort_idmaps (866) populates it. So an upward lookup on a map with
nr_extents > 5 and reverse == NULL dereferences NULL in the C — safe but
wrong in Model T, which would silently search a zeroed array.
It is unreachable for a published map: map_write installs at 1098–1099 only
after sort_idmaps returned success at 1089, and the failure path at 1108–1112
frees both arrays and resets nr_extents to 0. But that is a three-function
argument about the C's reachable states, so per §8 it carries
[NEEDS EMPIRICAL CHECK] and enters WellFormed as a clause rather than
being assumed. Model T represents it as the reverse_live field; proving
nr_extents > 5 -> reverse_live for every map the write path produces is part
of F5 PRESERVE.
Note this is narrower than A-PUB: WF-REVERSE-LIVE is a sequential
reachability fact about map_write's straight-line order, which the model
can state and F5 can prove. A-PUB is about what a concurrent reader
observes, which it cannot.
Blocker B-1 in OPEN_CHALLENGES.md: Aeneas crashes with an internal
Unreachable in InterpAbs.merge_abs_conts_aux on the shape "branch on a
bool read through a reference, where the joined value then feeds a later
condition" — which is exactly cmp_map_id 262–271. Worked around by making
IdmapKey Copy and passing it by value. Semantics-preserving (the C's key is
a stack local at both call sites, and cmp_map_id only reads it), tagged
DEVIATION at the definition, and recorded with a five-line reproducer.
This does not change any property statement; it changes the signature the
statements are about. A-BSEARCH already covered the monomorphization of the
callback, and this narrows the same seam slightly further.
Model T translated through Charon and Aeneas without complaint — UidGidMap
carries both arms, and insert_extent reproduces the clobber write. So the
fallback to Model F was not needed and A-UNION is replaced by theorem S5
NO-STALE-READ, stated as non-interference: for maps with nr_extents > 5
agreeing on the heap arm and nr_extents, perturbing the base arm arbitrarily
must not change any lookup.
S5 is not yet proved. Until it is, §3's A-UNION row is downgraded from
"assumption" to "obligation", which is progress but not a result. The
non-interference formulation is currently smoke-tested only
(rust/tests/sanity.rs::no_stale_read_smoke).
Two of the safety statements came out stronger than SPEC §2 predicted, in the sense of needing less; both are recorded rather than silently absorbed.
S2/S3 for every lookup needs only nr_extents ≤ 340, not WellFormed.
map_id_range_down_spec / _up_spec (and hence map_id_down, map_id_up,
make_kuid, from_kuid) are proved to return ok under the single hypothesis
m.nr_extents.val ≤ 340 — the upper half of WF-LEN — and nothing else. The
≤ 5 linear scan needs no hypothesis at all (its indices are bounded by the
dispatch); the > 5 binary search needs exactly the array bound, because with
nr_extents > 340 bsearch would read past the 340-slot heap array — in the
model, fail; in the C, a heap over-read. So the lookup path's memory safety
depends on one clause of WellFormed, established by map_write 1041, and on
no arithmetic clause. WellFormed is still what the functional properties
need (F1–F5); §5's matrix is updated accordingly in VERIFY-REPORT.md.
Model T cannot express the C's NULL-reverse hazard, and that changes what
WF-REVERSE-LIVE is. map_id_range_up_spec was proved without any
reverse_live hypothesis. That is not because the proof found the hazard
harmless; it is because in Model T the reverse array always exists (as a
zeroed 340-slot array), so there is no state in which the C's
bsearch(&key, map->reverse, ...) at 380 dereferences NULL. The model is
memory-safe where the C is not, by construction, and so the model cannot be
used to find that class of bug — the same asymmetry as the prior spike's
"Rust panics where the C writes out of bounds". Consequences:
WF-REVERSE-LIVEis a C-level clause. Its content is the writer enumeration inOPEN_CHALLENGES.mdS-5 (every producer of a published> 5map storesreversebeforenr_extents); the model trivially satisfies it and F5 will provereverse_liveformap_write's output, but that proof is about the flag, not about the C's pointer.- The differential harness (Phase 4) is the only place the hazard is
observable, and only negatively: a C build under ASan/UBSan would report a
NULL dereference if a
> 5map ever reachedmap_id_range_up_maxwithreverse == NULL. It should be given a corpus entry that constructs a migrated-but-unsorted map directly (bypassingmap_write) to confirm the C does crash there and the model does not — i.e. to confirm the asymmetry rather than assume it. [NEEDS EMPIRICAL CHECK], carried to Phase 4.
All statement-level; none weakens a property. Recorded rather than silently edited.
-
F3 is split.
ROUNDTRIPas §4.3 wrote it —k = ⊥ ∨ up k = x— is proved as two theorems:roundtrip_sound(ifup kreturns anything, it isxor⊥, never an unrelated id) for both paths, androundtrip_complete(it is not⊥), proved below 6 extents and resting onbsearch_completeabove. The split is not cosmetic: soundness needs no sortedness at all, completeness is exactly what sortedness buys. The brief's "containment" property is the soundness half and is closed. -
The permutation half of
A-SORTmoved intoWellFormed.rev_permsays: above 5 extentsreverse_liveandabsRev m ~ absFwd m. §3 had all ofA-SORTunder F4. In fact the soundness ofmap_id_upabove 5 extents already needs thereversearm to hold the same extents asforward(otherwise an element found inreverseneed not be in the map at all). The sortedness half stays outsideWellFormedasSortedArms, consumed only by F4. §5's matrix is corrected inVERIFY-REPORT.md§3b. -
F5 is decomposed, and its hypotheses changed.
PreRemap Lnames what the line loop establishes before the re-map (§1.2's clauses on the pre-remap values pluslen ≤ 340).Remapped parent L L'names the re-map position by position, withgetElem?so no dependent bounds appear.preserve_lower_listisPRESERVE-LOWERon lists (proved);remap_install_funis the same at the C loop's level (open, L-4).lines_loop_funcarries three hypotheses §4.5 did not mention: the three return-code parameters of the folded loop are negative; the exact 340 boundary of 1041 asi < 340 ∨ i = len; andi ≤ len. All three are invariants of the C loop, not extra assumptions — the first because the codes are-EPERM/-EINVALliterals, the second because 1041 rejects the 341st line only when a next line exists, the third because the loop steps by one whilei < len.write_preserves_wfneeds no hypothesis on the target map: success forces the write-once check (980) and the capability check (986) to have passed. §4.5 had implicitly assumed an empty target; it is not needed.Nodup (absFwd m)is carried through F5. The C never states it; it follows from upper-disjointness and is needed to tell two positions of the re-mapped list apart. A proof artefact, not a kernel invariant.
-
down_range_soundis a separate theorem fromdown_sound. The re-map at 1072 uses the range form with the caller'scount; its soundness needscount ≥ 1 ∧ id + count ≤ W − 1— which 1033 provides — so that the C'sid + count − 1is exact. Stated and proved for both paths. -
init_user_ns_map_wfis a theorem, not an assumption. §3 listed the base case ofA-PARENT-WFas discharged "by the initializer"; it is now proved fromUidGidMap.init_user_ns_map, and the fact thatcount = 2^32 − 1satisfiesnowrap_uwith nothing to spare is where the≤ W − 1bound of §1.2.1 is exercised.