Commit c889faab35a for php.net

commit c889faab35a9ece483552114896cb80c15949888
Author: David Carlier <devnexen@gmail.com>
Date:   Fri May 29 07:00:54 2026 +0100

    ext/zip: memory leak when zip cancel callback bails out.

    Fix #22176

    A cancel callback that throws during the implicit zip_close() in the
    shutdown destructor triggers a zend_bailout that longjmps through
    libzip, skipping its free(filelist). Wrap the call in zend_try/zend_catch
    and cancel on bailout so libzip can unwind and clean up.

    While at it, apply the same guard to the progress callback, which is
    invoked from libzip the same way and is prone to the identical leak.

    close GH-22177

diff --git a/NEWS b/NEWS
index 370095987ea..e476e2b8303 100644
--- a/NEWS
+++ b/NEWS
@@ -55,6 +55,9 @@ PHP                                                                        NEWS
 - Zip:
   . Fixed bug GH-21705 (ZipArchive::getFromIndex() ignores
     ZipArchive::FL_UNCHANGED for deleted entries). (Weilin Du)
+  . Fixed bug GH-22176 (memory leak with ZipArchive::registerCancelBack()
+    is used with reference returning function during shutdown).
+    (David Carlier)

 02 Jul 2026, PHP 8.6.0alpha1

diff --git a/ext/zip/php_zip.c b/ext/zip/php_zip.c
index 5df4d47740a..9bc801455ed 100644
--- a/ext/zip/php_zip.c
+++ b/ext/zip/php_zip.c
@@ -628,6 +628,12 @@ static bool php_zipobj_close(ze_zip_object *obj, zend_string **out_str) /* {{{ *

 	obj->za = NULL;
 	obj->from_string = false;
+
+	if (obj->bailout_callback) {
+		obj->bailout_callback = false;
+		zend_bailout();
+	}
+
 	return success;
 }
 /* }}} */
@@ -1073,10 +1079,16 @@ static void php_zip_object_dtor(zend_object *object)

 	if (intern->za) {
 		if (zip_close(intern->za) != 0) {
-			php_error_docref(NULL, E_WARNING, "Cannot destroy the zip context: %s", zip_strerror(intern->za));
+			if (!intern->bailout_callback) {
+				php_error_docref(NULL, E_WARNING, "Cannot destroy the zip context: %s", zip_strerror(intern->za));
+			}
 			zip_discard(intern->za);
 		}
 		intern->za = NULL;
+		if (intern->bailout_callback) {
+			intern->bailout_callback = false;
+			zend_bailout();
+		}
 	}
 }

