Reading memory by painting it

A short note on the ideas behind a Chromium renderer-to-GPU sandbox escape via Skia.

The GPU process is a one-way pipe: draw commands in, pixels out, almost nothing else. So you do the only thing the pipe allows: have the GPU paint the memory onto a canvas and read the painting back.

That is the core of a renderer-to-GPU sandbox escape I wrote up for Chrome 146. A Skia integer overflow corrupts a neighboring path object, and the GPU reads chosen bytes as float coordinates and rasterizes them. The renderer reads the pixels back and reconstructs the original values.

Think of the value as an unknown coordinate, a pin on an infinite plane, and the canvas as a fixed-size map the GPU draws the pin onto. Mostly the pin lands off the map, since a random byte read as a float is usually astronomically large or vanishingly small. The search is a pan-and-zoom. First find the zoom that brings the pin onto the map at all, which fixes the exponent, the scale where the value lives. Then zoom in and pan to keep the pin centered, each step pinning down more digits of the coordinate. After a few rounds the exact coordinate is recovered, a few bits at a time.

Why the pin doesn't drift

Pan-and-zoom runs on floating point, which normally loses bits, and a map that drifts as you zoom is useless. Two choices keep it exact. Zooming by a power of two only shifts the exponent and never rounds the mantissa. Panning subtracts two close, same-signed values, which Sterbenz's Lemma guarantees is computed with zero rounding error.

So the coordinate you recover is the one actually in memory.

That sketch makes it sound like one trick. The engineering is rather more involved than that, and the rest of it lives in the full writeup.

What makes it unusual

The odd part is the mix of fields. The attack is shaped like a problem in numerical analysis and computational geometry, and its reliability is a theorem, not a measurement. That is the difference between a read and a leak.

Introduction

This article's contribution is a visual memory-read primitive: reading GPU-process memory by drawing it. We use it to build a renderer-to-GPU-process sandbox escape for Chrome Stable M146 on Linux. A Skia integer overflow corrupts an adjacent path object so the GPU interprets chosen memory bytes as float coordinates and renders them as pixels; the renderer reads those pixels back and reconstructs the original pointer.

A pointer is an exact number; the canvas it gets drawn on is a grid only a few hundred pixels wide, so reading the pointer back off the pixels should lose most of it. It does not. The read recovers a pointer a few bits at a time, zooming the drawing so each successive slice fills the screen, and exploits exact properties of IEEE-754 floats and Skia's antialiasing so the recovered bits faithfully match memory.

The whole exploit bootstraps from two bugs: a Skia out-of-bounds write and a one-shot heap-address leak that defeats ASLR. We upgrade the OOB write to gain arbitrary write and read capabilities over the heap; the leak gives the one address everything else is computed from. From there the rest follows: groom PartitionAlloc, trigger the overflow, walk the heap to locate a function-pointer table, and hijack one of its entries. That final call reuses an indirect call site that the compiler leaves out of its Control Flow Integrity (CFI) checks, the defense that would otherwise reject a call through a corrupted function pointer.

All constants are from Chrome stable 146.0.7680.164 on Linux x64.

Mitigation surface

The GPU process enforces several standard memory-safety mitigations. To establish a reliable exploit pipeline, we must bypass or operate within the constraints of each defense:

  • ASLR (Chrome binary) — randomizes the base address of executable code. Enabled: yes. Handling: bypassed by reading a code pointer from the heap once arbitrary memory reads are established, then calculating offsets to target functions.
  • ASLR (PA pool) — randomizes PartitionAlloc heap regions. Enabled: yes. Handling: defeated by an initial heap address leak exposed in a GPU-to-renderer error message.
  • PartitionAlloc bucket segregation — segregates allocations by size. Enabled: yes. Handling: bypassed by grooming target allocations into adjacent pages of the same size class.
  • BackupRefPtr (raw_ptr<T>) — Chrome's UAF protection. Enabled: yes (for PartitionAlloc's BRP pool). Handling: avoided by targeting a heap object managed by a third-party smart pointer instead of Chrome's protected raw_ptr type.
  • LLVM CFI (-fsanitize=cfi-icall) — forward-edge control flow integrity. Enabled: yes. Handling: bypassed by hijacking an indirect call site that is explicitly excluded from CFI compilation checks.

IPC primer

The exploit drives the GPU process over IPC, specifically the raster command-buffer channel. The compromised renderer cannot directly instantiate C++ objects in the GPU process. Instead, it serializes command streams and maps Shared Memory (SHM) handles. The GPU process deserializes these streams into Skia commands using its hardware-accelerated rasterization backend (Ganesh).

RendererGPU process (Ganesh)
IPC Command BufferRasterDecoderImpl
BeginRaster / EndRasteropen / flush SkCanvas
Raster(shm_id, off, sz)deserialize PaintOps
Create/Delete TCmutate transfer cache
ReadbackARGB(out_shm)copy pixels to SHM
SHM transfer bufferspaths, PaintOps, blobs
SHM readback bufferoutput pixels (ARGB)

Three command types matter for this chain:

  1. Raster (geometry): Raster(shm_id, off, size) maps renderer SHM and calls PaintOp::Deserialize. This turns serialized byte streams into real Skia operations on the GPU thread.
  2. Transfer cache (heap grooming): CreateTransferCacheEntryINTERNAL maps renderer SHM into a GPU-side buffer, registering it under a unique ID. DeleteTransferCacheEntryINTERNAL drops the entry, reclaiming its backing allocation. Because command processing maintains strict FIFO order, this allows precise heap grooming.
  3. Readback (data exfiltration): ReadbackARGBImagePixelsINTERNAL copies rasterized pixels back into renderer-owned SHM. The renderer scans these pixels to reconstruct the leaked memory. Thus, our arbitrary read primitive must encode memory bytes into renderable color values.

IPC command serialization allows the renderer to trigger arbitrary allocations (Transfer Cache) and read back rasterized canvas pixels in a strictly ordered execution sequence.

Our first obstacle in executing the exploit chain is defeating ASLR by finding out where we are in memory.

The address leak: defeating heap ASLR

The leak comes from a hex string the GPU returns over an IPC message.

The Logger-as-leak primitive

// gpu/command_buffer/service/logger.cc (Logger::Logger; signature condensed)
Logger::Logger(const DebugMarkerManager* debug_marker_manager, ...) {
    Logger* this_temp = this;
    this_in_hex_ = std::string("GroupMarkerNotSet(crbug.com/242999)!:")
                   + base::HexEncode(&this_temp, sizeof(this_temp));
}

