@@ -746,6 +746,34 @@ static bool ClipboardSpanIsReadable(const void* base, size_t bytes, size_t* read
746746 return readable >= bytes;
747747}
748748
749+ static int ForeignReadExceptionFilter (DWORD code) {
750+ return (code == EXCEPTION_ACCESS_VIOLATION || code == EXCEPTION_IN_PAGE_ERROR )
751+ ? EXCEPTION_EXECUTE_HANDLER
752+ : EXCEPTION_CONTINUE_SEARCH ;
753+ }
754+
755+ // Copy a span of clipboard-owner memory into caller-owned storage under a
756+ // structured exception guard. The handle GetClipboardData returns is a
757+ // per-process marshaled copy that win32k can free ASYNCHRONOUSLY -- even while
758+ // the clipboard is held open and the handle is GlobalLock'd. Proven by the
759+ // 2026-08-06 PageHeap dump: the readable-span probe and a full
760+ // WideCharToMultiByte sizing scan both succeeded, then the very next read of
761+ // the same buffer faulted on its first byte. No probe can close that race
762+ // (ClipboardSpanIsReadable only inspects page state, and the answer is stale
763+ // the moment it returns), so the one read that touches the foreign pages is
764+ // the guarded one; callers parse only the private copy afterwards. Losing the
765+ // event when the buffer is revoked is acceptable -- we log and drop it.
766+ // No locals with destructors here (see SafeDecodeDibPixels for why).
767+ static bool CopyForeignSpanGuarded (void * dst, const void * src, size_t bytes) {
768+ __try {
769+ std::memcpy (dst, src, bytes);
770+ return true ;
771+ }
772+ __except (ForeignReadExceptionFilter (GetExceptionCode ())) {
773+ return false ;
774+ }
775+ }
776+
749777// Decode a single little-endian uint32 (DWORD) out of a registered clipboard format.
750778// Returns true and sets *outValue only when the format is present AND backed by at
751779// least a readable DWORD; returns false (without touching *outValue) otherwise.
@@ -781,9 +809,9 @@ static bool TryReadClipboardUint32(UINT format, uint32_t* outValue) {
781809 bool ok = false ;
782810 const SIZE_T dataSize = GlobalSize (hData);
783811 size_t readable = 0 ;
784- if (dataSize >= sizeof ( uint32_t ) && ClipboardSpanIsReadable (raw, sizeof ( uint32_t ), &readable)) {
785- uint32_t value = 0 ;
786- std::memcpy (&value, raw, sizeof (value)); // little-endian DWORD on Windows/x86-64
812+ uint32_t value = 0 ; // little-endian DWORD on Windows/x86-64
813+ if (dataSize >= sizeof ( uint32_t ) && ClipboardSpanIsReadable (raw, sizeof ( uint32_t ), &readable) &&
814+ CopyForeignSpanGuarded (&value, raw, sizeof (value))) {
787815 *outValue = value;
788816 ok = true ;
789817 }
@@ -844,12 +872,6 @@ struct DibPixelDecode {
844872 bool preserveAlpha;
845873};
846874
847- static int DibReadExceptionFilter (DWORD code) {
848- return (code == EXCEPTION_ACCESS_VIOLATION || code == EXCEPTION_IN_PAGE_ERROR )
849- ? EXCEPTION_EXECUTE_HANDLER
850- : EXCEPTION_CONTINUE_SEARCH ;
851- }
852-
853875// No locals with destructors may live here: under /EHsc a structured exception
854876// unwinding through this frame would not run C++ destructors, so keeping the body
855877// trivially destructible is what makes catching the access violation safe. All
@@ -922,7 +944,7 @@ static DibDecodeResult SafeDecodeDibPixels(const DibPixelDecode& d) {
922944 }
923945 return DibDecodeResult::Ok;
924946 }
925- __except (DibReadExceptionFilter (GetExceptionCode ())) {
947+ __except (ForeignReadExceptionFilter (GetExceptionCode ())) {
926948 return DibDecodeResult::Faulted;
927949 }
928950}
@@ -1506,34 +1528,43 @@ ClipboardPayload ReadClipboardData(HWND hwnd) {
15061528 if (hData) {
15071529 const wchar_t * utf16Str = static_cast <const wchar_t *>(GlobalLock (hData));
15081530 if (utf16Str) {
1509- // A conformant CF_UNICODETEXT buffer is NUL-terminated within its
1510- // own allocation. A foreign owner (notably the RDP / Terminal
1511- // Services clipboard) can hand us one that isn't -- passing -1 to
1512- // WideCharToMultiByte would then scan for a NUL past the end of the
1513- // buffer (an over-read: intermittent AV in the wild, deterministic
1514- // under PageHeap). So sanity-check first: probe how much of the
1515- // declared buffer is committed and require a NUL within it. No NUL
1516- // -> truncated/garbage (or hostile); drop the whole clipboard event.
1517- // A NUL that IS present makes the -1 scans below safe by construction.
1531+ // Two hazards stack up here. (1) A conformant CF_UNICODETEXT
1532+ // buffer is NUL-terminated within its own allocation, but a
1533+ // foreign owner (notably the RDP / Terminal Services clipboard)
1534+ // can hand us one that isn't -- a -1 WideCharToMultiByte scan
1535+ // would then run past the end of the committed pages. (2) win32k
1536+ // can revoke the marshaled buffer asynchronously, MID-READ, even
1537+ // while the clipboard is open and the handle is GlobalLock'd
1538+ // (see CopyForeignSpanGuarded). So: probe the committed span,
1539+ // copy it exactly once under the SEH guard, and do all scanning
1540+ // and conversion against the private copy.
15181541 const SIZE_T byteSize = GlobalSize (hData);
15191542 size_t readableBytes = 0 ;
15201543 ClipboardSpanIsReadable (reinterpret_cast <const unsigned char *>(utf16Str),
15211544 static_cast <size_t >(byteSize), &readableBytes);
15221545 const size_t readableChars = readableBytes / sizeof (wchar_t );
1523- if (readableChars == 0 || wmemchr (utf16Str, L' \0 ' , readableChars) == nullptr ) {
1546+ std::vector<wchar_t > utf16Copy (readableChars);
1547+ if (readableChars > 0 &&
1548+ !CopyForeignSpanGuarded (utf16Copy.data (), utf16Str, readableChars * sizeof (wchar_t ))) {
1549+ g_logger.log (__FUNCTION__, Logger::Level::Warning,
1550+ L" Ignoring clipboard event: CF_UNICODETEXT buffer was revoked mid-read (declared %zu bytes, %zu readable at probe time)." ,
1551+ static_cast <size_t >(byteSize), readableBytes);
1552+ LogClipboardOwnerDossier (__FUNCTION__, L" revoked CF_UNICODETEXT clipboard buffer" );
1553+ }
1554+ else if (readableChars == 0 || wmemchr (utf16Copy.data (), L' \0 ' , readableChars) == nullptr ) {
15241555 g_logger.log (__FUNCTION__, Logger::Level::Warning,
15251556 L" Ignoring clipboard event: CF_UNICODETEXT is not NUL-terminated within its committed buffer (declared %zu bytes, %zu readable)." ,
15261557 static_cast <size_t >(byteSize), readableBytes);
15271558 LogClipboardOwnerDossier (__FUNCTION__, L" malformed CF_UNICODETEXT clipboard buffer" );
15281559 }
15291560 else {
15301561 // Calculate required buffer size for UTF-8 (including null terminator)
1531- int utf8Size = WideCharToMultiByte (CP_UTF8 , 0 , utf16Str , -1 , nullptr , 0 , nullptr , nullptr );
1562+ int utf8Size = WideCharToMultiByte (CP_UTF8 , 0 , utf16Copy. data () , -1 , nullptr , 0 , nullptr , nullptr );
15321563 if (utf8Size > 0 ) {
15331564 payload.meta .formatId = CLIPP_FORMAT_UTF8 ;
15341565 bytes.resize (utf8Size);
15351566 // Perform the actual conversion straight into the vector
1536- if (WideCharToMultiByte (CP_UTF8 , 0 , utf16Str , -1 ,
1567+ if (WideCharToMultiByte (CP_UTF8 , 0 , utf16Copy. data () , -1 ,
15371568 reinterpret_cast <char *>(bytes.data ()), utf8Size, nullptr , nullptr ) > 0 ) {
15381569 g_logger.log (__FUNCTION__, Logger::Level::Info, L" Read CF_UNICODETEXT from system clipboard (UTF-8 payload: %zu bytes)" , bytes.size ());
15391570 }
@@ -1579,8 +1610,14 @@ ClipboardPayload ReadClipboardData(HWND hwnd) {
15791610 LogClipboardOwnerDossier (__FUNCTION__, L" truncated \" PNG\" clipboard buffer" );
15801611 }
15811612 else if (dataSize > 0 ) {
1582- std::vector<unsigned char > pngData (pngBytes, pngBytes + static_cast <size_t >(dataSize));
1583- if (IsPngStream (pngData)) {
1613+ // One guarded pass over the foreign bytes; validate the private copy.
1614+ std::vector<unsigned char > pngData (static_cast <size_t >(dataSize));
1615+ if (!CopyForeignSpanGuarded (pngData.data (), pngBytes, static_cast <size_t >(dataSize))) {
1616+ g_logger.log (__FUNCTION__, Logger::Level::Warning,
1617+ L" \" PNG\" clipboard buffer was revoked mid-read (%zu bytes); skipping image payload" , static_cast <size_t >(dataSize));
1618+ LogClipboardOwnerDossier (__FUNCTION__, L" revoked \" PNG\" clipboard buffer" );
1619+ }
1620+ else if (IsPngStream (pngData)) {
15841621 payload.meta .formatId = CLIPP_FORMAT_PNG ;
15851622 bytes = std::move (pngData);
15861623 g_logger.log (__FUNCTION__, Logger::Level::Info, L" Read \" PNG\" clipboard format from system clipboard (%zu bytes)" , static_cast <size_t >(dataSize));
@@ -1599,7 +1636,8 @@ ClipboardPayload ReadClipboardData(HWND hwnd) {
15991636 }
16001637 }
16011638 // 2b. Fall back to CF_DIB (Windows synthesizes it from whatever the
1602- // source offered). DIBToPNG re-encodes it under the truncation guard.
1639+ // source offered). The whole DIB is copied once under the SEH guard,
1640+ // so DIBToPNG's header/palette/pixel parsing runs on private memory.
16031641 else if (IsClipboardFormatAvailable (CF_DIB )) {
16041642 HANDLE hData = GetClipboardData (CF_DIB );
16051643 if (hData) {
@@ -1608,19 +1646,35 @@ ClipboardPayload ReadClipboardData(HWND hwnd) {
16081646 // GlobalSize tells us exactly how many bytes the DIB takes up in memory.
16091647 // Encode the local DIB as PNG before placing it into the network payload.
16101648 SIZE_T dataSize = GlobalSize (hData);
1611- if (dataSize > 0 ) {
1612- std::vector<unsigned char > pngData;
1613- ScopedTimer timer (L" Clipboard DIB to PNG encoding" );
1614- if (DIBToPNG (dibData, static_cast <size_t >(dataSize), pngData)) {
1615- payload.meta .formatId = CLIPP_FORMAT_PNG ;
1616- bytes = std::move (pngData);
1617- g_logger.log (__FUNCTION__, Logger::Level::Info, L" Read CF_DIB from system clipboard and encoded PNG payload (DIB: %zu bytes, PNG: %zu bytes)" , static_cast <size_t >(dataSize), bytes.size ());
1618- } else {
1619- g_logger.log (__FUNCTION__, Logger::Level::Debug, L" Failed to encode CF_DIB clipboard image as PNG; skipping image payload" );
1620- }
1621- } else {
1649+ size_t readableDibBytes = 0 ;
1650+ if (dataSize == 0 ) {
16221651 g_logger.log (__FUNCTION__, Logger::Level::Debug, L" CF_DIB clipboard data has zero byte GlobalSize; skipping image payload" );
16231652 }
1653+ else if (!ClipboardSpanIsReadable (dibData, static_cast <size_t >(dataSize), &readableDibBytes)) {
1654+ g_logger.log (__FUNCTION__, Logger::Level::Error,
1655+ L" Refusing to read CF_DIB clipboard data: buffer is not fully committed (declared %zu bytes, only %zu readable)" ,
1656+ static_cast <size_t >(dataSize), readableDibBytes);
1657+ LogClipboardOwnerDossier (__FUNCTION__, L" truncated CF_DIB clipboard buffer" );
1658+ }
1659+ else {
1660+ std::vector<unsigned char > dibCopy (static_cast <size_t >(dataSize));
1661+ if (!CopyForeignSpanGuarded (dibCopy.data (), dibData, static_cast <size_t >(dataSize))) {
1662+ g_logger.log (__FUNCTION__, Logger::Level::Warning,
1663+ L" CF_DIB clipboard buffer was revoked mid-read (%zu bytes); skipping image payload" , static_cast <size_t >(dataSize));
1664+ LogClipboardOwnerDossier (__FUNCTION__, L" revoked CF_DIB clipboard buffer" );
1665+ }
1666+ else {
1667+ std::vector<unsigned char > pngData;
1668+ ScopedTimer timer (L" Clipboard DIB to PNG encoding" );
1669+ if (DIBToPNG (dibCopy.data (), static_cast <size_t >(dataSize), pngData)) {
1670+ payload.meta .formatId = CLIPP_FORMAT_PNG ;
1671+ bytes = std::move (pngData);
1672+ g_logger.log (__FUNCTION__, Logger::Level::Info, L" Read CF_DIB from system clipboard and encoded PNG payload (DIB: %zu bytes, PNG: %zu bytes)" , static_cast <size_t >(dataSize), bytes.size ());
1673+ } else {
1674+ g_logger.log (__FUNCTION__, Logger::Level::Debug, L" Failed to encode CF_DIB clipboard image as PNG; skipping image payload" );
1675+ }
1676+ }
1677+ }
16241678 GlobalUnlock (hData);
16251679 } else {
16261680 LogLastError (__FUNCTION__, L" Failed to lock CF_DIB clipboard data" );
0 commit comments