The hp-bioscfg field report: auditing one driver into four CVEs

A complete walkthrough of the HP BIOS configuration driver audit: the newline pattern, the off-by-one terminator, and a count read through a union.

This is a field report from a complete audit of drivers/platform/x86/hp/hp-bioscfg, the HP BIOS configuration driver, conducted across May to November 2026. Thirteen patches from the audit were merged upstream, four CVEs were assigned to the first wave, and the fixes were tested by three people on four HP machines. The material here is drawn from the patches themselves, the upstream discussion on the platform-driver-x86 list, and the current source in the kernel tree; each section links to the full research note with exploit code.

The driver exposes firmware attributes through sysfs: /sys/class/firmware-attributes/hp/attributes/<name>/ for per-attribute settings and /sys/class/firmware-attributes/hp/authentication/SPM/ for Secure Platform Management keys and tokens. Userspace writes strings, firmware answers through WMI buffers, and the kernel glues the two together. Every bug below lives in that glue.

How a write reaches HP firmware: userspace, sysfs store handlers, shared helpers, and the WMI firmware interface, with the audit's bug sites marked
How a write reaches HP firmware: userspace, sysfs store handlers, shared helpers, and the WMI firmware interface, with the audit's bug sites marked

The architecture, and where it breaks

Three facts about this driver shaped the whole audit.

First, every write path makes a kstrdup() or kmemdup() copy of the input before validating it, so every length disagreement between "bytes the user wrote" and "bytes the copy holds" becomes an out-of-bounds access on the copy, not on the user buffer. Second, every read path parses a WMI buffer whose string fields are UTF-16 with a firmware-supplied u16 byte length, so the parser is only as safe as its arithmetic on a length it did not choose. Third, all of it runs at driver init on every affected HP machine, before any user touches anything, which is why the enumeration-time bugs carry no-privileges exposure in their CVSS vectors.

The WMI string parser pipeline with the four bugs marked A through D and the fixes that landed
The WMI string parser pipeline with the four bugs marked A through D and the fixes that landed

The newline pattern

Two CVEs, one root cause. Sysfs writes from a shell almost always end in a newline, so the driver strips it everywhere. The strip itself was written without a length guard in two different places.

In validate_password_input() the code computed strlen(buf) and immediately read buf[length - 1] to test for the newline. A write of a bare newline gives length zero, so the read lands one byte before the heap allocation: CVE-2026-81013. In sk_store() and kek_store() the code stripped the newline, allocated with kmemdup() for the shortened length, and then passed the original count to the firmware query, reading one byte past the allocation on every echo: CVE-2026-81014. KASAN confirmed both with one byte reports, and the 81014 advisory carries the full splat.

The lesson I keep from this pair: the same conceptual bug, a newline strip written three months apart by the same team, produced two separate CVEs. Copy paste hygiene in parsers is a security control.

The terminator that did not fit

hp_get_string_from_buffer() clamps a decoded string against the destination size with a strict greater-than, so a string exactly the size of the destination leaves the NUL terminator writing one byte past it. Every fixed size buffer in the driver reaches this function: path[512], current_value[512], the 64 byte password fields, encodings[][512], prerequisites[][512]. That is CVE-2026-81012, the highest scored of the set at 8.4, with no privileges required in the vector because the write fires during firmware driven enumeration rather than on a privileged user action.

The trigger needs an attribute value whose decoded length lands exactly on the buffer size, which makes it firmware dependent and, in truth, mostly dormant on real machines. I filed it anyway. Off-by-one terminator writes are one refactor away from being live, and the fix is one character.

A count read through a union

My favorite find of the series. The per-type package parsers receive their element count from elements[0]. Element zero is the NAME field and is always an ACPI string, so reading ->package.count from it actually returns ->string.length through the union acpi_object. The parsers were bounding their loops against the length of the attribute's name.

