VYPR
High severityOSV Advisory· Published Aug 16, 2025· Updated Apr 15, 2026

CVE-2025-55286

CVE-2025-55286

Description

z2d is a pure Zig 2D graphics library. z2d v0.7.0 released with a new multi-sample anti-aliasing (MSAA) method, which uses a new buffering mechanism for storing coverage data. This differs from the standard alpha mask surface used for the previous super-sample anti-aliasing (SSAA) method. Under certain circumstances where the path being drawn existed in whole or partly outside of the rendering surface, incorrect bounding could cause out-of-bounds access within the coverage buffer. This affects the higher-level drawing operations, such as Context.fill, Context.stroke, painter.fill, and painter.stroke, when either the .default or .multisample_4x anti-aliasing modes were used. .supersample_4x was not affected, nor was drawing without anti-aliasing. In non-safe optimization modes (consumers compiling with ReleaseFast or ReleaseSmall), this could potentially lead to invalid memory accesses or corruption. z2d v0.7.1 fixes this issue, and it's recommended to upgrade to v0.7.1, or, given the small period of time v0.7.0 has been released, use v0.7.1 immediately, skipping v0.7.0.

Affected products

1

Patches

1
93e45d36af53

painter: oob drawing fixes

https://github.com/vancluever/z2dChris MarchesiAug 15, 2025via osv
13 files changed · +273 16
  • spec/075_oob_draw_corners.zig+85 0 added
    @@ -0,0 +1,85 @@
    +// SPDX-License-Identifier: 0BSD
    +//   Copyright © 2024-2025 Chris Marchesi
    +
    +//! Case: Interrogates out-of-bounds drawing under various cases, drawing
    +//! overlapping images in the four corner quadrants. The image should be
    +//! clipped on the corners, particularly on strokes, which should not not
    +//! display where they would be out-of-bounds (e.g., not snapped).
    +const mem = @import("std").mem;
    +
    +const z2d = @import("z2d");
    +
    +pub const filename = "075_oob_draw_corners";
    +
    +pub fn render(alloc: mem.Allocator, aa_mode: z2d.options.AntiAliasMode) !z2d.Surface {
    +    const width = 300;
    +    const height = 300;
    +    const cx = width / 2;
    +    const cy = height / 2;
    +    const margin = 10;
    +    var sfc = try z2d.Surface.initPixel(
    +        z2d.pixel.Pixel.fromColor(.{ .rgb = .{ 0.33, 0.33, 0.33 } }),
    +        alloc,
    +        width,
    +        height,
    +    );
    +
    +    var context = z2d.Context.init(alloc, &sfc);
    +    defer context.deinit();
    +    context.setAntiAliasingMode(aa_mode);
    +
    +    // Overlap, top left
    +    context.translate(-margin, -margin);
    +    try context.lineTo(cx, cy);
    +    try context.lineTo(0 - cx, cy);
    +    try context.lineTo(cx, 0 - cy);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +
    +    // Overlap, top right
    +    context.resetPath();
    +    context.setIdentity();
    +    context.translate(margin, -margin);
    +    try context.moveTo(cx, cy);
    +    try context.lineTo(width + cx, cy);
    +    try context.lineTo(width + cx, 0 - cy);
    +    try context.lineTo(cx, 0 - cy);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +
    +    // Overlap, bottom left
    +    context.resetPath();
    +    context.setIdentity();
    +    context.translate(-margin, margin);
    +    try context.moveTo(cx, cy);
    +    try context.lineTo(0 - cx, cy);
    +    try context.lineTo(0 - cx, height + cy);
    +    try context.lineTo(cx, height + cy);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +
    +    // Overlap, bottom right
    +    context.resetPath();
    +    context.setIdentity();
    +    context.translate(margin, margin);
    +    try context.moveTo(cx, cy);
    +    try context.lineTo(width + cx, cy);
    +    try context.lineTo(width + cx, height + cy);
    +    try context.lineTo(cx, height + cy);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +
    +    return sfc;
    +}
    
  • spec/076_oob_draw_sides.zig+86 0 added
    @@ -0,0 +1,86 @@
    +// SPDX-License-Identifier: 0BSD
    +//   Copyright © 2024-2025 Chris Marchesi
    +
    +//! Case: Interrogates out-of-bounds drawing under various cases, drawing
    +//! overlapping images on each four sides. The image should be clipped on the
    +//! corners, particularly on strokes, which should not not display where they
    +//! would be out-of-bounds (e.g., not snapped).
    +const mem = @import("std").mem;
    +
    +const z2d = @import("z2d");
    +
    +pub const filename = "076_oob_draw_sides";
    +
    +pub fn render(alloc: mem.Allocator, aa_mode: z2d.options.AntiAliasMode) !z2d.Surface {
    +    const width = 300;
    +    const height = 300;
    +    const cx = width / 2;
    +    const cy = height / 2;
    +    const margin = 10;
    +    var sfc = try z2d.Surface.initPixel(
    +        z2d.pixel.Pixel.fromColor(.{ .rgb = .{ 0.33, 0.33, 0.33 } }),
    +        alloc,
    +        width,
    +        height,
    +    );
    +
    +    var context = z2d.Context.init(alloc, &sfc);
    +    defer context.deinit();
    +    context.setAntiAliasingMode(aa_mode);
    +
    +    // Top
    +    context.translate(0, -margin);
    +    try context.moveTo(cx - margin, cy);
    +    try context.lineTo(cx - margin, 0 - cy);
    +    try context.lineTo(cx + margin, 0 - cy);
    +    try context.lineTo(cx + margin, cy);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +
    +    // Bottom
    +    context.resetPath();
    +    context.setIdentity();
    +    context.translate(0, margin);
    +    try context.moveTo(cx - margin, cy);
    +    try context.lineTo(cx - margin, height + cy);
    +    try context.lineTo(cx + margin, height + cy);
    +    try context.lineTo(cx + margin, cy);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +
    +    // Left
    +    context.resetPath();
    +    context.setIdentity();
    +    context.translate(-margin, 0);
    +    try context.moveTo(cx, cy - margin);
    +    try context.lineTo(0 - cx, cy - margin);
    +    try context.lineTo(0 - cx, cy + margin);
    +    try context.lineTo(cx, cy + margin);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +
    +    // Right
    +    context.resetPath();
    +    context.setIdentity();
    +    context.translate(margin, 0);
    +    try context.moveTo(cx, cy - margin);
    +    try context.lineTo(width + cx, cy - margin);
    +    try context.lineTo(width + cx, cy + margin);
    +    try context.lineTo(cx, cy + margin);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +
    +    return sfc;
    +}
    
  • spec/077_oob_draw_full_outside.zig+50 0 added
    @@ -0,0 +1,50 @@
    +// SPDX-License-Identifier: 0BSD
    +//   Copyright © 2024-2025 Chris Marchesi
    +
    +//! Case: Interrogates out-of-bounds drawing under various cases, drawing rects
    +//! completely out-of-bounds in various places. This is ultimately a no-op
    +//! test, the image should be completely black.
    +const mem = @import("std").mem;
    +
    +const z2d = @import("z2d");
    +
    +pub const filename = "077_oob_draw_full_outside";
    +
    +pub fn render(alloc: mem.Allocator, aa_mode: z2d.options.AntiAliasMode) !z2d.Surface {
    +    const width = 300;
    +    const height = 300;
    +
    +    var sfc = try z2d.Surface.init(.image_surface_rgb, alloc, width, height);
    +
    +    var context = z2d.Context.init(alloc, &sfc);
    +    defer context.deinit();
    +    context.setAntiAliasingMode(aa_mode);
    +
    +    try draw(&context, -width, -height); // top left
    +    try draw(&context, width * 2, -height); // top right
    +    try draw(&context, width * 2, height * 2); // bottom left
    +    try draw(&context, -width, height * 2); // bottom right
    +
    +    const cx = width / 2;
    +    const cy = width / 2;
    +    try draw(&context, -width, cy); // left
    +    try draw(&context, width * 2, cy); // right
    +    try draw(&context, cx, -height); // top
    +    try draw(&context, cx, height * 2); // bottom
    +    return sfc;
    +}
    +
    +fn draw(context: *z2d.Context, cx: f64, cy: f64) !void {
    +    context.resetPath();
    +    context.setIdentity();
    +    context.translate(cx, cy);
    +    try context.moveTo(cx - 20, cy - 20);
    +    try context.lineTo(cx + 20, cy - 20);
    +    try context.lineTo(cx + 20, cy + 20);
    +    try context.lineTo(cx - 20, cy + 20);
    +    try context.closePath();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0x00, .g = 0xFF, .b = 0x00 } });
    +    try context.fill();
    +    context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0xFF } });
    +    try context.stroke();
    +}
    
  • spec/files/075_oob_draw_corners_pixelated.png+0 0 added
  • spec/files/075_oob_draw_corners_smooth.png+0 0 added
  • spec/files/076_oob_draw_sides_pixelated.png+0 0 added
  • spec/files/076_oob_draw_sides_smooth.png+0 0 added
  • spec/files/077_oob_draw_full_outside_pixelated.png+0 0 added
  • spec/files/077_oob_draw_full_outside_smooth.png+0 0 added
  • spec/main_bench.zig+8 0 modified
    @@ -89,6 +89,9 @@ const _071_gamma_linear = @import("071_gamma_linear.zig");
     const _072_gamma_srgb = @import("072_gamma_srgb.zig");
     const _073_stroke_sameclose = @import("073_stroke_sameclose.zig");
     const _074_text = @import("074_text.zig");
    +const _075_oob_draw_corners = @import("075_oob_draw_corners.zig");
    +const _076_oob_draw_sides = @import("076_oob_draw_sides.zig");
    +const _077_oob_draw_full_outside = @import("077_oob_draw_full_outside.zig");
     
     //////////////////////////////////////////////////////////////////////////////
     
    @@ -196,10 +199,15 @@ pub fn main() !void {
         try addCompositorBenchmark(&bench, _071_gamma_linear);
         try addCompositorBenchmark(&bench, _072_gamma_srgb);
         try addPathBenchmark(&bench, _073_stroke_sameclose);
    +
         // NOTE: something completely breaks memory tracking for the text test -
         // unless it's actually using 16 PiB, which something tells me it's not. ;)
         try addPathBenchmark(&bench, _074_text);
     
    +    try addPathBenchmark(&bench, _075_oob_draw_corners);
    +    try addPathBenchmark(&bench, _076_oob_draw_sides);
    +    try addPathBenchmark(&bench, _077_oob_draw_full_outside);
    +
         try bench.run(stdout);
     }
     
    
  • spec/main_spec.zig+18 0 modified
    @@ -85,6 +85,9 @@ const _071_gamma_linear = @import("071_gamma_linear.zig");
     const _072_gamma_srgb = @import("072_gamma_srgb.zig");
     const _073_stroke_sameclose = @import("073_stroke_sameclose.zig");
     const _074_text = @import("074_text.zig");
    +const _075_oob_draw_corners = @import("075_oob_draw_corners.zig");
    +const _076_oob_draw_sides = @import("076_oob_draw_sides.zig");
    +const _077_oob_draw_full_outside = @import("077_oob_draw_full_outside.zig");
     
     //////////////////////////////////////////////////////////////////////////////
     
    @@ -166,6 +169,9 @@ pub fn main() !void {
         try compositorExportRun(alloc, _072_gamma_srgb);
         try pathExportRun(alloc, _073_stroke_sameclose);
         try pathExportRun(alloc, _074_text);
    +    try pathExportRun(alloc, _075_oob_draw_corners);
    +    try pathExportRun(alloc, _076_oob_draw_sides);
    +    try pathExportRun(alloc, _077_oob_draw_full_outside);
     }
     
     //////////////////////////////////////////////////////////////////////////////
    @@ -466,6 +472,18 @@ test "074_text" {
         try pathTestRun(testing.allocator, _074_text);
     }
     
    +test "075_oob_draw_corners" {
    +    try pathTestRun(testing.allocator, _075_oob_draw_corners);
    +}
    +
    +test "076_oob_draw_sides" {
    +    try pathTestRun(testing.allocator, _076_oob_draw_sides);
    +}
    +
    +test "077_oob_draw_full_outside" {
    +    try pathTestRun(testing.allocator, _077_oob_draw_full_outside);
    +}
    +
     //////////////////////////////////////////////////////////////////////////////
     
     fn compositorExportRun(alloc: mem.Allocator, subject: anytype) !void {
    
  • src/internal/sparse_coverage.zig+6 1 modified
    @@ -85,14 +85,19 @@ pub const SparseCoverageBuffer = struct {
             }
         }
     
    -    /// Adds a span at x, running for len. Assumes that co-ordinates have
    +    /// Adds a span at `x`, running for `len`. Both `x` and `len` must be
    +    /// supplied in super-sampled co-ordinates. Assumes that co-ordinates have
         /// already been appropriately clamped correctly to be non-negative and
         /// cropped for length (e.g., x=-5, len=10 should be clamped and clipped to
         /// x=0, len=5).
         ///
         /// Will extend the coverage set if necessary by adding space and/or
         /// splitting spans, before adding the coverage for the span.
         pub fn addSpan(self: *SparseCoverageBuffer, x: u32, len: u32) void {
    +        if (x + len > self.capacity * scale) {
    +            @panic("attempt to add span beyond capacity. this is a bug, please report it");
    +        }
    +
             if (len == 0) return;
     
             // Start co-ordinates and coverage
    
  • src/painter.zig+20 15 modified
    @@ -356,11 +356,11 @@ fn paintDirect(
     
             for (0..edge_list.items.len / 2) |edge_pair_idx| {
                 const edge_pair_start = edge_pair_idx * 2;
    -            const start_x: i32 = math.clamp(
    -                edge_list.items[edge_pair_start],
    -                0,
    -                sfc_width - 1,
    -            );
    +            const start_x: i32 = @max(0, edge_list.items[edge_pair_start]);
    +            if (start_x >= sfc_width) {
    +                // We're past the end of the draw area and can stop drawing.
    +                break;
    +            }
                 const end_x: i32 = math.clamp(
                     edge_list.items[edge_pair_start + 1],
                     start_x,
    @@ -514,8 +514,11 @@ fn paintSuperSample(
                     // Inverse to the above; pull back our scaled device space
                     // co-ordinates to mask space.
                     const edge_pair_start = edge_pair_idx * 2;
    -                const start_x: i32 =
    -                    math.clamp(edge_list.items[edge_pair_start] - box_x0, 0, mask_width - 1);
    +                const start_x: i32 = @max(0, edge_list.items[edge_pair_start] - box_x0);
    +                if (start_x >= mask_width) {
    +                    // We're past the mask draw area and can stop drawing.
    +                    break;
    +                }
                     const end_x: i32 =
                         math.clamp(edge_list.items[edge_pair_start + 1] - box_x0, start_x, mask_width);
     
    @@ -568,7 +571,6 @@ fn paintMultiSample(
         const alpha_scale: i32 = 256 / coverage_full;
     
         const sfc_width: i32 = surface.getWidth();
    -    const sfc_width_scaled: i32 = sfc_width * scale;
         const sfc_height: i32 = surface.getHeight();
         const _precision = if (operator.requiresFloat()) .float else precision;
         if (!polygons.inBox(scale, surface.getWidth(), surface.getHeight())) {
    @@ -627,8 +629,9 @@ fn paintMultiSample(
     
         // We need a scaled x-offset that we need to use when adding spans
         // (subtracting/pulling back) or drawing out spans (adding/pushing
    -    // forward).
    +    // forward), and a scaled draw width to clamp to.
         const scanline_start_x_scaled = scanline_start_x * scale;
    +    const scanline_draw_width_scaled = scanline_draw_width * scale;
     
         // Make an ArenaAllocator for our edges, this allows us to re-use the
         // same memory after every scanline by simply resetting the arena.
    @@ -692,19 +695,21 @@ fn paintMultiSample(
                     const edge_pair_start = edge_pair_idx * 2;
                     // Pull back the scaled device space co-ordinates (similar to
                     // supersample).
    -                const start_x: i32 = math.clamp(
    -                    edge_list.items[edge_pair_start] - scanline_start_x_scaled,
    -                    x_min,
    -                    sfc_width_scaled - 1,
    -                );
    +                const start_x: i32 = @max(x_min, edge_list.items[edge_pair_start] - scanline_start_x_scaled);
    +                if (start_x >= scanline_draw_width_scaled) {
    +                    // We're past the end of the draw area and can stop
    +                    // drawing.
    +                    break;
    +                }
    +
                     // NOTE: The end clamping here has to be properly synced with
                     // the (unscaled) scanline_end_x, found closer to coverage
                     // buffer initialization (used to calculate the buffer length,
                     // and clamped to sfc_width).
                     const end_x: i32 = math.clamp(
                         edge_list.items[edge_pair_start + 1] - scanline_start_x_scaled,
                         start_x,
    -                    sfc_width_scaled,
    +                    scanline_draw_width_scaled,
                     );
                     const fill_len: i32 = end_x - start_x;
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.