CVE-2026-15774
Overview
Background
- `SubRunAllocator`
- a fast bump allocator in Skia’s GPU text stack that owns a
BagOfBytesarena (fAlloc) used to hand out memory for text sub-runs. - `SubRunInitializer<T>`
- a helper that owns the raw backing block returned by
::operator newand constructs aTinto it via placementnew, freeing the block with::operator deletewhen it still owns it. - `BagOfBytes`
- the arena type embedded in
SubRunAllocator; its destructor~BagOfByteswalks bookkeeping such asfEndByte, which can point inside the block owned bySubRunInitializer<T>. - `std::tuple` destruction order
- the C++ standard does not specify the order in which
std::tuplemembers are destroyed, so the relative teardown of tuple elements is not guaranteed.
Root Cause Analysis
SubRunAllocator::AllocateClassMemoryAndArena allocated one block with ::operator new, wrapped it in a SubRunInitializer<T>, and built a SubRunAllocator (alloc) whose BagOfBytes arena pointed into the tail of that same block, then returned all three bundled in a std::tuple<SubRunInitializer<T>, int, SubRunAllocator>. When a caller such as SlugImpl::MakeFromBuffer detected an invalid or corrupted buffer and returned nullptr early, that temporary tuple was destroyed, but because tuple member destruction order is unspecified the SubRunInitializer<T> (element 0) could run first and free the backing memory via ::operator delete. The SubRunAllocator (element 2) was then destroyed second, and its ~BagOfBytes dereferenced fEndByte, which now pointed into the freed block, producing a use-after-free read followed by a wild-free or double-free. The invariant violated is that the arena (alloc) must be torn down before the object that owns and frees its backing storage.
The fix replaces the tuple with the struct AllocateAndArenaResult<T>, declaring alloc last so that guaranteed reverse-declaration-order member destruction always tears down alloc before initializer frees the memory.
std::tuple for two members with a hard destruction-order dependency, when the standard leaves tuple member destruction order unspecified. The fix moves them into AllocateAndArenaResult<T>, where struct members are guaranteed to be destroyed in reverse declaration order, so declaring alloc after initializer deterministically frees the arena first.Attack Path
- Trigger deserialization
An attacker supplies a crafted, invalid or corrupted serialized Slug buffer that reaches
SlugImpl::MakeFromBuffer. - Force the early return
Skia’s validation detects the malformed buffer and returns
nullptrearly, which destroys the temporarystd::tupleproduced bySubRunAllocator::AllocateClassMemoryAndArena. - Provoke the misordered teardown
With destruction order unspecified,
SubRunInitializer<T>(tuple element 0) runs first and frees the backing block via::operator delete, beforeSubRunAllocator(element 2) is destroyed. - UAF in the arena destructor
~BagOfBytesthen readsfEndByte, which points inside the already-freed block, yielding a use-after-free read followed by a wild-free or double-free. - Escalation
Turning this freed-memory access and corrupted free into a controlled read/write primitive or code execution is not shown by the diff
(inferred).
Impact Assessment
(inferred). The precondition is reaching SlugImpl::MakeFromBuffer with attacker-influenced, invalid buffer data so that the early nullptr return destroys the temporary. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.3. Any escalation to a read/write primitive or remote code execution is (inferred) rather than demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
SubRunInitializersrc/text/gpu/SubRunAllocator.h |
modified | |
AllocateClassMemoryAndArenasrc/text/gpu/SubRunAllocator.h |
modified |
Files Changed
src/text/gpu/SubRunAllocator.h
Audit Directions
- Ordered-teardown in aggregatesAudit other uses of
std::tuple,std::pair, or aggregate returns where one element owns storage that another element points into, since these types do not guarantee element destruction order. - Arena-backed helper lifetimesReview other
SubRunAllocator/BagOfBytesbundling sites and any allocator whose destructor (e.g.~BagOfBytesreadingfEndByte) touches memory owned by a sibling object, especially on error/early-return paths. - Deserialization early returnsInspect deserialization entry points like
SlugImpl::MakeFromBufferthat returnnullptron invalid input, where destruction of partially constructed temporaries can expose latent lifetime-ordering bugs.
Patch
From 014a77e8a6cd4abbd5952fd4a1815ec3a521f300 Mon Sep 17 00:00:00 2001 From: Arthur Sonzogni <[email protected]> Date: Fri, 03 Jul 2026 15:03:41 +0000 Subject: [PATCH] Fix Use-After-Free in SubRunAllocator The destruction order of `std::tuple` members is not specified by the C++ standard. This is the root cause of a Use-After-Free (UAF) in SubRunAllocator. Replacing the tuple with a custom struct resolves the issue by guaranteeing the correct destruction order. The Bug: During deserialization of a Slug (specifically in SlugImpl::MakeFromBuffer), if the input buffer is invalid or corrupted, Skia detects this and returns nullptr early. This early return destroys the temporary return value. In the old code (using `std::tuple`), `SubRunInitializer` (index 0) was destructed first and freed the backing memory. `SubRunAllocator` (index 2) was destructed next. Its destructor (~BagOfBytes) then attempted to access fEndByte (which points inside the freed memory), resulting in a UAF (read) followed by a wild-free or double-free. The Fix: We replaced the `std::tuple` with a custom helper struct `AllocateAndArenaResult`: struct AllocateAndArenaResult { SubRunInitializer<T> initializer; // Destructed last int totalMemorySize; SubRunAllocator alloc; // Destructed first }; Since struct members are guaranteed to be destructed in the reverse order of their declaration, declaring `alloc` last guarantees it is destructed before `SubRunInitializer` frees the memory. Additionally, this CL refactors `SubRunInitializer` to use `std::unique_ptr` with a custom deleter to manage the raw memory, removing the need for a manual destructor and making the ownership transfer explicit via `release()`. Bug: skia:530646115 Change-Id: I80cba4fdb9eebfe16e5ec837d70b5646422fbcff Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1284796 Commit-Queue: Kaylee Lubick <[email protected]> Reviewed-by: Kaylee Lubick <[email protected]> --- diff --git a/src/text/gpu/SubRunAllocator.h b/src/text/gpu/SubRunAllocator.h index 921f0f7..dd5c9d7 100644 --- a/src/text/gpu/SubRunAllocator.h +++ b/src/text/gpu/SubRunAllocator.h @@ -188,23 +188,30 @@ template <typename T> class SubRunInitializer { public: - SubRunInitializer(void* memory) : fMemory{memory} { SkASSERT(memory != nullptr); } - ~SubRunInitializer() { - ::operator delete(fMemory); - } + explicit SubRunInitializer(void* memory) : fMemory{memory} { SkASSERT(memory != nullptr); } + template <typename... Args> T* initialize(Args&&... args) { // Warn on more than one initialization. SkASSERT(fMemory != nullptr); - return new (std::exchange(fMemory, nullptr)) T(std::forward<Args>(args)...); + return new (fMemory.release()) T(std::forward<Args>(args)...); } private: - void* fMemory; + struct Deleter { + // Frees the heap memory without calling a destructor. + // We must use `::operator delete(p)` instead of `delete p` because `p` is `void*`. + // Deleting a `void*` is undefined behavior in C++. + // This is paired with the placement `::operator new` in AllocateClassMemoryAndArena. + void operator()(void* p) const { ::operator delete(p); } + }; + std::unique_ptr<void, Deleter> fMemory; }; -// GrSubRunAllocator provides fast allocation where the user takes care of calling the destructors -// of the returned pointers, and GrSubRunAllocator takes care of deleting the storage. The +template <typename T> struct AllocateAndArenaResult; + +// SubRunAllocator provides fast allocation where the user takes care of calling the destructors +// of the returned pointers, and SubRunAllocator takes care of deleting the storage. The // unique_ptrs returned, are to assist in assuring the object's destructor is called. // A note on zero length arrays: according to the standard a pointer must be returned, and it // can't be a nullptr. In such a case, SkArena allocates one byte, but does not initialize it. @@ -234,20 +241,8 @@ SubRunAllocator& operator=(SubRunAllocator&&) = default; template <typename T> - static std::tuple<SubRunInitializer<T>, int, SubRunAllocator> - AllocateClassMemoryAndArena(int allocSizeHint) { - SkASSERT_RELEASE(allocSizeHint >= 0); - // Round the size after the object the optimal amount. - int extraSize = BagOfBytes::PlatformMinimumSizeWithOverhead(allocSizeHint, alignof(T)); - - // Don't overflow or die. - SkASSERT_RELEASE(INT_MAX - SkTo<int>(sizeof(T)) > extraSize); - int totalMemorySize = sizeof(T) + extraSize; - - void* memory = ::operator new (totalMemorySize); - SubRunAllocator alloc{SkTAddOffset<char>(memory, sizeof(T)), extraSize, extraSize/2}; - return {memory, totalMemorySize, std::move(alloc)}; - } + static AllocateAndArenaResult<T> + AllocateClassMemoryAndArena(int allocSizeHint); template <typename T, typename... Args> T* makePOD(Args&&... args) { static_assert(HasNoDestructor<T>, "This is not POD. Use makeUnique."); @@ -328,6 +323,34 @@ BagOfBytes fAlloc; }; +// Members are destroyed in the reverse order of their declaration: +// https://isocpp.org/wiki/faq/dtors#order-dtors-for-members +// `alloc` must be destroyed first because it may contain pointers to memory owned by `initializer`. +// `initializer` must be destroyed last because it owns the backing memory. +// See also: https://issues.skia.org/issues/530646115 +template <typename T> +struct AllocateAndArenaResult { + SubRunInitializer<T> initializer; + int totalMemorySize; + SubRunAllocator alloc; +}; + +template <typename T> +inline AllocateAndArenaResult<T> +SubRunAllocator::AllocateClassMemoryAndArena(int allocSizeHint) { + SkASSERT_RELEASE(allocSizeHint >= 0); + // Round the size after the object the optimal amount. + int extraSize = BagOfBytes::PlatformMinimumSizeWithOverhead(allocSizeHint, alignof(T)); + + // Don't overflow or die. + SkASSERT_RELEASE(INT_MAX - SkTo<int>(sizeof(T)) > extraSize); + int totalMemorySize = sizeof(T) + extraSize; + + void* memory = ::operator new (totalMemorySize); + SubRunAllocator alloc{SkTAddOffset<char>(memory, sizeof(T)), extraSize, extraSize/2}; + return {SubRunInitializer<T>{memory}, totalMemorySize, std::move(alloc)}; +} + // Helper for defining allocators with inline/reserved storage. // For argument declarations, stick to the base type (SubRunAllocator). // Note: Inheriting from the storage first means the storage will outlive the
Original Bug Report
Use-After-Free in SubRunAllocator
This UAF was discovered during the MiraclePtr rewrite project. It is blocking it, and is also a pre-existing bug, maybe a vulnerability.
Reproducer:
bin/gn gen out/asan --args='is_debug=true extra_cflags=["-fsanitize=address", "-fno-omit-frame-pointer"] extra_ldflags=["-fsanitize=address"]'
autoninja -C out/asan dm
xvfb-run out/asan/dm --match Slug_b520571816
Description
The C++ destruction order of std::tuple is not defined. In practise, clang will destroy <0>, <1>, and <2> and GCC will destroy in the opposite direction: <2>, <1>, <0>.
SubRunAllocator::AllocateClassMemoryAndArena returns:
std::tuple<SubRunInitializer<T>, int, SubRunAllocator>
There is an early return causing memory issues:
Due to the destruction order of std::tuple:
SubRunInitializer(index 0) is destructed first. Its destructor calls::operator deleteon the backing memory.SubRunAllocator(index 2) is destructed second. Its destructor (~BagOfBytes) attempts to accessfEndByteto free any heap-allocated blocks. SincefEndBytepoints to a location inside the backing memory that was just freed by theSubRunInitializer, this results in a Use-After-Free.
ASAN Report
=================================================================
==2941250==ERROR: AddressSanitizer: heap-use-after-free on address 0x7d2d7c8476e0 at pc 0x55906e06b3a0 bp 0x7fffffff9730 sp 0x7fffffff9728
READ of size 8 at 0x7d2d7c8476e0 thread T0
#0 0x55906e06b39f in sktext::gpu::BagOfBytes::~BagOfBytes() ../../src/text/gpu/SubRunAllocator.cpp:39
#1 0x55906e06af73 in sktext::gpu::SubRunAllocator::~SubRunAllocator() ../../src/text/gpu/SubRunAllocator.h:211
#2 0x5590711e33b5 in std::_Head_base<2ul, sktext::gpu::SubRunAllocator, false>::~_Head_base() /usr/include/c++/15/tuple:200
#3 0x5590711e33d1 in std::_Tuple_impl<2ul, sktext::gpu::SubRunAllocator>::~_Tuple_impl() /usr/include/c++/15/tuple:546
#4 0x5590711e33ed in std::_Tuple_impl<1ul, int, sktext::gpu::SubRunAllocator>::~_Tuple_impl() /usr/include/c++/15/tuple:281
#5 0x5590711e3435 in std::_Tuple_impl<0ul, sktext::gpu::SubRunInitializer<sktext::gpu::SlugImpl>, int, sktext::gpu::SubRunAllocator>::~_Tuple_impl() /usr/include/c++/15/tuple:281
#6 0x5590711e3451 in std::tuple<sktext::gpu::SubRunInitializer<sktext::gpu::SlugImpl>, int, sktext::gpu::SubRunAllocator>::~tuple() /usr/include/c++/15/tuple:833
#7 0x5590711e2429 in sktext::gpu::SlugImpl::MakeFromBuffer(SkReadBuffer&, SkStrikeClient const*) ../../src/text/gpu/SlugImpl.cpp:67
0x7d2d7c8476e0 is located 480 bytes inside of 504-byte region [0x7d2d7c847500,0x7d2d7c8476f8)
freed by thread T0 here:
#0 0x7fdd7e11b653 in operator delete(void*) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:143
#1 0x5590711e3a07 in sktext::gpu::SubRunInitializer<sktext::gpu::SlugImpl>::~SubRunInitializer() ../../src/text/gpu/SubRunAllocator.h:193
#2 0x5590711e3409 in std::_Head_base<0ul, sktext::gpu::SubRunInitializer<sktext::gpu::SlugImpl>, false>::~_Head_base() /usr/include/c++/15/tuple:200
previously allocated by thread T0 here:
#0 0x7fdd7e11abbb in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:86
#1 0x5590711e38ca in std::tuple<sktext::gpu::SubRunInitializer<sktext::gpu::SlugImpl>, int, sktext::gpu::SubRunAllocator> sktext::gpu::SubRunAllocator::AllocateClassMemoryAndArena<sktext::gpu::SlugImpl>(int) ../../src/text/gpu/SubRunAllocator.h:247