This formats the Logger instance's address as a hex string. This string is prepended to every GL error message the Logger emits.

The renderer triggers a synthetic GL error via IPC by calling BeginQueryEXT(0). Since target 0 is invalid, the GPU dispatches an error:

// raster_decoder.cc, RasterDecoderImpl::HandleBeginQueryEXT
// (via the LOCAL_SET_GL_ERROR macro)
error_state_->SetGLError(GL_INVALID_ENUM, "glBeginQueryEXT",
                         "unknown query target");

So the renderer receives:

[GroupMarkerNotSet(crbug.com/242999)!:009B5C01DC1A0000]GL ERROR ...
                                      ^^^^^^^^^^^^^^^^
                                      HexEncode(&logger_)

Deriving pool_base and decoder

Logger lives in chromium's PartitionAlloc BRP pool. On 64-bit Linux, PA reserves the Regular and BRP pools as one glued 32 GiB region aligned to 2352^{35}, so masking the low 35 bits off the leaked address gives that region's base. Logger is also an embedded member of RasterDecoderImpl at a build-constant offset (+15016+\text{150}_{16}) from the decoder base. From the single leaked &Logger=1ADC015C9B0016\&\mathrm{Logger} = \text{1ADC015C9B00}_{16}:

pool_base=&Logger& ⁣((135)1)=1ADC015C9B0016&FFFFFFF80000000016=1AD80000000016(cleared low 35 bits)pool_tag=&Logger32=1ADC16(top 16 bits)decoder=&Logger15016=1ADC015C99B016(decoder base)\begin{aligned} \mathrm{pool\_base} &= \&\mathrm{Logger} \mathbin{\&} \sim\!\big((1 \ll 35) - 1\big) \\ &= \text{1ADC015C9B00}_{16} \mathbin{\&} \text{FFFFFFF800000000}_{16} \\ &= \text{1AD800000000}_{16} &&\text{(cleared low 35 bits)} \\[4pt] \mathrm{pool\_tag} &= \&\mathrm{Logger} \gg 32 = \text{1ADC}_{16} &&\text{(top 16 bits)} \\[4pt] \mathrm{decoder} &= \&\mathrm{Logger} - \text{150}_{16} = \text{1ADC015C99B0}_{16} &&\text{(decoder base)} \end{aligned}

One leaked heap address yields both the PA pool base and the decoder object. Every later pointer is computed from these two.

Now that we have derived the base address of the PartitionAlloc pool, we need to understand how the allocator arranges memory so we can manipulate it.

PartitionAlloc primer

The chain manipulates PA at several points. Understanding the allocator's specific behavior is useful to follow.

Buckets

PartitionAlloc segregates allocations by size class. Every allocation rounds up to a fixed bucket size under the root's bucket distribution. The GPU process, where this exploit runs, uses the kNeutral distribution.

PA manages memory by grouping allocations into size classes (buckets) using dedicated virtual memory regions for each size. Because an allocation's size class is permanently bound to its memory address, calling free(ptr) automatically routes the freed slot back to its original bucket. Thus, freeing a 56 KiB object instantly makes its slot available in that bucket's freelist, allowing us to reclaim and overwrite it by allocating another object of the exact same size.

For an out-of-bounds write to hit a target object, both the source and the victim must be sized into the exact same bucket.

Slot spans and adjacent overflows

Depending on size, PartitionAlloc allocates memory in one of three regimes:

  1. Multi-slot spans: smaller buckets (like 10240 B) pack multiple slots contiguous inside a single span.
  2. Single-slot spans: medium/large buckets hold exactly one slot per span.
  3. Direct-mapped: allocations above ~960 KiB get dedicated virtual memory regions isolated by unmapped guard pages. An OOB write here triggers an immediate SIGSEGV.

To avoid guard pages, the exploit targets bucketed allocations. Specifically, it uses bucket-57344, which operates under the single-slot regime.

Adjacent single-slot spans (same bucket)

span Areservation 64K, alloc < 64K
span Breservation 64K, alloc < 64K
span Creservation 64K, alloc < 64K
OOB WRITE

PartitionAlloc carves slot spans sequentially. On Linux, the allocator commits the entire 64 KiB reservation for each span, even when the allocation inside it is smaller. The committed but unused tail is fully writable. When an OOB write overflows span A's allocation, it crosses the 64 KiB boundary directly into span B without hitting any unmapped pages. Same-bucket sizing forces adjacent spans to share this corridor.

Note: the specific choice of bucket-57344 is flexible. We could target other buckets as well. However, if it is too small, you would need to deal with the thread cache (requiring a different grooming strategy). It must not be too big, or it will fall into the direct-mapped case.

Allocations must stay bucketed, not direct-mapped, whose guard pages SIGSEGV. Linux commits the whole single-slot span, so an overflow crosses into the adjacent span, not an unmapped page.

Let's see how we can trigger the out-of-bounds write.

The vulnerability and the OOB site

The bug

This is a simple integer overflow bug.

// third_party/skia/src/core/PathStencilCoverOp.cpp
// PathStencilCoverOp::onPrepare
int maxTrianglesInFans = std::max(fTotalCombinedPathVerbCnt - 2, 0);
int fanTriangleCount = 0;
if (VertexWriter triangleVertexWriter =
            vertexAlloc.lockWriter(
                    sizeof(SkPoint), maxTrianglesInFans * 3)) {
    for (auto [pathMatrix, path, color] : *fPathDrawList) {
        tess::AffineMatrix m(pathMatrix);
        for (tess::PathMiddleOutFanIter it(path); !it.done();) {
            for (auto [p0, p1, p2] : it.nextStack()) {
                triangleVertexWriter << m.map2Points(p0, p1)
                                     << m.mapPoint(p2);
                ++fanTriangleCount;
            }
        }
    }
}

fTotalCombinedPathVerbCnt is an int32_t. If we send a draw batch where (verbs - 2) * 3 overflows, maxTrianglesInFans * 3 wraps to a small positive number. The lockWriter function allocates a small vertex block, but the emission loop writes the true number of triangles, blowing past the buffer's end.

The next issue is how can we ship a big number of vertices without triggering the out-of-memory killer.

The math

