kernel: claim refuses delegated hardware — and E2 had already closed the hole
The rule as planned: a device that was given to someone may be handed on, never taken. Implemented, and honest about what it is worth. Writing the test showed the plan had the wrong step doing the work. A delegated device is HELD, so an attempt to take it is refused as AlreadyClaimed before the giver is ever consulted; and once a borrower's death returns the device to its lender — or clears both when the lender is gone — there is no state where a device is unheld and still on loan. The window a stranger could have used stops existing at E2. This check is unreachable. It stays anyway: one comparison, failing closed, guarding any future path that frees a device without clearing its giver, which is exactly the hole this run closed. The comment says it is unreachable rather than implying a protection it does not provide. The attacker fixture does not gain the assertion that was deferred to this step, and its header records why: there is no refusal for it to observe, and on a bare boot with no device manager nothing is delegated at all, so the assertion had nothing to bite on. It failed loudly on its first run rather than passing quietly, which is the only reason this was noticed. It also leaves the loader's framebuffer alone without naming it: nobody delegates the framebuffer, so it has no giver, so the display service claims it exactly as before. Suite 118/118.
This commit is contained in:
@@ -119,14 +119,22 @@ Two things fall out rather than being special-cased:
|
|||||||
display service can still claim it exactly as today. No exemption in the kernel, no
|
display service can still claim it exactly as today. No exemption in the kernel, no
|
||||||
mention of display anywhere in the rule.
|
mention of display anywhere in the rule.
|
||||||
- **Restart needs no race.** A dying driver's device returns to the manager, which
|
- **Restart needs no race.** A dying driver's device returns to the manager, which
|
||||||
re-delegates it on respawn. Today the kernel releases it to nobody and the manager
|
re-delegates it on respawn. Previously the kernel released it to nobody and the
|
||||||
re-claims first-come, so every restart reopens the hole this run closes.
|
manager re-claimed first-come, so every restart reopened the hole.
|
||||||
|
|
||||||
|
**Found while implementing: E2 is the step that closes the hole, not E3.** A delegated
|
||||||
|
device is *held*, so an attempt to take it is refused as `AlreadyClaimed` long before
|
||||||
|
the giver is consulted — and once a borrower's death returns the device to its lender
|
||||||
|
(or clears both when the lender is gone), there is no state where a device is unheld and
|
||||||
|
still on loan. E3's check is therefore unreachable today. It stays as one comparison
|
||||||
|
that fails closed, guarding any future path that frees a device without clearing its
|
||||||
|
giver, and its comment says so rather than implying a protection it is not providing.
|
||||||
|
|
||||||
| Step | What |
|
| Step | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| E1 | Record a giver per device; `device_transfer` and the spawn grant set it — **done** |
|
| E1 | Record a giver per device; `device_transfer` and the spawn grant set it — **done** |
|
||||||
| E2 | On task death a device reverts to its giver if alive, else its claim clears — **done** |
|
| E2 | On task death a device reverts to its giver if alive, else its claim clears — **done** |
|
||||||
| E3 | `device_claim` refuses a device that has a giver |
|
| E3 | `device_claim` refuses a device that has a giver — **done**, but unreachable: E2 already closed the window |
|
||||||
| E4 | The manager claims every resource-bearing device at boot, so nothing is left takeable |
|
| E4 | The manager claims every resource-bearing device at boot, so nothing is left takeable |
|
||||||
| E5 | The attacker fixture gains the claim half it has been waiting for since D2 |
|
| E5 | The attacker fixture gains the claim half it has been waiting for since D2 |
|
||||||
| E6 | Delete the delegated-set scaffolding — every driver is delegated now |
|
| E6 | Delete the delegated-set scaffolding — every driver is delegated now |
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ pub fn transfer(id: u64, to: u32) TransferError!void {
|
|||||||
/// re-enumerate, and `NotConfined` means the machine could not place the device under
|
/// re-enumerate, and `NotConfined` means the machine could not place the device under
|
||||||
/// IOMMU translation — the claim was rolled back, and that one is a fault report, not
|
/// IOMMU translation — the claim was rolled back, and that one is a fault report, not
|
||||||
/// a retry. `Refused` is an errno this library does not know a name for.
|
/// a retry. `Refused` is an errno this library does not know a name for.
|
||||||
pub const ClaimError = error{ NoSuchDevice, AlreadyClaimed, NotConfined, Refused };
|
pub const ClaimError = error{ NoSuchDevice, AlreadyClaimed, NotConfined, NotYours, Refused };
|
||||||
|
|
||||||
/// Take exclusive ownership of device `id`.
|
/// Take exclusive ownership of device `id`.
|
||||||
pub fn claim(id: u64) ClaimError!void {
|
pub fn claim(id: u64) ClaimError!void {
|
||||||
@@ -80,6 +80,7 @@ pub fn claim(id: u64) ClaimError!void {
|
|||||||
abi.ENODEV => error.NoSuchDevice,
|
abi.ENODEV => error.NoSuchDevice,
|
||||||
abi.EBUSY => error.AlreadyClaimed,
|
abi.EBUSY => error.AlreadyClaimed,
|
||||||
abi.ECONFINE => error.NotConfined,
|
abi.ECONFINE => error.NotConfined,
|
||||||
|
abi.EPERM => error.NotYours, // delegated hardware: it must be handed to you
|
||||||
else => error.Refused,
|
else => error.Refused,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,22 @@ pub fn enumerateFrom(start: usize, out: []device_abi.DeviceDescriptor) usize {
|
|||||||
pub fn claim(id: u64, owner: u32) ClaimError!void {
|
pub fn claim(id: u64, owner: u32) ClaimError!void {
|
||||||
if (id >= count) return error.NoSuchDevice;
|
if (id >= count) return error.NoSuchDevice;
|
||||||
if (claimed[@intCast(id)] != null) return error.AlreadyClaimed;
|
if (claimed[@intCast(id)] != null) return error.AlreadyClaimed;
|
||||||
|
// **Delegated hardware may be handed on, never taken.**
|
||||||
|
//
|
||||||
|
// Belt and braces, and worth being honest about: with the loan rule above this is
|
||||||
|
// **currently unreachable**. A device that was given to someone is held, so it is
|
||||||
|
// refused as `AlreadyClaimed` before reaching here; and when the holder dies the
|
||||||
|
// device goes back to its lender (or, if the lender is gone, has its giver cleared
|
||||||
|
// with its claim), so there is no state where a device is unheld *and* still on
|
||||||
|
// loan. The window a stranger could have used simply stops existing.
|
||||||
|
//
|
||||||
|
// It stays because it is one comparison and it fails closed: any future path that
|
||||||
|
// frees a device without clearing its giver would otherwise hand delegated
|
||||||
|
// hardware to whoever asked first, which is exactly the hole this run closed.
|
||||||
|
//
|
||||||
|
// Note it leaves the loader's framebuffer alone without naming it: nobody delegates
|
||||||
|
// the framebuffer, so it has no giver, so the display service claims it as always.
|
||||||
|
if (giver[@intCast(id)] != null) return error.NotYours;
|
||||||
claimed[@intCast(id)] = owner;
|
claimed[@intCast(id)] = owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +444,7 @@ pub fn errnoOf(e: RegisterError) i64 {
|
|||||||
pub const ClaimError = error{
|
pub const ClaimError = error{
|
||||||
NoSuchDevice, // no device with that id
|
NoSuchDevice, // no device with that id
|
||||||
AlreadyClaimed, // a live task already owns it
|
AlreadyClaimed, // a live task already owns it
|
||||||
|
NotYours, // delegated hardware: it has a giver, so it must be handed on, not taken
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Why a `transfer` was refused.
|
/// Why a `transfer` was refused.
|
||||||
@@ -474,6 +491,7 @@ pub fn claimErrnoOf(e: ClaimError) i64 {
|
|||||||
return switch (e) {
|
return switch (e) {
|
||||||
error.NoSuchDevice => abi.ENODEV,
|
error.NoSuchDevice => abi.ENODEV,
|
||||||
error.AlreadyClaimed => abi.EBUSY,
|
error.AlreadyClaimed => abi.EBUSY,
|
||||||
|
error.NotYours => abi.EPERM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,13 @@
|
|||||||
//! is what cost a debugging session on the Ryzen, so the distinction is
|
//! is what cost a debugging session on the Ryzen, so the distinction is
|
||||||
//! part of the contract and is tested as such.
|
//! part of the contract and is tested as such.
|
||||||
//!
|
//!
|
||||||
//! **What this fixture cannot yet claim.** `device_claim` is still
|
//! **Why there is no "cannot take a delegated device" assertion here.** The
|
||||||
//! first-come-first-served at this point in the run — that is the hole D6
|
//! hole this fixture was written for is closed, but not by a refusal it could
|
||||||
//! closes. So the claim half of the invariant ("a process holds what it was
|
//! observe. A device that was given to someone is *held*, so an attempt to
|
||||||
//! handed and cannot name its way into holding more") is deliberately NOT
|
//! take it is refused as `AlreadyClaimed` — the same answer as before. What
|
||||||
//! asserted here; it is added to this fixture at D6, when it becomes true.
|
//! changed is what happens when the holder dies: the device returns to
|
||||||
//! Asserting it now would mean writing a test that documents the bug.
|
//! whoever lent it instead of becoming free, so the window in which a
|
||||||
|
//! stranger could take it no longer exists. There is no moment to catch.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const device = @import("driver");
|
const device = @import("driver");
|
||||||
|
|||||||
Reference in New Issue
Block a user