aps(4): every EC transaction reads and writes I/O port 0x161F, one byte past the driver's allocated 0x1f-byte I/O resource window
Summary
aps_identify allocates 0x1f ports (0x1600..0x161E) but the EC TWR file is 16 registers at 0x10..0x1F and aps_do_io touches offset 0x1f on every transaction (arbitration drain read, forced buf[15]=0x01 write, response read). Off-by-one inherited from OpenBSD rev 1.19; fires every 1s poll on supported ThinkPads and OOB-reads 0x161F during probe on non-APS machines. Pure hygiene (fixed port, constant value, no memory safety). 0x20 one-character fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4811 Β· 5 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.4 KB | β raw | |
| VERDICT.md | β | 5.1 KB | β raw | |
| verdict.json | β | 2.1 KB | view raw | |
| fix.diff | β | 319 B | view raw | |
| manifest.json | β | 1.0 KB | view raw |
DF-4811 β aps(4): every EC transaction touches I/O port 0x161F, one byte outside the driver's allocated resource window
File: sys/dev/powermng/aps/aps.c
Severity: Info (hardening / correctness β no memory-safety impact)
Status: untested (static proof; not runtime-verifiable β see VERDICT.md)
What the driver is
aps.c is the DragonFly port of the OpenBSD ThinkPad Active Protection System
driver (sudden-motion sensor + EC thermometer on the Renesas H8S embedded
controller, LPC I/O window at 0x1600). Despite the directory name
(sys/dev/powermng/), it is not an Apple-SMC driver; it is ISA/LPC, probed via
aps_identify() which adds a fixed-port child.
The bug (pure arithmetic)
aps.c:101 #define APS_ADDR_BASE 0x1600 aps.c:102 #define APS_ADDR_SIZE 0x1f /* 31 ports: 0x1600..0x161E */ aps.c:61 #define APS_TWR_RET 0x1f /* last TWR register offset */
aps_identify() allocates the resource with that size:
aps.c:284 bus_set_resource(child, SYS_RES_IOPORT, 0, aps.c:285 APS_ADDR_BASE, APS_ADDR_SIZE, -1);
A size of 0x1f covers offsets 0x00..0x1E (ports 0x1600..0x161E). But the EC's
register file is 16 TWR registers at offsets 0x10..0x1F (comment at aps.c:47-48:
"EC uses LPC Channel 3 registers TWR0..15"), and the driver accesses offset 0x1F
in every aps_do_io() transaction:
aps.c:193 bus_space_read_1(iot, ioh, APS_TWR_RET); /* read @ 0x161F */ aps.c:210 wmask |= APS_READ_RET; /* force bit 15 */ aps.c:211 buf[APS_RET] = 0x01; aps.c:213 for (n = 1, bp = 2; n < 16; bp <<= 1, n++) aps.c:214 if (wmask & bp) aps.c:215 bus_space_write_1(iot, ioh, /* write @ 0x161F */ aps.c:215 APS_TWR_BASE + n, buf[n]); /* n=15 β 0x10+15=0x1f */ aps.c:233 rmask |= APS_READ_RET; aps.c:236 for (n = 0, bp = 1; n < 16; bp <<= 1, n++) aps.c:238 buf[n] = bus_space_read_1(iot, ioh, /* read @ 0x161F */ aps.c:238 APS_TWR_BASE + n);
Port 0x1600 + 0x1f = 0x161F is exactly one past the end of the
rman-allocated window [0x1600, 0x161E]. The size constant should be 0x20.
Reachability
- Driver is optional:
sys/conf/files:2600(dev/powermng/aps/aps.c optional aps isa); commented out in GENERIC (sys/config/X86_64_GENERIC:178), enabled viadevice aps0 at isa? port 0x1600(LINT64:1677) orkldload aps. - Once loaded, the OOB access happens on every EC transaction (probe, poll, suspend/resume re-init) β i.e., the normal operation of the driver on the ThinkPads it supports.
- On non-APS machines,
aps_do_io()'s arbitration drain path (aps.c:192-194) still reads port 0x161F repeatedly during probe, then returns ENXIO.
Impact ceiling
None demonstrated beyond hygiene: a single inb/outb to a port outside the driver's rman allocation. No kernel memory safety, no info leak, no privilege boundary. Worst theoretical case is interference with an unrelated device that decodes 0x161F on non-ThinkPad hardware (where the driver fails probe anyway).
Reproduce (on ThinkPad hardware with the EC)
kldload aps # or boot a kernel with `device aps0 at isa? port 0x1600` sysctl hw.sensors.aps0 # each refresh = aps_do_io = OOB touch at 0x161F
Not reproducible in the QEMU audit guest (no H8S EC behind port 0x1600; the driver never attaches) β see VERDICT.md.
Fix
One line: APS_ADDR_SIZE 0x20 (see fix.diff; static derivation, not
runtime-validated).
VERDICT β DF-4811 (aps.c off-by-one I/O port window)
finding_id: DF-4811 status: untested (static proof only; runtime verification not possible on the audit guest) impact: none (hygiene/hardening β out-of-bounds I/O port access, not kernel memory)
Was it reproduced?
No, and it cannot be on this guest, for a hardware reason (not a code reason):
- The bug lives behind
aps_probe()βaps_do_io()(aps.c:315), which requires a Renesas H8S embedded controller answering on the LPC window at 0x1600. - The QEMU/KVM audit guest has no such EC. On it, port 0x1604 (APS_STR3) reads
0xFF; the arbitration loop drains and
aps_do_io()returns 1;aps_probe()returns ENXIO (aps.c:203-206, 315-318) and the driver never attaches. (vm.shguest = QEMU x86_64, no ThinkPad EC device model exists.) - The driver is additionally not loaded by default (GENERIC has it commented:
sys/config/X86_64_GENERIC:178; module is
optional aps isa, sys/conf/files:2600).
Per the audit contract, PoC verification is skipped for Info-severity hardening findings; this pack documents the static proof instead.
Static proof (why the finding is nonetheless certain)
Arithmetic, no hardware needed:
APS_ADDR_BASE = 0x1600,APS_ADDR_SIZE = 0x1f(aps.c:101-102) βbus_set_resource()(aps.c:284-285) allocates ports 0x1600..0x161E (31 ports; rman ranges are [start, start+count)).APS_TWR_RET = 0x1f(aps.c:61);APS_TWR_BASE = 0x10(aps.c:60).aps_do_io()reads offset 0x1f directly at aps.c:193, and both the write loop (aps.c:213-215, n max 15 β offset 0x10+0x0f = 0x1f; bit 15 forced bywmask |= APS_READ_RETat aps.c:210) and the read loop (aps.c:236-238, bit 15 forced at aps.c:233) touch offset 0x1f on every invocation.- 0x1600 + 0x1f = 0x161F β [0x1600, 0x161E] β exactly one port past the allocated window, in every EC transaction (probe/attach/poll/suspend/resume).
- The register file itself (TWR0..TWR15, aps.c:47-48 comment) legitimately
extends to offset 0x1F, so the driver needs a 0x20-byte window; the size
constant is the off-by-one, inherited from OpenBSD aps.c rev 1.19
(
$OpenBSD: aps.c,v 1.19header at aps.c:1).
Why impact = none (what an attacker cannot get)
- The OOB access is a fixed single port (0x161F), fixed direction set (read + one write of constant 0x01 at aps.c:211/215), no attacker-controlled value, count, or timing beyond "driver loaded + EC transaction happens".
- No kernel memory is involved;
bus_space_*_1on x86 is a bareinb/outb. - Loading the driver requires root (
kldload/ kernel config). - No path from this to privilege escalation, info leak, or kernel memory corruption exists on the affected (ThinkPad) hardware β the EC decodes 0x161F anyway, which is why this has been harmless upstream since 2005.
Classes considered and killed in aps.c (pass 2)
- detach vs poll UAF (DF-4647/4773 family):
sensor_task_unregister()(kern_sensors.c:238-249) needsthr->lock; the per-CPU sensor thread holds that lock acrossst->func(st->arg)(kern_sensors.c:307,326-336), so unregister cannot return whileaps_refreshis executing, and a running=0 task is never executed again (freed at kern_sensors.c:328-330).aps_detach(aps.c:405-414) deinstalls sysctls first, then unregisters, then releases the I/O resource β no window. - sysctl strings (DF-4766 family):
sysctl_handle_sensor(kern_sensors.c:474-497) copies into an M_ZERO'd buffer via bounded memcpy;desc[]filled withksnprintf(..., sizeof(...))(aps.c:361-389). No unterminated-string shape. - iobuf[16] uninit / OOB: all
aps_do_ioaccesses are n β [0,15]; every byte written to the EC is initialized (cmd at aps.c:196, args at :215, RET=1 at :211); all bytes consumed afterwards are device-read (aps.c:236-241). - busy-wait DoS:
DRIVERSLEEPis microseconds (sys/platform/pc64/isa/clock.c:485); worst case 100Γ500 Β΅s = 50 ms per transaction β not a stall. (The sensor thread holding its lock across callback sleeps is a kern_sensors.c design property, out of scope for this file.) - sensor counts vs arrays: loops bounded by
APS_NUM_SENSORS(9) againstsensors[APS_NUM_SENSORS](aps.c:116,134,394-395,496,525). - resume double-register:
sensor_task_registerat aps.c:556 is paired with the unregister at aps.c:567 by ACPI suspend/resume ordering. - attach unwinding: failure paths (aps.c:349-358) precede any registration β clean.
Fix validation
Not performed (runtime verification impossible: guest lacks the EC; Info
severity). fix.diff is a static, one-line derivation
(APS_ADDR_SIZE 0x1f β 0x20, making the window 0x1600..0x161F, which is
exactly what aps_do_io touches). No code under sys/ was modified.
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
not_testablenot_testable: fix validation requires real ThinkPad APS EC hardware (or an LPC device model the guest lacks); the one-line fix is a static derivation
fix.diff (APS_ADDR_SIZE 0x1f -> 0x20, unapplied, authored against read-only sys/)
Confirmed kernel references
Detail
Evidence (decisive lines)
README.md 'The bug' section (arithmetic at aps.c:101-102 vs :61,:193,:210-215,:233-238); VERDICT.md 'Static proof' steps 1-5; fix.diff
PoC changes
n/a β no seed PoC; finding derived during pass-2 audit
Verified recommended fix
Widen the allocated I/O window to match the 16-register TWR file the driver touches: #define APS_ADDR_SIZE 0x20 in sys/dev/powermng/aps/aps.c
Verdict
Static-proven off-by-one: aps.c allocates a 0x1f-byte I/O window (0x1600-0x161E) but every aps_do_io() transaction reads/writes port 0x1600+0x1f=0x161F (aps.c:193,210-215,233-238), one past the rman allocation. Not runtime-verified: the QEMU guest has no H8S EC at 0x1600 so the driver never attaches, and Info-severity hardware-gated findings are skipped per audit contract. No kernel-memory impact; fix is the one-line window size 0x1f->0x20.
No comments yet.