maxTriangles=V2=1,431,657,140vertexCount=maxTriangles3=4,294,971,420=4,124(mod232)(int32 overflow)\begin{aligned} \mathrm{maxTriangles} &= V - 2 = 1{,}431{,}657{,}140 \\ \mathrm{vertexCount} &= \mathrm{maxTriangles} \cdot 3 \\ &= 4{,}294{,}971{,}420 \\ &= 4{,}124 \pmod{2^{32}} &&\text{(int32 overflow)} \end{aligned}requestSize=4,1248 bytes=32,992 bytesPA allocation=49,152 (Skia bin)+32 (metadata)=49,184 bytesbucket-57344\begin{aligned} \mathrm{requestSize} &= 4{,}124 \cdot 8\ \text{bytes} = 32{,}992\ \text{bytes} \\ \mathrm{PA\ allocation} &= 49{,}152\ (\text{Skia bin}) + 32\ (\text{metadata}) = 49{,}184\ \text{bytes} \\ &\to \text{bucket-57344} \end{aligned}

This is not a 1.43B-vertex upload. The renderer sends a compact PaintOp stream backed by SHM:

RendererGPU (Ganesh)
1) Inline: Payload+RemainderVerb sum += 57,142
2) Upload 3.6 MB Noise Chunk(Saved to PaintCache) Verb sum += 400,000
3) Draw 3,578 Cached Copies (Tiny IPC cache IDs)Verb sum += 1.431B

Total V = 1.43B

(V - 2) * 3 -> 4,124

The noise path uses 200,000 move/line pairs to inflate the verb accumulator without generating real geometry (zero fan triangles). The actual overflow happens later during the allocation calculation: (V - 2) * 3. Because the geometry is cached, subsequent OOB write triggers only require a small IPC payload.

The 32,992-byte vertex request lands in bucket-57344. Skia's allocator rounds the request to 49,152 bytes (a power-of-two midpoint size), backing it with a GrCpuBuffer. This results in a 49,184-byte allocation, which PartitionAlloc bins to bucket-57344.

bucket-57344 slot57,344 bytes
PartitionAlloc allocation49,184 bytes

GrCpuBuffer header (sizeof(GrCpuBuffer))

GrCpuBuffer staging data49,152 bytes
Wrapped Vertex Data32,992 bytes

(16,160 bytes of buffer slack)

(8,160 bytes unused tail)

The OOB geometry

To trigger the overflow, we decouple the allocated buffer size from the actual write extent by splitting the input path cascade into two distinct phases:

  1. Inflation (noise path): we use a path containing hundreds of thousands of verbs (move/line pairs) that produce zero triangles during tessellation. These verbs inflate the total verb accumulator (V).
  2. Corruption (payload path): a hand-crafted path containing real triangles whose (x, y) vertex coordinates represent the exact byte values we want to write.

By placing the payload path at a specific position in the cascade, its OOB writes land on the adjacent victim's SkPathData structure, corrupting its pointers.

Why SkPath is the victim

To turn our OOB write into a useful arbitrary read/write primitive, we must find a "victim" data structure to overwrite in memory. We are hunting for a structure that satisfies three requirements:

  1. Dynamic sizing: the object must be dynamically sized so we can force it into the same PartitionAlloc bucket as our overflow source.
  2. Unified allocation: the object's pointer-bearing control header must reside in the exact same contiguous allocation as its raw payload. If they are split into separate allocations (like std::vector or SkBitmap), our OOB write will only reach the raw data buffer, leaving the control pointers untouched.
  3. Observability: overwriting the object's pointers must allow us to read arbitrary memory and leak the data back to the renderer (e.g., by having an observable side effect).

SkPath is a strong victim candidate because its underlying storage class, SkPathData, meets all three criteria.

Constraint 1: bucket and span matching via dynamic sizing

The victim needs a large, pointer-bearing allocation in the next single-slot reservation. The source is operator new(49184) in bucket-57344, so the victim is sized into bucket-57344 as well. The groom then controls the order of adjacent 64 KiB slot-span reservations inside the BRP pool.

SkPath has variable-sized internal storage governed by verb and point counts:

SkPathData storage = header + (npts x 8) + verb/conic arrays

Constraint 2: contiguous header and payload

We want to corrupt pointers. Variable size alone is insufficient. Objects like std::vector, SharedImage, or SkBitmap separate their pointer-bearing headers from their bulk data. Our OOB write only reaches the bulk data allocation, leaving the header pointers untouched in a separate bucket.

SkPath's storage allocates both the SkPathData header and the verbs/points arrays in a single contiguous block of memory.

// third_party/skia/src/core/SkPathData.cpp
sk_sp<SkPathData> SkPathData::Alloc(size_t npts, size_t nvbs, ...) {
    // (Accumulator math calculates total = header + pts
    // + conics + verbs)
    void* storage = ::operator new(*size);
    return sk_sp<SkPathData>(new (storage) SkPathData(...));
}

The header, containing the fPoints/fVerbs pointers, is fused to the payload.

Constraint 3: observable side effect

The victim also needs post-corruption observability. The GPU process is almost a one-way data sink: the renderer issues draw commands; the GPU returns rendered pixels and very little else.

SkPath is useful because it gets rendered to pixels. Once we corrupt its fPoints pointer to alias another memory location, asking the GPU to draw the path reads bytes from that memory location, interprets them as floats, transforms them through the canvas matrix, and rasterizes them. The renderer can then call ReadbackARGB and get the pixels.

How do we recover the pointer from pixels though?

But first, we must work out the tedious mechanical details: how to groom the heap so we can reach the victim in the first place.

The forge primitive (write)

Our goal is to build a write primitive, forge(W, V, AF). A single execution corrupts the victim SkPathData header with three pointers:

  • W (fPoints): the address from which Skia reads coordinate data when drawing the path.
  • V (fVerbs): the address from which Skia reads verbs. We point this to a {Move, Line} sequence so the coordinates are consumed.
  • AF (listener.data): the address of a callback list. If the ownership bit is set, evicting the path calls sk_free(AF).

Because the GPU must iterate 1.43 billion verbs to trigger the overflow, each forge takes ~3.5 seconds. The rest of the exploit must therefore be designed to use as few writes as possible.

The heap-spray groom ("pin/unpin")

To corrupt the victim, we must force the allocator to place our overflow buffer immediately before the victim in memory. We achieve this in three steps:

  1. Pin: fill empty space, place the victim, and add trailing cushions.
  2. Unpin: free the allocation directly preceding the victim to leave a vacancy.
  3. Reclaim: allocate the overflow buffer in the vacancy and trigger the OOB write.

