Skip to content

Commit 6f7647c

Browse files
committed
fix: add structured exception handling for clipboard memory access on windows
1 parent bb4487c commit 6f7647c

1 file changed

Lines changed: 90 additions & 36 deletions

File tree

src/platform/win32/Clipboard.cpp

Lines changed: 90 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)