Commit f87368b55e2 for php

commit f87368b55e2b6518db472612545ed332dbb62439
Author: lacatoire <la.catoire@gmail.com>
Date:   Tue Oct 20 09:12:43 2026 +0200

    ext/gd: keep the brush and tile images alive while the destination uses them

    imagesetbrush() and imagesettile() store a raw pointer to the brush or tile
    image in libgd, but the destination GdImage keeps no reference to it. Once the
    brush object is released, drawing with IMG_COLOR_BRUSHED or IMG_COLOR_TILED
    reads freed memory. Keep a reference to both images in the destination object
    and expose them to the GC.

    Close GH-24444

diff --git a/NEWS b/NEWS
index 2478151740c..34a5a7506dc 100644
--- a/NEWS
+++ b/NEWS
@@ -33,6 +33,10 @@ PHP                                                                        NEWS
   . Fixed bug GH-25268 (NULL php_libxml_ref_obj dereference in
     dom_objects_store_clone_obj when cloning Dom\Notation). (David Carlier)

+- GD:
+  . Fixed use-after-free when drawing with a brush or tile image that was
+    released after imagesetbrush() or imagesettile(). (lacatoire)
+
 - MBString:
   . Fixed stack overflow in mb_check_encoding() and mb_convert_encoding() with
     deeply nested arrays. (Eyüp Can Akman)
diff --git a/ext/gd/gd.c b/ext/gd/gd.c
index 9f6e38a2e78..2b8d7341bce 100644
--- a/ext/gd/gd.c
+++ b/ext/gd/gd.c
@@ -135,6 +135,9 @@ zend_class_entry *gd_image_ce;

 typedef struct _gd_ext_image_object {
 	gdImagePtr image;
+	/* libgd only keeps a pointer to the brush and tile images: hold them alive */
+	zval brush;
+	zval tile;
 	zend_object std;
 } php_gd_image_object;

@@ -183,9 +186,23 @@ static void php_gd_image_object_free(zend_object *intern)
 	if (img_obj_ptr->image) {
 		gdImageDestroy(img_obj_ptr->image);
 	}
+	zval_ptr_dtor(&img_obj_ptr->brush);
+	zval_ptr_dtor(&img_obj_ptr->tile);
 	zend_object_std_dtor(intern);
 }

+static HashTable *php_gd_image_object_get_gc(zend_object *intern, zval **table, int *n)
+{
+	php_gd_image_object *img_obj_ptr = php_gd_exgdimage_from_zobj_p(intern);
+	zend_get_gc_buffer *gc_buffer = zend_get_gc_buffer_create();
+
+	zend_get_gc_buffer_add_zval(gc_buffer, &img_obj_ptr->brush);
+	zend_get_gc_buffer_add_zval(gc_buffer, &img_obj_ptr->tile);
+	zend_get_gc_buffer_use(gc_buffer, table, n);
+
+	return zend_std_get_properties(intern);
+}
+
 /**
  * Creates a new GdImage object wrapping the gdImagePtr and attaches it
  * to the zval (usually return_value).
@@ -208,6 +225,7 @@ static void php_gd_object_minit_helper(void)
 	memcpy(&php_gd_image_object_handlers, &std_object_handlers, sizeof(zend_object_handlers));
 	php_gd_image_object_handlers.clone_obj = NULL;
 	php_gd_image_object_handlers.free_obj = php_gd_image_object_free;
+	php_gd_image_object_handlers.get_gc = php_gd_image_object_get_gc;
 	php_gd_image_object_handlers.get_constructor = php_gd_image_object_get_constructor;
 	php_gd_image_object_handlers.compare = zend_objects_not_comparable;
 	php_gd_image_object_handlers.offset = XtOffsetOf(php_gd_image_object, std);
@@ -1274,6 +1292,10 @@ PHP_FUNCTION(imagesettile)

 	gdImageSetTile(im, tile);

+	php_gd_image_object *dst = php_gd_exgdimage_from_zobj_p(Z_OBJ_P(IM));
+	zval_ptr_dtor(&dst->tile);
+	ZVAL_COPY(&dst->tile, TILE);
+
 	RETURN_TRUE;
 }
 /* }}} */
@@ -1294,6 +1316,10 @@ PHP_FUNCTION(imagesetbrush)

 	gdImageSetBrush(im, tile);

+	php_gd_image_object *dst = php_gd_exgdimage_from_zobj_p(Z_OBJ_P(IM));
+	zval_ptr_dtor(&dst->brush);
+	ZVAL_COPY(&dst->brush, TILE);
+
 	RETURN_TRUE;
 }
 /* }}} */
diff --git a/ext/gd/tests/imagesetbrush_tile_keep_alive.phpt b/ext/gd/tests/imagesetbrush_tile_keep_alive.phpt
new file mode 100644
index 00000000000..24a8c5bea64
--- /dev/null
+++ b/ext/gd/tests/imagesetbrush_tile_keep_alive.phpt
@@ -0,0 +1,47 @@
+--TEST--
+imagesetbrush(), imagesettile(): the brush and tile images stay alive while they are in use
+--EXTENSIONS--
+gd
+--FILE--
+<?php
+$im = imagecreatetruecolor(20, 20);
+$brush = imagecreatetruecolor(4, 4);
+imagefill($brush, 0, 0, 0xff0000);
+imagesetbrush($im, $brush);
+unset($brush);
+
+$junk = [];
+for ($i = 0; $i < 50; $i++) {
+    $j = imagecreatetruecolor(4, 4);
+    imagefill($j, 0, 0, 0x00ff00);
+    $junk[] = $j;
+}
+imageline($im, 2, 10, 17, 10, IMG_COLOR_BRUSHED);
+printf("%06x\n", imagecolorat($im, 5, 10));
+
+$tile = imagecreatetruecolor(2, 2);
+imagefill($tile, 0, 0, 0x0000ff);
+imagesettile($im, $tile);
+unset($tile);
+$junk = [];
+for ($i = 0; $i < 50; $i++) {
+    $j = imagecreatetruecolor(2, 2);
+    imagefill($j, 0, 0, 0x00ff00);
+    $junk[] = $j;
+}
+imagefilledrectangle($im, 0, 0, 19, 19, IMG_COLOR_TILED);
+printf("%06x\n", imagecolorat($im, 5, 5));
+
+// A new brush replaces the old one, and a cycle is collected
+$a = imagecreatetruecolor(4, 4);
+imagesetbrush($a, $a);
+imagesettile($a, $a);
+$w = WeakReference::create($a);
+unset($a);
+gc_collect_cycles();
+var_dump($w->get());
+?>
+--EXPECT--
+ff0000
+0000ff
+NULL