More concretely:

Step 1: The Pin

TC[94] (Pre-slot padding)
TC[95] (The "Pin" slot)
SkPathData (The "Victim")
TC[96] (Cushion landing zone)

We allocate 96 entries (TC[0..95]) to fill active heap slots. Right after TC[95], we allocate the victim SkPathData. We then allocate 64 cushion entries (TC[96..159]) so the victim does not land next to unmapped memory. This pins the victim directly between TC[95] and TC[96].

Step 2: The Unpin

TC[94] (Pre-slot padding)
[ EMPTY HOLE ]Target
SkPathData (The "Victim")
TC[96] (Cushion landing zone)

We delete TC[95]. Because this size class allocates one object per page block, freeing TC[95] leaves its slot vacant while keeping the adjacent victim in place.

Step 3: Reallocation & Overflow

TC[94] (Pre-slot padding)
[ Overflow Geometry ]cascades down
SkPathData (The "Victim")Corrupts header!
TC[96] (Cushion landing zone)

We trigger the path overflow. Skia requests a vertex buffer of the same size class, which the allocator places in the vacant TC[95] slot. The overflow write cascades directly into the adjacent victim.

Okay, I lied about the three easy-peasy steps for heap grooming. In reality, because the visual read (see the c1read primitive below) requires repeating this groom dozens of times, we need one final step to maintain stability:

Step 4: The Re-pin

TC[94] (Pre-slot padding)
[ Re-secured Pin Slot ]Repinned
SkPathData (The "Victim")
TC[96] (Cushion landing zone)

To do this, we delete the pin, trigger the overflow, and create a new pin all in one command batch. The moment the overflow buffer is freed, the new pin re-occupies the slot, preventing other allocations from stealing it before our next write.

Note: these counts of 96 and 64 are engineering choices.

The dual-use corruption

We want to achieve two distinct goals with our corruption: memory reading (for the walk) and arbitrary freeing (for the hijack).

  1. The read: to read memory, we corrupt the points and verbs arrays in SkPathData. We set points = W (the memory address to read) and verbs = V (pointing to a {kMove, kLine} sequence). When Skia draws the path, it reads the bytes at W as float coordinates and draws a line, rendering the memory to pixels.
  2. The free: to free memory, we corrupt the listener array. We set listener.data = AF (the address to free), listener.size = 0, and listener.own = 1. When the path is evicted from the cache, Skia's destructor cleans up the listener array and calls sk_free(listener.data).

Because these fields occupy different offsets within the SkPathData structure, we can do both at the same time:

SkPathData
  refcount = 1
  listener.data = AF   <--- Address to free
  listener.size = 0
  listener.own  = 1
  points        = W    <--- Memory address to read
  points.count  = 2
  conics        = 0
  verbs         = V    <--- Points to {kMove, kLine}
  verbs.count   = 2

A single forge(W, V, AF) can configure both primitives, saving us from performing separate heap grooms.

With the ability to write arbitrary values into the victim's control structure and trigger arbitrary frees, we now need a way to read arbitrary memory so we can inspect the GPU heap.

The c1read primitive (read)

To read memory, we must handle the fact that the GPU is a data sink that draws pixels but does not return raw memory. The visual read primitive (c1read) achieves this.

We do not have a direct memory read, but we have:

  • A write (forge) to overwrite the victim's points pointer (fPoints).
  • An IPC command (ReadbackARGB) to copy rendered pixels back to the renderer.

If we overwrite the victim path's points pointer with the target memory address W, and ask Skia to draw the path, Skia dereferences W, reads the bytes there as coordinate floats, and renders a line. Scanning the resulting pixels reconstructs the original float value, recovering the memory bytes stored at W.

When Skia draws the path, it translates the coordinate values (floats) to actual pixel coordinates on our 640-pixel wide canvas:

Dx=SxX+TxD_x = S_x X + T_x

If we don't scale the drawing, most arbitrary memory values (read as floats) will be far too large or too small to land on our screen, leaving the line completely invisible. Even if a coordinate does land on our canvas, a single 640-pixel screen can only show us a rough estimate of the value, not the precise 32-bit float.

To find the exact value of the float, we zoom in (scale SxS_x) and shift the drawing (translate TxT_x) over multiple drawing attempts. By progressively magnifying the line and adjusting its position, we can piece together its precise bits. This is our zoom search ladder.

The zoom search and refinement

To resolve the float X using the zoom search ladder, we first assume a 1-dimensional coordinate space where the screen position is:

Dx=SxX+TxD_x = S_x X + T_x

We must coordinate our probes to handle two main constraints: the low-resolution canvas and the risk of the coordinate mapping off-screen.

