Commit 132403de396 for php.net

commit 132403de39604a7e7a3fb04276dde54d6326c9aa
Author: David Carlier <devnexen@gmail.com>
Date:   Fri Sep 18 05:04:30 2026 +0100

    ext/zip: ZipArchive::close() use-after-free from a progress or cancel callback.

    Fix #23747

    ZipArchive::close() called from a progress or cancel callback ran a
    nested zip_close() that failed, then zip_discard() freed the archive
    while the outer zip_close() from close() or open() was still using it.
    Track the close in progress and throw an Error from close() and open()
    meanwhile.

    Close GH-23749

diff --git a/NEWS b/NEWS
index c38d108d840..c8eb609cc2a 100644
--- a/NEWS
+++ b/NEWS
@@ -47,6 +47,8 @@ PHP                                                                        NEWS
 - Zip:
   . Fixed ZipArchive::extractTo() ignoring files given in a non-list array.
     (David Carlier)
+  . Fixed bug GH-23747 (ZipArchive::close() use-after-free from a progress or
+    cancel callback). (David Carlier)


 24 Sep 2026, PHP 8.4.26
diff --git a/ext/zip/php_zip.c b/ext/zip/php_zip.c
index 682736d4fe7..42a5a527cfc 100644
--- a/ext/zip/php_zip.c
+++ b/ext/zip/php_zip.c
@@ -1582,8 +1582,16 @@ PHP_METHOD(ZipArchive, open)

 	if (ze_obj->archive) {
 		/* we already have an opened zip, free it */
+		if (ze_obj->archive->close) {
+			efree(resolved_path);
+			zend_throw_error(NULL, "Already being closed");
+			RETURN_THROWS();
+		}
 		intern = ze_obj->archive->za;
-		if (zip_close(intern) != 0) {
+		ze_obj->archive->close = true;
+		err = zip_close(intern);
+		ze_obj->archive->close = false;
+		if (err != 0) {
 			php_error_docref(NULL, E_WARNING, "Empty string as source");
 			efree(resolved_path);
 			RETURN_FALSE;
@@ -1668,7 +1676,14 @@ PHP_METHOD(ZipArchive, close)

 	ze_obj = Z_ZIP_P(self);

+	if (ze_obj->archive->close) {
+		zend_throw_error(NULL, "Already being closed");
+		RETURN_THROWS();
+	}
+
+	ze_obj->archive->close = true;
 	err = zip_close(intern);
+	ze_obj->archive->close = false;
 	if (err) {
 		php_error_docref(NULL, E_WARNING, "%s", zip_strerror(intern));
 		/* Save error for property reader */
diff --git a/ext/zip/php_zip.h b/ext/zip/php_zip.h
index 8385674a1cf..e5930c87aad 100644
--- a/ext/zip/php_zip.h
+++ b/ext/zip/php_zip.h
@@ -73,6 +73,7 @@ typedef struct _php_zip_archive {
 	/* libzip reads buffers until the archive is closed, can outlive the object. */
 	char **buffers;
 	int buffers_cnt;
+	bool close;
 #ifdef HAVE_PROGRESS_CALLBACK
 	zval progress_callback;
 #endif
diff --git a/ext/zip/tests/gh23747.phpt b/ext/zip/tests/gh23747.phpt
new file mode 100644
index 00000000000..24e977410e1
--- /dev/null
+++ b/ext/zip/tests/gh23747.phpt
@@ -0,0 +1,102 @@
+--TEST--
+GH-23747 (ZipArchive::close() from inside a progress or cancel callback causes segv)
+--CREDITS--
+djarfluka
+--EXTENSIONS--
+zip
+--SKIPIF--
+<?php
+if (!method_exists(ZipArchive::class, 'registerProgressCallback')) {
+    die('skip progress callbacks are not supported');
+}
+if (!method_exists(ZipArchive::class, 'registerCancelCallback')) {
+    die('skip cancel callbacks are not supported');
+}
+?>
+--FILE--
+<?php
+function populate(ZipArchive $zip, string $filename): void {
+    $zip->open($filename, ZipArchive::CREATE | ZipArchive::OVERWRITE);
+    for ($i = 0; $i < 64; $i++) {
+        $zip->addFromString("f$i.txt", str_repeat('x', 2000));
+    }
+}
+
+$filename = __DIR__ . '/gh23747.zip';
+
+$zip = new ZipArchive();
+populate($zip, $filename);
+$zip->registerProgressCallback(0.0, function ($rate) use ($zip) {
+    static $done = false;
+    if (!$done) {
+        $done = true;
+        try {
+            $zip->close();
+        } catch (Error $e) {
+            echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+        }
+    }
+});
+var_dump($zip->close());
+
+$zip = new ZipArchive();
+populate($zip, $filename);
+$zip->registerCancelCallback(function () use ($zip) {
+    static $done = false;
+    if (!$done) {
+        $done = true;
+        try {
+            $zip->close();
+        } catch (Error $e) {
+            echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+        }
+    }
+    return 0;
+});
+var_dump($zip->close());
+
+$zip = new ZipArchive();
+populate($zip, $filename);
+$zip->registerProgressCallback(0.0, function ($rate) use ($zip) {
+    static $done = false;
+    if (!$done) {
+        $done = true;
+        try {
+            $zip->close();
+        } catch (Error $e) {
+            echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+        }
+    }
+});
+var_dump($zip->open($filename));
+var_dump($zip->count());
+
+$zip = new ZipArchive();
+populate($zip, $filename);
+$zip->registerProgressCallback(0.0, function ($rate) use ($zip, $filename) {
+    static $done = false;
+    if (!$done) {
+        $done = true;
+        try {
+            $zip->open($filename);
+        } catch (Error $e) {
+            echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+        }
+    }
+});
+var_dump($zip->close());
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/gh23747.zip');
+?>
+--EXPECT--
+Error: Already being closed
+bool(true)
+Error: Already being closed
+bool(true)
+Error: Already being closed
+bool(true)
+int(64)
+Error: Already being closed
+bool(true)