On shipped kernels this is unreachable, because the init path rejects packages smaller than the type requires. It becomes reachable the moment that check is relaxed to support older HP BIOS variants that ship reduced packages, which is exactly the follow-up series I was preparing. The count fix went in first, standalone, and carries CVE-2026-81011 on its own merits. Code whose safety depends on a check in a different file is a bug that is waiting for a refactor.

Deep dive: four bugs in one parser

hp_get_string_from_buffer() alone held four distinct bugs, and it is worth walking through all of them, because this one function is a catalog of how parsers fail.

Bug A, the accumulator that bounds itself. The loop counting backslash escapes used the same variable as both accumulator and loop bound:

size = src_size / sizeof(u16);
for (i = 0; i < size; i++)
        if (src[i] == '\\' || src[i] == '\r' ||
            src[i] == '\n' || src[i] == '\t')
                size++;

Every escape character found increments size, which is also what i is compared against, so one escape near the end of the valid range extends the walk past the buffer, and each additional escape found there extends it further. A self-amplifying out-of-bounds read driven by firmware data.

Bug B, bytes where units belong. utf16s_to_utf8s() takes its input length in u16 units; the code passed src_size, a byte count, so the conversion could walk up to twice the real character count before the output cap stopped it.

Bug C, the check after the step. src_size = *(src++) reads the length and steps past it in one statement, and the bounds check came after, comparing *buffer_size against src_size as if the two prefix bytes were still available. An exact-size buffer read two bytes past its end.

Bug D, the desync. At the end the function advanced the buffer pointer and remaining length by the escape-inflated size instead of the true consumption, sizeof(u16) + src_size. Every subsequent field parser in the same enumeration then read from the wrong offset, which is how one bad string can corrupt the parsing of every attribute after it.

The fix keeps a separate orig_size accumulator, checks sizeof(u16) + src_size before reading, passes orig_size to the conversion, and advances by the true consumption. The terminator bug from CVE-2026-81012 sat on top of all of this and was fixed in the same function.

Deep dive: dead conversion and ASCII truncation

Reading the parser end to end surfaced something better than a bounds bug. The function calls utf16s_to_utf8s() and then runs a manual loop that overwrites the entire destination with dst[i] = *src, a raw truncating cast. Since the conversion takes src by value, it cannot advance the caller's pointer, so the second loop restarts from the same position and discards everything the conversion wrote. The conversion was dead work, and the function was ASCII-only in practice: any character above 0x7f was truncated to garbage instead of being encoded.

The redesign settled on using generic code instead of hand-rolled escaping: convert with utf16s_to_utf8s() into a scratch buffer, then run string_escape_mem() over the UTF-8 output, which is safe without being UTF-8 aware because every byte the escape flags can match is ASCII and below 0x80, and UTF-8 lead and continuation bytes never are. One sizing trap worth flagging: src_size is firmware controlled up to a u16's worth of bytes, whose worst-case UTF-8 expansion approaches 100KB, so the scratch path must cap at the destination size, which is 512 at every call site in this driver, rather than trust the claimed length.

Deep dive: the single-line helper

The last bug of the series sits in hp_enforce_single_line_input(), the helper every store path funnels through. The input arrives as a kstrdup() copy: the copy stops at the first NUL, but the full write size is passed to the helper, so with an embedded NUL the memchr() scan for the newline runs past the end of the copy, and can write one byte past it when stripping.

The fix replaces memchr() with strnchrnul(), which stops at the first NUL on its own, and turns the second branch into a pointer comparison so a newline found anywhere before the final byte fails with -EINVAL:

-       p = memchr(buf, '\n', count);
+       p = strnchrnul(buf, count, '\n');

        if (p == buf + count - 1)
                *p = '\0'; /* strip trailing newline */
-       else if (p)
+       else if (p < buf + count - 1)
                return -EINVAL;  /* enforce single line input */

Verified on an HP EliteBook 840 G2 running Fedora 44 on Linux 7.2.8: writing A\0 followed by 4093 bytes of B to current_password a hundred times returns -EINVAL every time, with no read past the allocation. The patch carries Andy Shevchenko's Reviewed-by and is queued through the platform-drivers-x86 tree for mainline and stable.