Our intuition to solve this 1D problem is structured as follows:

  1. Low-resolution channel: a 640-pixel canvas provides only about 9 bits of resolution. We cannot read a 32-bit float directly.
  2. Magnification: we must zoom in (scale up SxS_x) to expose the fine details of the float's mantissa.
  3. Re-centering: zooming in pushes the coordinate off-screen unless we center the viewport. We shift the viewport by subtracting our current estimate (est) of the float, drawing the residual error (X − est) scaled up.
  4. Exact arithmetic: we choose scale SxS_x to be a power of 2, so multiplying SxXS_x X only shifts the exponent without rounding the mantissa. We precompute Tx=c0estSxT_x = c_0 - \text{est} \cdot S_x in JavaScript. When the GPU calculates Dx=SxX+TxD_x = S_x X + T_x, the addition behaves as a subtraction between two values of similar magnitude (SxXS_x X and estSxc0\text{est} \cdot S_x - c_0). Under IEEE-754 rules, this subtraction is exact (Sterbenz's Lemma), ensuring no mantissa bits are lost to rounding.

This is implemented in two phases:

  1. Coarse search (find exponent): we binary search the scale Sx=2135eS_x = 2^{135-e} until the line's edge lands on the canvas. A dark-pixel bounding box tells us if the line is off-screen (all white), overflowing (clamped), or on-screen (interior). This gives us the float's exponent ee.
  2. Fine refinement (find mantissa): we zoom in further by increasing SxS_x, translating the viewport by TxT_x to keep the target edge on canvas as it resolves progressively finer mantissa bits.

To keep the edge on canvas, we translate the viewport by adjusting TxT_x using our current estimate:

Tx=c0estSx(c0=320 is the canvas center)T_x = c_0 - \text{est} \cdot S_x \qquad (c_0 = 320 \text{ is the canvas center})

This makes the drawn position:

Dx=Sx(Xest)+c0D_x = S_x (X - \text{est}) + c_0

Why is this math exact? First, SxS_x is always a power of 2 (e.g. 2exp2^{\text{exp}}), meaning the product SxXS_x X incurs no rounding error — it merely shifts the float's exponent. Second, because our estimate (est) is close to XX, the term estSx\text{est} \cdot S_x has the same sign and similar magnitude to SxXS_x X. Third, when the GPU evaluates Dx=(SxX)+TxD_x = (S_x X) + T_x, it adds a negative TxT_x (for positive XX and est). This is equivalent to:

Dx=(SxX)Tx=(SxX)(estSxc0)D_x = (S_x X) - |T_x| = (S_x X) - (\text{est} \cdot S_x - c_0)

Since c0c_0 (320) is small compared to the scaled values, the two operands are well within a factor of 2 of each other. Sterbenz's Lemma guarantees that the difference of any two floating-point numbers of the same sign within a factor of 2 of each other is computed with zero rounding error. The GPU thus renders the exact residual difference without losing any precision.

We read back the pixel centroid (cxcx) of the resulting strip to calculate the true DxD_x. We then invert the math to update our estimate:

estnew=2cxETxSx\text{est}_{\text{new}} = \frac{2 \, cx - E - T_x}{S_x}

where EE is the screen edge (0 or 640). The three refinement rounds set SxS_x to 2d2^d times the coarse-search scale, with d=6,11,15d = 6, 11, 15; successive rounds therefore zoom in by 252^5 then 242^4, re-centering the viewport to resolve progressively finer mantissa bits.

Each readback probe reads from the already corrupted and cached path in GPU memory, without re-triggering the expensive 1.43B-verb cascade. This asymmetry lets us probe 4-9 times per c1read without incurring massive performance costs.

Power-of-2 scaling avoids mantissa rounding; Sterbenz's Lemma makes the GPU's subtraction exact. Each probe refines the float without losing bits.

Rendering on a canvas is a 2-dimensional operation. However, if the line is axis-aligned then we can reduce the problem into the solvable 1D one above. Let's handle the easy case first.

Leaking ASLR (vptr)

The ASLR leak relies on three known inputs. First, we know the heap address of the RasterDecoderImpl object, which was leaked via the Logger earlier. This heap address allows us to forge a pointer W to point inside the object. Second, we know the relative layout of the compiled chrome.so library, including the build-constant offsets (Relative Virtual Addresses, or RVAs) between symbols and the load base. Third, we know that chrome.so is aligned to a 4 KiB page boundary, so the low 12 bits of any library pointer are predictable.

What we do not know is the randomized load address of chrome.so (chrome_base) or the absolute 64-bit virtual addresses of its vtable pointers.

To locate chrome_base, we must resolve the absolute value of the primary vtable pointer. RasterDecoderImpl uses multiple inheritance, meaning the compiler places two vtable pointers at the start of the object: vptr1 at offset 0x00, and vptr2 (which points to vptr1 + 0x260) at offset 0x08. Because both point into the same read-only segment of chrome.so and differ only by the static 0x260 offset, their middle bytes (2..5) are usually identical.

By setting the forged coordinate array pointer W to decoder + 2, we force Skia to read the object memory as point coordinates. Each point in a path is represented as an SkPoint struct, consisting of two 32-bit floats (fX and fY). This results in the following little-endian byte alignment:

0123456789ABCDEF

(Offset)

vptr1vptr2
vptr1[0..1]vptr1[2..5]vptr1[6..7]vptr2[0..1]vptr2[2..5]vptr2[6..7]
P0.x(float32)P0.y(float32)P1.x(float32)P1.y(float32)
Point P0
Point P1

W = decoder + 2

Since W starts at decoder + 2:

  • P0xP_{0x} overlays vptr1[2..5]\mathrm{vptr}_1[2..5] (middle bytes of vptr1\mathrm{vptr}_1)
  • P0yP_{0y} overlays vptr1[6..7]\mathrm{vptr}_1[6..7] and vptr2[0..1]\mathrm{vptr}_2[0..1]
  • P1xP_{1x} overlays vptr2[2..5]\mathrm{vptr}_2[2..5] (middle bytes of vptr2\mathrm{vptr}_2)

Because the middle bytes of vptr1\mathrm{vptr}_1 and vptr2\mathrm{vptr}_2 are identical (vptr1[2..5]=vptr2[2..5]\mathrm{vptr}_1[2..5] = \mathrm{vptr}_2[2..5]), P0xP_{0x} is exactly equal to P1xP_{1x}. When Skia processes this path in GrStyledShape::simplifyStroke, the identical X coordinates force the stroke to simplify into a perfectly straight, vertical line. This allows us to feed P0xP_{0x} directly into the 1D zoom search ladder described above to resolve the middle bytes.

However, the recovered middle bytes only pin the pointer to a 64 KiB window, leaving 16 possible 4 KiB pages. To resolve this ambiguity, we look at the companion coordinate P0yP_{0y}.

In x86_64 user space, canonical pointers only use the low 48 bits, meaning the upper two bytes of vptr1\mathrm{vptr}_1 (vptr1[6..7]\mathrm{vptr}_1[6..7]) are always zero. In little-endian memory, P0yP_{0y} is read as:

P0y=(vptr2[1]24)(vptr2[0]16)(vptr1[7]8)vptr1[6]=(vptr2[1]24)(vptr2[0]16)000016\begin{aligned} P_{0y} &= (\mathrm{vptr}_2[1] \ll 24) \mathbin{|} (\mathrm{vptr}_2[0] \ll 16) \mathbin{|} (\mathrm{vptr}_1[7] \ll 8) \mathbin{|} \mathrm{vptr}_1[6] \\ &= (\mathrm{vptr}_2[1] \ll 24) \mathbin{|} (\mathrm{vptr}_2[0] \ll 16) \mathbin{|} \text{0000}_{16} \end{aligned}

When this 32-bit integer is parsed as a float32, its sign and exponent bits are entirely determined by vptr2[0..1]\mathrm{vptr}_2[0..1], which contains the randomized page offset and page bits of the secondary vtable. Each of the 16 candidate pages produces a different vptr2\mathrm{vptr}_2 value, mapping to a unique (exponent, sign) pair for P0yP_{0y}.

Probing the canvas with different Y-scales SyS_y will only render the line on-screen when SyS_y matches the true exponent of P0yP_{0y}, while the direction it shifts reveals the sign. This fingerprint uniquely identifies the correct page, resolving the ambiguity.

We can express this mapping mathematically. The loading address of chrome.so is aligned to a 4 KiB page boundary, meaning the lowest 12 bits of chrome_base are always zero. The page index k (0..15) within the 64 KiB window occupies bits 12..15 of chrome_base, making the low 16 bits of the load base equal to (k << 12). Adding the static RVA offset of the secondary vtable (0x0faa7e00) gives:

vptr2,low16=(k12)+7E0016=((k+7)&F16)12  E0016\begin{aligned} \mathrm{vptr}_{2,\mathrm{low16}} &= (k \ll 12) + \text{7E00}_{16} \\ &= \big((k + 7) \mathbin{\&} \text{F}_{16}\big) \ll 12 \ \mathbin{|}\ \text{E00}_{16} \end{aligned}

Let N=(k+7)mod16N = (k + 7) \bmod 16. In little-endian bytes, this 16-bit value is stored as vptr2[0]=0016\mathrm{vptr}_2[0] = \text{00}_{16}, and vptr2[1]=(N4)E16\mathrm{vptr}_2[1] = (N \ll 4) \mathbin{|} \text{E}_{16}.

When read as a float32 coordinate, P0yP_{0y} spans vptr1[6..7]\mathrm{vptr}_1[6..7] and vptr2[0..1]\mathrm{vptr}_2[0..1]. Because canonical user-space pointers on x86_64 only use the low 48 bits, the upper two bytes of vptr1\mathrm{vptr}_1 are always zero (vptr1[6..7]=000016\mathrm{vptr}_1[6..7] = \text{0000}_{16}). Thus, the raw 32-bit memory buffer for P0yP_{0y} contains the bytes [0016,0016,0016,vptr2[1]][\text{00}_{16}, \text{00}_{16}, \text{00}_{16}, \mathrm{vptr}_2[1]], which translates to the bit pattern:

P0y,bits=vptr2[1]24=((N4)E16)24\begin{aligned} P_{0y,\mathrm{bits}} &= \mathrm{vptr}_2[1] \ll 24 \\ &= \big((N \ll 4) \mathbin{|} \text{E}_{16}\big) \ll 24 \end{aligned}

In the IEEE 754 single-precision format, the 32 bits are divided into a sign bit (bit 31), an 8-bit exponent (bits 30..23), and a 23-bit mantissa (bits 22..0). Mapping P0y,bitsP_{0y,\mathrm{bits}} to these fields yields:

S=bit 31 of P0y,bits=bit 7 of vptr2[1]=N/8E=bits 30..23 of P0y,bits=(Nmod8)32+28M=bits 22..0 of P0y,bits=0\begin{aligned} S &= \text{bit } 31 \text{ of } P_{0y,\mathrm{bits}} = \text{bit } 7 \text{ of } \mathrm{vptr}_2[1] = \lfloor N / 8 \rfloor \\ E &= \text{bits } 30..23 \text{ of } P_{0y,\mathrm{bits}} = (N \bmod 8) \cdot 32 + 28 \\ M &= \text{bits } 22..0 \text{ of } P_{0y,\mathrm{bits}} = 0 \end{aligned}

Because the mantissa MM is zero, the float simplifies to P0y=(1)S2eP_{0y} = (-1)^S \cdot 2^e, where the float exponent ee is:

e=E127=(Nmod8)3299e = E - 127 = (N \bmod 8) \cdot 32 - 99

Once the canvas probes measure the exponent ee and sign SS (0 for positive, 1 for negative), we can algebraically invert these relations. First, we compute the remainder of NN modulo 8 from the exponent:

Nmod8=(e+99)/32N \bmod 8 = (e + 99) / 32

Second, we reconstruct the complete 4-bit value of NN by combining the quotient (SS) and the remainder:

N=S8+(e+99)/32N = S \cdot 8 + (e + 99) / 32

Finally, we reverse the constant shift to recover the exact page index kk:

k=(N7)mod16k = (N - 7) \bmod 16

Reconstructing the 64-bit vtable pointer (vptr) resolves the load base itself. Although ASLR shifts the absolute load address of chrome.so at boot, the internal layout of the compiled library is static. Subtracting the build-constant offset of the vtable (here, 0x0faa7ba0) from the leaked runtime pointer yields the absolute load address (chrome_base), allowing us to locate other interesting symbols (such as mkdir@plt at RVA 0x0f566840) by adding its RVA:

chrome_base=vptr0FAA7BA016mkdir_abs=chrome_base+0F56684016\begin{aligned} \mathrm{chrome\_base} &= \mathrm{vptr} - \text{0FAA7BA0}_{16} \\ \mathrm{mkdir\_abs} &= \mathrm{chrome\_base} + \text{0F566840}_{16} \end{aligned}

With the arbitrary write (forge) primitive and the leaked base addresses (heap pool base and chrome code base), we can now proceed to execute code.

The Arb-Call

The forge primitive gives us a controlled write into the GPU process heap. To turn that into code execution we need an indirect call site where the function pointer is attacker-writable and the call is not guarded by CFI.

Target properties

So we hunt for a heap structure with four specific properties:

  1. Address predictability: we must be able to locate it (either via a stable static pool offset or a dynamic heap walk).
  2. Function tables: it must contain function pointers that are called during routine execution (so we can trigger our hijack).
  3. CFI exemption: calls through these pointers are not guarded by CFI.
  4. Safe free path: we must be able to free it (to put its slot back on the freelist) and reclaim it with our own payload.

GrGLInterface (Skia's OpenGL function table) fits this profile well.

Why GrGLInterface bypasses CFI

GrGLInterface is Skia's OpenGL function-pointer table. The GPU calls through its entries on every GL operation.

While LLVM CFI's cfi-icall normally instruments indirect call sites to verify function signatures, the GrGLFunction call family is explicitly excluded in src/tools/cfi/ignores.txt:

# third_party/skia/include/gpu/gl/GrGLFunctions.h
fun:*GrGLFunction*

This exclusion is necessary because chromium loads GL function pointers at runtime via eglGetProcAddress() / glXGetProcAddress(). Since the actual types are determined by the GL driver rather than compile-time information, CFI cannot validate them, and calls through GrGLFunction<> are compiled without checks.

GrGLFunction calls are excluded from CFI because GL pointers are loaded at runtime from the driver. Overwriting GrGLInterface gives us a CFI-free indirect call gate on the heap.

Locating the target (static offset)

GrGLInterface is allocated once during GPU process initialization, before any user rasterization traffic. Because the startup allocation sequence is highly deterministic for a given environment, this allocation lands at a "stable" offset within PartitionAlloc's pool.

However, this static offset is environment-dependent: it varies across different browser builds, compiler flags, and operating systems. In the target environment of this build, the GrGLInterface singleton sits at a constant pool offset from the leaked heap base:

&GrGLInterface=pool_base+4008EA80016\&\mathrm{GrGLInterface} = \mathrm{pool\_base} + \text{4008EA800}_{16}

Wait, what if we want an environment-independent solution? Don't panic — jump hoops, walk the heap (see the dynamic-walk section below).

With the target address resolved, we can now proceed to hijack it.

The free + reclaim

Our goal is to free GrGLInterface and immediately reclaim its slot with a controlled payload. Because the GPU process maintains a live pointer to this structure and continuously calls GL functions through it, overwriting this memory block allows us to intercept the next GL call and hijack execution.

First, we plant AF = &GrGLInterface into the victim's listener-array data pointer (the UAF target from the dual-use corruption above). Evicting the victim triggers the destructor cascade, freeing the interface:

ring.clearPaintCache() -> ~SkPathData() -> sk_free(&GrGLInterface)

GrGLInterface is an 8,240-byte object that lands in the multi-slot bucket-10240. When freed, its slot returns to that bucket's LIFO freelist. Within the same IPC batch, we reclaim the slot by allocating 64 same-sized transfer-cache entries (kTCRawMemory) filled with our payload. Because the freelist is LIFO, the first allocation pops the GrGLInterface slot we just freed and overwrites it with our payload. Spraying 64 allocations ensures we win the slot even if background thread activity creates noise.

Payload layout and execution dispatch

GrGLInterface consists of a header followed by an array of GrGLFunction entry wrappers. Each wrapper contains an invoker function pointer (fCall) and a local buffer (fBuf) for closure storage:

struct GrGLFunction {
    void* fCall;
    char  fBuf[32];
};

Invoking any entry calls fCall(fBuf, args...). Because the invoker is always handed the address of fBuf as its first argument, we can hijack execution by pointing fCall to mkdir@plt and putting the string "/tmp/pwned" in fBuf.

To ensure this works regardless of which GL function Skia calls next, we tile this fake entry pattern across the entire reclaimed slot, zeroing out the header. When the GPU triggers the next GL call, the x86-64 SysV ABI passes the first argument (the address of fBuf, i.e., "/tmp/pwned") in rdi. This transforms the dispatch into a direct call:

mkdir("/tmp/pwned", mode)

Since mkdir ignores any subsequent arguments, the directory is successfully created and code execution is achieved.

Chasing pointers: the dynamic walk

Recall our progress: the c1read primitive bypassed code ASLR by reading coordinate floats from the double-vtable to locate mkdir@plt, and the Arb-Call section established our arbitrary call mechanics. However, the static offset shortcut above is highly environment-dependent. If the driver or operating system changes, the offset drifts, and we lack a generic method to locate the GrGLInterface address on the heap.

To resolve this dynamically, we must trace the GPU's internal object graph starting from our leaked decoder base. Doing so requires the ability to read arbitrary pointers from the heap, not just the special double-vtable layout.

General pointer reads and Zero Framing (ZA/ZB)

To perform this walk, we need to read arbitrary 64-bit pointers. While our ASLR leak (the vptr section above) read a specific vptr that naturally came with a twin (vptr2), a general pointer *T at location T has no neighboring twin; its adjacent 8 bytes contain unrelated heap data.

This presents a major measurement problem. When Skia renders a line between two points P0=(P0x,P0y)P_0 = (P_{0x}, P_{0y}) and P1=(P1x,P1y)P_1 = (P_{1x}, P_{1y}), the screen coordinates are mapped using scale (S) and translation (T):

Dx[i]=SxP[i].x+TxDy[i]=SyP[i].y+Ty\begin{aligned} D_x[i] &= S_x \, P[i].x + T_x \\ D_y[i] &= S_y \, P[i].y + T_y \end{aligned}

If we point our forged path fPoints to T directly, the X and Y coordinates of the line will read unrelated heap values, tilting the line. This diagonal tilt leaks coordinates into both axes, destroying our measurement.

Our solution is to align the other coordinates with zero bytes in memory. By sliding our read window W, we force the non-target coordinates to read as zero, which simplifies the math:

  1. Horizontal alignment (s1): we force P0y=0P_{0y} = 0 and P1y=0P_{1y} = 0. The line segment collapses to:
    (SxP0x+Tx, Ty)(SxP1x+Tx, Ty)(S_x P_{0x} + T_x,\ T_y) \longrightarrow (S_x P_{1x} + T_x,\ T_y)
  2. Vertical alignment (s4): we force P0x=0P_{0x} = 0 and P1x=0P_{1x} = 0. The line segment collapses to:
    (Tx, SyP0y+Ty)(Tx, SyP1y+Ty)(T_x,\ S_y P_{0y} + T_y) \longrightarrow (T_x,\ S_y P_{1y} + T_y)

We call this Zero Framing, and it comes in two flavors depending on the structure layout:

  • ZA (Zero Adjacent): the neighbor field is a 48-bit pointer. Since user-space pointers on 64-bit Linux have their top 16 bits as zero, the upper bytes of the neighbor are expected to be zero.
  • ZB (Zero Block): the neighbor field is padding, a null pointer, or a zeroed integer (the whole qword is zero).

By aligning W so that the companion coordinate's exponent byte lands on zero memory, we drive that coordinate to ~0 and force the line flat (s1) or vertical (s4). The ZA form uses the zero top bytes of an adjacent 48-bit pointer; the ZB form shifts W an extra 8 bytes (the T-9 and T-12 entries below) so an all-zero qword frames the companion SkPoint instead, leaving the recovered-byte positions unchanged.

s1 ZA:T1s4 ZA:T4s1 ZB:T9s4 ZB:T12\begin{aligned} s_1\ \mathrm{ZA} &: T - 1 & s_4\ \mathrm{ZA} &: T - 4 \\ s_1\ \mathrm{ZB} &: T - 9 & s_4\ \mathrm{ZB} &: T - 12 \end{aligned}

A single forge recovers only part of the pointer. The s1 forge reads three bytes at once (b0-b2 encoded as one target float, whose sign/exponent byte b2 pins the magnitude for the zoom search); the s4 forge reads a single byte (b3). We therefore use two separate shifted forges to reconstruct the low 32 bits:

s1 forge (W = T - 1)

T-1TT+1T+2T+3T+4T+5T+6
frameb0b1b2b3b4b5b6=0
P0.x: recovers b0..b2(target float)P0.y: exponent b6=0(flat, reads ~0)

s4 forge (W = T - 4)

T-4T-3T-2T-1TT+1T+2T+3
q4q5q6q7=0b0b1b2b3
P0.x: exponent q7=0(vert, reads ~0)P0.y: recovers b3(target float)

Combining s1 and s4 recovers the low four bytes (b0-b3) of *T; the top two bytes (b4, b5) come from the already-known pool tag, completing the full 48-bit pointer.

Sliding the read window so companion coordinates land on zero bytes collapses the 2D line into a 1D axis-aligned segment, letting us reuse the zoom search for arbitrary pointer reads.

With this arbitrary read primitive, we can now traverse the GPU's internal object graph.

The graph walk

To walk from RasterDecoderImpl to GrGLInterface, every traversed pointer must satisfy the ZA/ZB framing to be readable:

fromfieldto
RasterDecoderImplquery_manager_RasterQueryManager
RasterQueryManagershared_context_state_SharedContextState
SharedContextStategr_context_GrDirectContext
GrDirectContextfResourceProviderGrResourceProvider
GrResourceProviderfNonAAQuadIndexBufferGrGpuBuffer
GrGpuBufferfGpuGrGLGpu
GrGLGpufGLContextGrGLContext
GrGLContextfInterfaceGrGLInterface

This 8-hop walk requires 16 forges and about 75 bisection probes, taking about 1 minute to dynamically resolve GrGLInterface.

We can shorten the path by leveraging direct owner pointers: GrDirectContext owns GrGLGpu directly through fGpu, bypassing fResourceProvider and the buffer hops:

full  (8 hops):  grctx -> resprov -> quadbuf -> glgpu
short (6 hops):  grctx -> glgpu

Other shorter walks exist in the object graph, but they do not satisfy the ZA/ZB framing.

Putting it together

Conceptually, the exploit resolves in three main stages:

  1. Heap shaping & forge: spraying transfer-cache entries grooms the heap. Triggering the PathStencilCoverOp overflow corrupts the victim's pointers, establishing a reliable "forge" write primitive.
  2. Information leak (visual oracle): we turn Skia's rasterization loop into a high-fidelity visual oracle to read back memory bytes, bypassing ASLR by leaking the RasterDecoderImpl address and resolving the pool tag.
  3. Execution dispatch: we trigger a UAF on GrGLInterface, reclaim the freed slot with our fake function table, and intercept the next GL call to redirect control flow to mkdir.

While the run is highly deterministic, background allocator churn can occasionally cause failure due to:

  • Stolen holes or wedged objects in the groom stage.
  • Lost reclaim races where another thread grabs the freed interface first.
  • Float degeneracies where specific pointer bytes fail IEEE-754 limits.

Because a process crash simply spawns a fresh GPU process with a clean heap, the exploit simply retries until it succeeds.

Glossary

  • PartitionAlloc (PA): Chromium's heap allocator, segregating allocations by size class ("buckets").
  • Pool tag: the top 16 bits (here 0x1adc) carried by BRP-pool pointers. Only bits 34-47 are strictly pool-invariant; the walked init-time objects also share bits 32-33, so all 16 are reused to rebuild leaked pointers.
  • SkPath / SkPathData: Skia's path structures, used here as the OOB write victim and overflow trigger.
  • PathStencilCoverOp: the Skia rendering operation containing the integer overflow vulnerability.
  • GrGLInterface: Skia's interface of OpenGL function pointers; the target of our arbitrary call hijacking.
  • GrGLFunction: Skia's function wrapper containing the invoker (fCall) and closure buffer (fBuf).
  • sk_sp<T>: Skia's intrusive ref-counted smart pointer, which does not acquire BackupRefPtr metadata.
  • raw_ptr<T>: Chromium's BackupRefPtr-protected pointer wrapper designed to prevent UAF.
  • forge: the controlled-write primitive built on the cascade heap overflow.
  • c1read: the controlled-read primitive consisting of two forges and pixel readback.
  • ZA / ZB framing: alignment conditions on the adjacent qword that satisfy float-to-int conversion constraints.
  • walk: chained c1read pointer reads traversing the GPU object graph.

References

  1. Chromium Authors, "PartitionAlloc Design", 2024.
  2. Chromium Authors, "BackupRefPtr (MiraclePtr)", 2024.
  3. Chromium Authors, "Control Flow Integrity (CFI)", 2024.
  4. Chromium Authors, "CFI ignore list (tools/cfi/ignores.txt)".
  5. Skia Authors, "Skia Graphics Library".
  6. P. H. Sterbenz, "Floating-Point Computation", Prentice-Hall, 1974.
  7. IEEE, "IEEE Standard for Floating-Point Arithmetic", IEEE 754-2019.
  8. Chromium Bug Tracker, crbug.com/242999 (referenced in Logger::Logger hex-encode path).

Shouts

  • the chrome-security team, for paying out the bounties that funded our local caffeine supply.
  • the skia and v8 engineers, for providing such a beautifully complex, yet groomable canvas so we can express our art.
  • PartitionAlloc's LIFO freelist, for popping our reclaimed slot first.
  • all fellow heap-sprayers, pointer-chasers, and float-bisectors.
  • Pat H. Sterbenz, the real OG whose lemma keeps our oracle honest.
  • zero bytes everywhere, for staying null when we needed you most.
  • our GPU, for not melting during the 1.43B verb cascade prepare loop.
  • the phrack staff and community, past, present, and root.
  • Calif.IO, for paying our researcher's salary.
  • Anthropic's Claude, who hallucinated at least three wrong exploits before casually generating real creative math ideas like a boss.

Exploit source

The complete runnable PoC, targeting Google Chrome Stable 146.0.7680.164 on Linux x64:

Download reading-memory-poc.tar.xz (76.5 KB)

tar xJf reading-memory-poc.tar.xz
CHROME=/path/to/chrome-146 ./mojojs_exploit/run_mojojs.sh