cc28449d96 Fix use-after-move crash in TransferString::release() with GCC
Triage note: Unspecified argument evaluation order let span() run after memory was moved, reading moved-from/freed backing store — a real use-after-move memory-safety bug.
Contents
The bug at a glance
This is a real use-after-move memory-safety bug in the IPC string-transfer path: TransferString::release() passed memory->span() and a lambda that moved out the backing RefPtr as sibling arguments, and under GCC’s typical right-to-left argument evaluation the move ran first, so span() then read m_data/m_size through a nulled RefPtr, producing a SIGSEGV. It is rated medium because on the primarily-tested Clang toolchain (left-to-right) the bug does not manifest, and the crash is a null-ish deref of a moved-from RefPtr rather than a controllable heap UAF, so realistic impact is a crash / denial of service on GCC-built WebKit (WebKitGTK/WPE). Reachability is straightforward: any IPC-transferred string larger than ~16 KB (transferAsMappingSize) takes the shared-memory path that contains the bug.
The defect is C++ unspecified argument-evaluation order combined with a move that invalidates a value read in a sibling argument: ExternalStringImpl::create(memory->span(), [memory = memory.releaseNonNull()]{}) requires memory->span() to be evaluated before memory is moved out, but the language does not guarantee that ordering. GCC evaluates arguments right-to-left, so releaseNonNull() nulls the RefPtr before span() dereferences it. The fix hoists span() into a local before the create() call.
Root cause
TransferString::release() converts an IPC-transferred string back into a WTF::String. For large payloads it avoids a copy by wrapping the shared-memory mapping in an ExternalStringImpl: the external string points directly at the mapping’s bytes and holds a keep-alive lambda that owns the mapping so the memory stays valid for the string’s lifetime. Both the Latin1 (SharedSpan8) and char16_t (SharedSpan16) paths do this.
The pre-patch code was, for the Latin1 path: Ref<StringImpl> impl = ExternalStringImpl::create(byteCast<Latin1Character>(memory->span()), [memory = memory.releaseNonNull()] (auto…) mutable { });. There are two arguments to create(): argument 1 is byteCast<Latin1Character>(memory->span()), which dereferences the RefPtr memory to read the mapping’s data pointer and size (m_data/m_size); argument 2 is a lambda whose init-capture [memory = memory.releaseNonNull()] calls releaseNonNull() on the same RefPtr, which returns a Ref and leaves memory null. These two argument expressions both touch memory, and one of them (the capture) destroys memory’s pointee reference by moving it out.
C++ leaves the evaluation order of function-call arguments unspecified (they are indeterminately sequenced). The correctness of this line therefore silently depended on argument 1 being evaluated before argument 2. GCC typically evaluates arguments right-to-left, so it evaluated the lambda’s capture first: memory.releaseNonNull() moved the reference out and nulled memory, and then byteCast<Latin1Character>(memory->span()) executed memory->span() on the now-null RefPtr, dereferencing null to read m_data and m_size — a use-after-move that manifests as a SIGSEGV (valgrind: ‘Invalid read of size 8’). Clang typically evaluates left-to-right, so span() ran first and the bug did not appear on macOS/Clang, which is why it survived — WebKit’s primary CI is Clang. The char16_t path had the identical structure with spanReinterpretCast<const char16_t>(memory->span()).
The trigger condition is memory->size() > maxCopySizeInBytes, i.e. the shared-memory/external path is taken only when the mapping exceeds transferAsMappingSize - 1 (16383 bytes); smaller strings take the copying path (String { byteCast<…>(memory->span()) }) which is a single expression and not affected. So the crash reproduces on GCC builds when an IPC-transferred string exceeds ~16 KB — the reported case being Evolution sending a long HTML email body to WebKitWebProcess via RunJavaScriptInFrameInScriptWorld.
The fix extracts the span into a local variable evaluated before the create() call: auto span = byteCast<Latin1Character>(memory->span()); then ExternalStringImpl::create(span, [memory = memory.releaseNonNull()] …). Because the local initialization is fully sequenced before the create() call’s argument evaluation, memory->span() always runs while memory is still valid, regardless of the compiler’s argument-evaluation order. The same change is applied to both the Latin1 and char16_t branches.
Key code
TransferString::release: extract span before the move-capturing create() call (TransferString.cpp)
if (memory->size() > maxCopySizeInBytes) {
auto span = byteCast<Latin1Character>(memory->span());
Ref<StringImpl> impl = ExternalStringImpl::create(span, [memory = memory.releaseNonNull()] (auto...) mutable { });
return std::optional<String> { std::in_place, String { WTF::move(impl) } };
}
// ... char16_t path, identical structure:
if (memory->size() > maxCopySizeInBytes) {
auto span = spanReinterpretCast<const char16_t>(memory->span());
Ref<StringImpl> impl = ExternalStringImpl::create(span, [memory = memory.releaseNonNull()] (auto...) mutable { });
return std::optional<String> { std::in_place, String { WTF::move(impl) } };
}
Patch walkthrough
Source/WebKit/Platform/IPC/TransferString.cpp— In TransferString::release(), for both the SharedSpan8 (Latin1) and SharedSpan16 (char16_t) large-string branches, hoists memory->span() into a local variable (auto span = …) that is evaluated before ExternalStringImpl::create() is called. Previously span() was one call argument and the move-capture [memory = memory.releaseNonNull()] was the sibling argument; unspecified argument order meant the move could run first (GCC right-to-left), nulling memory before span() dereferenced it. Sequencing the local init before the call removes the ordering dependency.
Background
C++ unspecified argument evaluation order — In C++ the order in which the arguments of a function call are evaluated is unspecified (indeterminately sequenced) up to C++17 and remains unsequenced relative to each other. Code that has one argument produce a side effect another argument depends on is a latent bug: it may work on one compiler and crash on another. GCC commonly evaluates right-to-left and Clang/MSVC left-to-right, so such bugs are frequently toolchain-specific.
Use-after-move — Moving out of an object (here RefPtr::releaseNonNull()) leaves it in a valid-but-unspecified/empty state; for RefPtr that means null. Reading through the moved-from object afterwards (memory->span()) is a use-after-move. Unlike a use-after-free of heap data, this particular one dereferences a null RefPtr to read the pointee’s members, so it faults deterministically as a null-region read rather than reading attacker-reclaimed memory.
ExternalStringImpl and keep-alive lambda — ExternalStringImpl::create builds a WTF::String backed by externally-owned memory rather than a heap copy; it takes a span pointing at that memory and a free/keep-alive callback invoked when the string dies. Here the keep-alive lambda captures the RefPtr to the shared-memory mapping (moved in via releaseNonNull()), so the mapping outlives the request and stays valid for the external string. The span argument must be computed from the mapping before ownership is moved into the lambda.
IPC TransferString and transferAsMappingSize — TransferString is WebKit’s IPC mechanism for sending strings between processes; small strings are inline-copied while strings larger than transferAsMappingSize (the shared-memory threshold, here 16384 bytes so the ‘> 16383’ condition) are transferred as a shared-memory mapping to avoid copies. release() reconstructs the String on the receiving side, taking the external/shared-memory path only for the large case — which is why only strings over ~16 KB triggered the crash.
SharedSpan8 vs SharedSpan16 — TransferString handles both Latin1 (8-bit, byteCast<Latin1Character>) and UTF-16 (char16_t, spanReinterpretCast<const char16_t>) string content, corresponding to WTF::String’s two internal representations. Both branches used the same span-plus-move-capture pattern, so both were vulnerable and both were fixed identically; the char16_t branch additionally checks memory->size() % sizeof(char16_t) for alignment.
Vulnerability window
- Introduction — TransferString::release() writes ExternalStringImpl::create(memory->span(), [memory = memory.releaseNonNull()]{}) for large strings, latently depending on left-to-right argument evaluation.
- Silent on Clang — WebKit’s primary macOS/Clang CI evaluates arguments left-to-right, so span() runs before the move and the bug never manifests in main testing.
- Field crash on GCC — On WebKitGTK/WPE built with GCC (right-to-left), Evolution sends a >16 KB HTML email body via RunJavaScriptInFrameInScriptWorld; release() takes the shared-memory path and SIGSEGVs (valgrind: Invalid read of size 8) reading through the moved-from RefPtr.
- Diagnosis — Root-caused to unspecified argument-evaluation order causing releaseNonNull() to run before memory->span().
- Fix — Hoist memory->span() into a local before the create() call in both the Latin1 and char16_t branches, making the read sequenced before the move.
- Release — Landed 313602@main on May 20 2026 (bug 311995) by Milan Crha, reviewed by Michael Catanzaro.
Triggering
No automated test was added. Trigger: on a GCC-built WebKit (WebKitGTK/WPE), cause an IPC string transfer whose payload exceeds transferAsMappingSize - 1 (16383 bytes) so TransferString::release() takes the shared-memory (ExternalStringImpl) path — e.g. send a large (>16 KB) string across process via an IPC message such as RunJavaScriptInFrameInScriptWorld (the reported Evolution HTML-email case). Under GCC’s right-to-left argument evaluation, memory.releaseNonNull() nulls the RefPtr before memory->span() reads it, producing a SIGSEGV. Not reproducible on Clang builds due to left-to-right evaluation.
Exploitation
- Reach — Send an IPC message carrying a string larger than ~16 KB to a GCC-built WebKit process so TransferString::release() enters the large-string ExternalStringImpl branch.
- Trigger — GCC’s right-to-left argument evaluation moves the RefPtr out first; memory->span() then dereferences the nulled RefPtr, reading m_data/m_size at a null offset.
- Impact — Deterministic SIGSEGV (null-region read) — a crash / denial of service of the receiving WebKit process. This is not a controllable heap use-after-free: the moved-from RefPtr is null, so the read targets the null page, not attacker-reclaimed data.
- Outcome — Crash-only on GCC toolchains (WebKitGTK/WPE); no evidence of exploitable memory disclosure or control-flow in the patch. Clang builds are unaffected.
Detection & hunting
For defenders and SOC / detection engineers:
- WebKitGTK/WPE crashes in TransferString::release —
- Large-string IPC + immediate crash —
- Valgrind/ASan on GCC CI —
Audit directions
- Move-capture as a sibling argument —
- ExternalStringImpl::create call sites —
- GCC vs Clang divergence —
- Other TransferString / shared-memory reconstructors —