Real-world confirmation

The audit also fixed a functional bug users had been hitting. The SPM authentication token path had an early return for the empty token that skipped populating the security buffer, and the size calculation for the empty case produced a 16 byte heap overflow on top of it. Applied as 67e222d412cc with Fixes: b2715aa2e135 and Cc: stable.

A third party, Saatvik Sharma, later tested that patch on hardware I do not own: an HP ZBook Firefly 16 inch G11, kernel 6.18.55. Before the fix, writing to the Customized Fan Control Options attribute failed with EINVAL and the driver logged error 0x4, "Invalid command type". With the patch rebuilt into 6.18.55, the write succeeds and reads back correctly. That Tested-by closed the loop: what began as a heap overflow audit also fixed a BIOS setting that was simply broken on real machines.

Method notes

The audit was manual reading plus KASAN harnesses, no fuzzing. The workflow that found these:

  • Read every store handler and ask where the length comes from and where the buffer ends.
  • Read every fixed size array and check the terminator separately from the content.
  • Read every ACPI package walk and verify the bound against the real package.count, not a derived value.
  • Reproduce before fixing. Every KASAN log in the advisories came from a harness I built first.

Timeline

DatePatchOutcome
Aug 2026hp_get_string_from_buffer() four-bug fixmerged as three commits
Sep 2026password newline underflow, sk/kek count, terminator, union countCVE-2026-81011 through 81014
Sep 2026empty auth token 16-byte heap overflow67e222d412cc, later Tested-by on ZBook G11
Oct 2026integer attribute oops, SPM state validation, token NUL-termination, password length off-by-one, -E2BIG, allow clearingfollow-up hardening series
Oct 2026hp_enforce_single_line_input() heap OOBReviewed-by, queued via platform-drivers-x86
Nov 2026string conversion redesignstring_escape_mem() based path

Coverage elsewhere

The series was picked up beyond the trackers. SentinelOne's vulnerability database carries full entries for the three scored CVEs of the first wave: CVE-2026-81011, CVE-2026-81012, and CVE-2026-81013. Their pages independently walk the same technical analysis and list the same stable backport commits, and their numbers line up with mine: EPSS at 0.13, 0.14, and 0.18 percent respectively, no known exploitation anywhere, and the same CVSS vectors the CNA published, with 81013 left unscored.

freenode's Kernel & Low-Level digest covered the nine-patch series as news: HP BIOS config driver gets heap overflow and OOB fixes. Two things in their summary are worth highlighting here because they add to the record. They correctly identified the empty auth token path as the most severe item, for a reason my own writeup understates: the empty token is the default state on any machine with no admin or setup password configured, so ordinary writes to writable attributes on a stock system could reach the 16-byte overflow. And they covered the reliability half of the series that the CVE record does not capture: new-password writes being routed into the current-password field, ordered-list attributes ending up with empty element lists, and the shared indexing mistake in all five attribute-type parsers that produced an "Invalid command value" error from the driver at every boot on my EliteBook 840 G2 test machine until the index advance was corrected.

References

  1. The four CVE notes with exploit code: CVE-2026-81011, CVE-2026-81012, CVE-2026-81013, CVE-2026-81014
  2. The parser fix series on lore: lore 20260825
  3. The empty auth token fix thread, including Saatvik Sharma's Tested-by: lore 20260919060037
  4. The single-line-input fix: lore 20261026051740
  5. The introducing commit for the whole driver: a34fc329b189 on kernel.org
  6. Driver source in current mainline: drivers/platform/x86/hp/hp-bioscfg
  7. SentinelOne entries: 81011, 81012, 81013
  8. freenode Kernel & Low-Level: HP BIOS config driver gets heap overflow and OOB fixes

Scores across the four CVEs, for the record: 8.4 High with no privileges required (81012), 7.1 High (81011), and two unscored by the CNA with distro re-scores in the 4.4 to 5.5 range (81013, 81014). The spread between what the CNA scores and what a distro will actually patch is the most useful thing this series taught me about reporting bugs.