@@ -2985,15 +2997,21 @@ PHP_METHOD(ZipArchive, getStream)
 #ifdef HAVE_PROGRESS_CALLBACK
 static void php_zip_progress_callback(zip_t *arch, double state, void *ptr)
 {
-	if (!EG(active)) {
+	ze_zip_object *obj = ptr;
+
+	if (UNEXPECTED(!EG(active) || obj->bailout_callback)) {
 		return;
 	}

 	zval cb_args[1];
-	ze_zip_object *obj = ptr;

 	ZVAL_DOUBLE(&cb_args[0], state);
-	zend_call_known_fcc(&obj->progress_callback, NULL, 1, cb_args, NULL);
+
+	zend_try {
+		zend_call_known_fcc(&obj->progress_callback, NULL, 1, cb_args, NULL);
+	} zend_catch {
+		obj->bailout_callback = true;
+	} zend_end_try();
 }

 /* {{{ register a progression callback: void callback(double state); */
@@ -3036,11 +3054,18 @@ static int php_zip_cancel_callback(zip_t *arch, void *ptr)
 	zval cb_retval;
 	ze_zip_object *obj = ptr;

-	if (!EG(active)) {
+	if (UNEXPECTED(!EG(active) || obj->bailout_callback)) {
 		return 0;
 	}

-	zend_call_known_fcc(&obj->cancel_callback, &cb_retval, 0, NULL, NULL);
+	zend_try {
+		zend_call_known_fcc(&obj->cancel_callback, &cb_retval, 0, NULL, NULL);
+	} zend_catch {
+		obj->bailout_callback = true;
+		/* Cancel if a bailout occurs to allow cleanup to happen */
+		return -1;
+	} zend_end_try();
+
 	if (Z_ISUNDEF(cb_retval)) {
 		/* Cancel if an exception has been thrown */
 		return -1;
diff --git a/ext/zip/php_zip.h b/ext/zip/php_zip.h
index 9e57a54de1c..408c2970823 100644
--- a/ext/zip/php_zip.h
+++ b/ext/zip/php_zip.h
@@ -74,6 +74,7 @@ typedef struct _ze_zip_object {
 	zip_int64_t last_id;
 	int err_zip;
 	int err_sys;
+	bool bailout_callback;
 #ifdef HAVE_PROGRESS_CALLBACK
 	zend_fcall_info_cache progress_callback;
 #endif
diff --git a/ext/zip/tests/gh22176.phpt b/ext/zip/tests/gh22176.phpt
new file mode 100644
index 00000000000..90b6327072c
--- /dev/null
+++ b/ext/zip/tests/gh22176.phpt
@@ -0,0 +1,33 @@
+--TEST--
+GH-22176 (Memory leak when a ZipArchive cancel callback bails out in the shutdown destructor)
+--EXTENSIONS--
+zip
+--SKIPIF--
+<?php
+if (!method_exists('ZipArchive', 'registerCancelCallback')) die('skip libzip too old');
+?>
+--FILE--
+<?php
+$zip = new ZipArchive;
+$zip->open(__DIR__ . '/gh22176_cancel.zip', ZipArchive::CREATE);
+$zip->registerCancelCallback(function () {
+    throw new \Exception('cancel boom');
+});
+$zip->addFromString('test', 'test');
+echo "done\n";
+// The archive is flushed and the object destroyed during request shutdown;
+// the thrown exception bails out through libzip's zip_close() without leaking
+// its internal state, and the bailout resumes once libzip has unwound.
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/gh22176_cancel.zip');
+?>
+--EXPECTF--
+done
+
+Fatal error: Uncaught Exception: cancel boom in %s:%d
+Stack trace:
+#0 [internal function]: {closure:%s:%d}()
+#1 {main}
+  thrown in %s on line %d
diff --git a/ext/zip/tests/gh22176_progress.phpt b/ext/zip/tests/gh22176_progress.phpt
new file mode 100644
index 00000000000..5507fe24ab9
--- /dev/null
+++ b/ext/zip/tests/gh22176_progress.phpt
@@ -0,0 +1,33 @@
+--TEST--
+GH-22176 (Memory leak when a ZipArchive progress callback bails out in the shutdown destructor)
+--EXTENSIONS--
+zip
+--SKIPIF--
+<?php
+if (!method_exists('ZipArchive', 'registerProgressCallback')) die('skip libzip too old');
+?>
+--FILE--
+<?php
+$zip = new ZipArchive;
+$zip->open(__DIR__ . '/gh22176_progress.zip', ZipArchive::CREATE);
+$zip->registerProgressCallback(0.5, function ($r) {
+    throw new \Exception('progress boom');
+});
+$zip->addFromString('test', 'test');
+echo "done\n";
+// The archive is flushed and the object destroyed during request shutdown;
+// the thrown exception bails out through libzip's zip_close() without leaking
+// its internal state, and the bailout resumes once libzip has unwound.
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/gh22176_progress.zip');
+?>
+--EXPECTF--
+done
+
+Fatal error: Uncaught Exception: progress boom in %s:%d
+Stack trace:
+#0 [internal function]: {closure:%s:%d}(0.0)
+#1 {main}
+  thrown in %s on line %d