Summary
On Windows, PHP's filesystem and stream APIs do not reject reserved device names such as CON, PRN, AUX, NUL, COM1 to COM9, LPT1 to LPT9, CONIN$ and CONOUT$ when they appear as a component of a path. An attacker-controlled filename therefore reaches CreateFileW() and opens a device instead of the regular file the application expected, which can block or hang the request and exhaust worker processes.
Details
Paths from application code flow through the stream wrapper layer into the Windows I/O layer. file_get_contents() opens the requested path via php_stream_open_wrapper_ex(), and move_uploaded_file() reaches VCWD_RENAME() and php_copy_file_ex(), so paths built from user-controlled upload filenames arrive at the same place.
The Windows path validation macro checks only for trailing spaces and certain trailing dots:
https://github.com/php/php-src/blob/php-8.5.10/win32/ioutil.h#L156-L169
There is no check for reserved device names, and php_win32_ioutil_open_w() passes the path straight to CreateFileW():
https://github.com/php/php-src/blob/php-8.5.10/win32/ioutil.c#L205
That device handles can result from ordinary file opening is visible further on, where a handle whose GetFileType() is FILE_TYPE_CHAR is marked as S_IFCHR in win32/ioutil.c and simply flagged as non-seekable in main/streams/plain_wrapper.c, rather than being rejected.
The fix adds php_win32_ioutil_path_kind_w() and php_win32_ioutil_path_kind_a(), which classify a path as ordinary, a bare device name, or a path containing a reserved component. Reserved components are rejected with ERROR_INVALID_NAME before any I/O, while bare device names such as NUL and NUL: keep working for backwards compatibility, and DOS device paths (\\.\, \\?\ and \??\) are left alone because Windows does not map device names inside them. Variants such as NUL.txt, aux:stream and NUL are resolved through RtlIsDosDeviceName_U so that PHP follows the behaviour of the Windows version it runs on.
PoC