β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-4811

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4811 Β· 5 files
FileTypeDescriptionSize
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 via device aps0 at isa? port 0x1600 (LINT64:1677) or kldload 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.md
↓ download raw

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.sh guest = 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:

  1. 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)).
  2. APS_TWR_RET = 0x1f (aps.c:61); APS_TWR_BASE = 0x10 (aps.c:60).
  3. 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 by wmask |= APS_READ_RET at aps.c:210) and the read loop (aps.c:236-238, bit 15 forced at aps.c:233) touch offset 0x1f on every invocation.
  4. 0x1600 + 0x1f = 0x161F βˆ‰ [0x1600, 0x161E] β‡’ exactly one port past the allocated window, in every EC transaction (probe/attach/poll/suspend/resume).
  5. 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.19 header 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_*_1 on x86 is a bare inb/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) needs thr->lock; the per-CPU sensor thread holds that lock across st->func(st->arg) (kern_sensors.c:307,326-336), so unregister cannot return while aps_refresh is 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 with ksnprintf(..., sizeof(...)) (aps.c:361-389). No unterminated-string shape.
  • iobuf[16] uninit / OOB: all aps_do_io accesses 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: DRIVERSLEEP is 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) against sensors[APS_NUM_SENSORS] (aps.c:116,134,394-395,496,525).
  • resume double-register: sensor_task_register at 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_testable
baseline no→ patch + rebuild →patched clean

not_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/)
↓ fix.diffper-fix-DF-4811

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.