From 89d4592777438e01ae6436495cabd516ae00d3ad Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:40:45 +0100 Subject: [PATCH] =?UTF-8?q?block:=20close=20the=20range-clamp=20overflow?= =?UTF-8?q?=20=E2=80=94=20a=20confined=20caller=20could=20wrap=20into=20th?= =?UTF-8?q?e=20neighbour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The naive bound `lba + count > r.count` wraps for an lba near u64 max: the sum overflows to a small value, sails under the check, and `base + lba` wraps to an absolute block OUTSIDE the range. Calibrated, it is a real confinement escape — a process confined to [1,3) reads absolute block 0 (the boot sector) with lba = maxInt(u64), since base + lba wraps to 0. The bound is rewritten as two subtractions that cannot overflow: lba within the range, and count within what remains. block-range gains a wrap-refused assertion calibrated to be exploitable against the naive form — it FAILS against the old bound (reads block 0) and passes against the fix (verified by reverting the clamp). Caught pre-emptively before the V2 boundary review. --- system/drivers/usb-storage/usb-storage.zig | 9 ++++++++- test/qemu_test.py | 1 + .../services/block-range-test/block-range-test.zig | 6 ++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/system/drivers/usb-storage/usb-storage.zig b/system/drivers/usb-storage/usb-storage.zig index 99c1b20..1351378 100644 --- a/system/drivers/usb-storage/usb-storage.zig +++ b/system/drivers/usb-storage/usb-storage.zig @@ -218,9 +218,16 @@ fn rangeFor(badge: u32) ?*Range { /// Resolve a caller's transfer to an absolute LBA, or null if it falls outside /// the caller's confinement. Unconfined callers (no range) pass through against /// the whole device. +/// +/// The bound is written to survive a hostile confined caller: `lba + count` +/// would WRAP for an `lba` near u64 max, sail under a naive `> r.count` check, +/// and translate to a wild absolute block — so the check is phrased as two +/// subtractions that cannot overflow (`lba` within the range, and `count` +/// within what remains). `r.base + lba` cannot overflow once `lba <= r.count`, +/// because the volume manager sets `base + count` inside the device. fn resolveTransfer(sender: u32, lba: u64, count: u32) ?u64 { const r = rangeFor(sender) orelse return lba; // unconfined: whole device - if (lba + count > r.count) return null; // past the volume's end + if (lba > r.count or r.count - lba < count) return null; // past the volume's end return r.base + lba; } diff --git a/test/qemu_test.py b/test/qemu_test.py index 4754310..6e91485 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -1246,6 +1246,7 @@ CASES = [ "timeout": 150, "expect": r"(?s)(?=.*block-range: ok in-range-read)" r"(?=.*block-range: ok out-of-range-refused)" + r"(?=.*block-range: ok wrap-refused)" r"(?=.*block-range: ok geometry-is-confined)" r"(?=.*block-range: ok confined-cannot-redefine)" r"(?=.*block-range: VERDICT done)", diff --git a/test/system/services/block-range-test/block-range-test.zig b/test/system/services/block-range-test/block-range-test.zig index f5ab054..585cd8a 100644 --- a/test/system/services/block-range-test/block-range-test.zig +++ b/test/system/services/block-range-test/block-range-test.zig @@ -133,6 +133,12 @@ pub fn main(init: process.Init) void { // range, and must be refused. verdict(device.read(0, 1, bounce.physical), "in-range-read"); verdict(!device.read(2, 1, bounce.physical), "out-of-range-refused"); + // The wrap attack, calibrated to be exploitable against a naive bound: this + // process is confined with base 1, so a volume-relative LBA of maxInt(u64) + // makes base + lba wrap to absolute block 0 — a real, readable block OUTSIDE + // the range (the boot sector). A naive `lba + count > count` check also + // wraps to 0 and waves it through; the overflow-safe bound refuses it. + verdict(!device.read(std.math.maxInt(u64), 1, bounce.physical), "wrap-refused"); // Geometry now reports the CONFINED size, not the device's. const confined = device.geometry() orelse {