Commit 2a2b337392a for php.net
commit 2a2b337392a1e8d522026b3441360692db373192
Author: David Carlier <devnexen@gmail.com>
Date: Thu Aug 13 22:06:26 2026 +0100
ext/zip: fix extractTo()/getFrom*() success on CRC-corrupted entries
zip_fclose() returns positive error codes, so the extraction's n < 0 check
treated a failed read as success and wrote corrupt data to disk.
getFromName()/getFromIndex() read exactly sb.size bytes, never reaching end
of file where libzip validates the CRC, and returned the corrupt data
silently. Read errors now fail with a warning.
Close GH-23240
diff --git a/NEWS b/NEWS
index 8ddc64181e4..db0775ae7f4 100644
--- a/NEWS
+++ b/NEWS
@@ -6,6 +6,10 @@ PHP NEWS
. Fixed bug GH-15375 (Nested "yield from" skips items after a valid() or
next() call on the inner generator). (iliaal)
+- Zip:
+ . Fixed ZipArchive::extractTo() and ZipArchive::getFrom*() reporting success
+ on corrupted entries. (David Carlier)
+
27 Aug 2026, PHP 8.4.25
- Core:
diff --git a/ext/zip/php_zip.c b/ext/zip/php_zip.c
index 712468509cb..3d6abde1c31 100644
--- a/ext/zip/php_zip.c
+++ b/ext/zip/php_zip.c
@@ -135,6 +135,30 @@ static char * php_zip_make_relative_path(char *path, size_t path_len) /* {{{ */
# define CWD_STATE_ALLOC(l) emalloc(l)
# define CWD_STATE_FREE(s) efree(s)
+/* {{{ php_zip_file_error
+ Entry error code, plus its message when message is not NULL.
+ zip_error_t and its accessors only exist since libzip 1.0. */
+static int php_zip_file_error(struct zip_file *zf, const char **message)
+{
+#if LIBZIP_VERSION_MAJOR < 1
+ int zep, syp;
+
+ zip_file_error_get(zf, &zep, &syp);
+ if (message) {
+ *message = zip_file_strerror(zf);
+ }
+ return zep;
+#else
+ zip_error_t *err = zip_file_get_error(zf);
+
+ if (message) {
+ *message = zip_error_strerror(err);
+ }
+ return zip_error_code_zip(err);
+#endif
+}
+/* }}} */
+
/* {{{ php_zip_extract_file */
static int php_zip_extract_file(struct zip * za, char *dest, const char *file, size_t file_len, zip_int64_t idx)
{
@@ -268,7 +292,21 @@ static int php_zip_extract_file(struct zip * za, char *dest, const char *file, s
n = 0;
while ((n=zip_fread(zf, b, sizeof(b))) > 0) {
- php_stream_write(stream, b, n);
+ if (php_stream_write(stream, b, n) != n) {
+ n = -1;
+ break;
+ }
+ }
+
+ if (n < 0) {
+ const char *message;
+
+ if (php_zip_file_error(zf, &message) != ZIP_ER_OK) {
+ php_error_docref(NULL, E_WARNING, "Cannot extract \"%s\": \"%s\"", file, message);
+ }
+ php_stream_close(stream);
+ zip_fclose(zf);
+ goto done;
}
if (stream->wrapper->wops->stream_metadata) {
@@ -279,7 +317,7 @@ static int php_zip_extract_file(struct zip * za, char *dest, const char *file, s
}
php_stream_close(stream);
- n = zip_fclose(zf);
+ n = zip_fclose(zf) == 0 ? 0 : -1;
done:
efree(fullpath);
@@ -2953,10 +2991,6 @@ static void php_zip_get_from(INTERNAL_FUNCTION_PARAMETERS, int type) /* {{{ */
PHP_ZIP_STAT_INDEX(intern, index, flags, sb);
}
- if (sb.size < 1) {
- RETURN_EMPTY_STRING();
- }
-
if (len < 1) {
len = sb.size;
}
@@ -2971,8 +3005,40 @@ static void php_zip_get_from(INTERNAL_FUNCTION_PARAMETERS, int type) /* {{{ */
}
buffer = zend_string_safe_alloc(1, len, 0, 0);
- zip_int64_t n = zip_fread(zf, ZSTR_VAL(buffer), ZSTR_LEN(buffer));
- if (n < 1) {
+
+ /* zip_fread() may return short reads, a truncated entry must not pass for a complete one. */
+ zip_int64_t n = 0;
+ while ((zip_uint64_t)n < ZSTR_LEN(buffer)) {
+ zip_int64_t rd = zip_fread(zf, ZSTR_VAL(buffer) + n, ZSTR_LEN(buffer) - n);
+
+ if (rd < 0) {
+ n = -1;
+ break;
+ }
+ if (rd == 0) {
+ break;
+ }
+ n += rd;
+ }
+
+ if (n >= 0 && (zip_uint64_t)n == sb.size) {
+ /* The whole entry has been consumed, read past its last byte so that
+ * libzip reaches the end of the stream and validates the CRC. */
+ char tmp;
+ if (zip_fread(zf, &tmp, 1) < 0) {
+ n = -1;
+ }
+ }
+ if (n < 0) {
+ const char *message;
+
+ php_zip_file_error(zf, &message);
+ php_error_docref(NULL, E_WARNING, "Cannot read entry: %s", message);
+ zip_fclose(zf);
+ zend_string_efree(buffer);
+ RETURN_FALSE;
+ }
+ if (n == 0) {
zip_fclose(zf);
zend_string_efree(buffer);
RETURN_EMPTY_STRING();
diff --git a/ext/zip/tests/oo_extract_crc.phpt b/ext/zip/tests/oo_extract_crc.phpt
new file mode 100644
index 00000000000..bcb31185c13
--- /dev/null
+++ b/ext/zip/tests/oo_extract_crc.phpt
@@ -0,0 +1,44 @@
+--TEST--
+ZipArchive::extractTo() and getFrom*() fail on a CRC-corrupted entry
+--EXTENSIONS--
+zip
+--FILE--
+<?php
+$dirname = __DIR__ . '/oo_extract_crc_dir';
+mkdir($dirname);
+$file = $dirname . '/corrupt.zip';
+$payload = str_repeat('A', 64) . 'PAYLOAD-END';
+
+$zip = new ZipArchive();
+$zip->open($file, ZipArchive::CREATE | ZipArchive::OVERWRITE);
+$zip->addFromString('a.txt', $payload);
+$zip->setCompressionName('a.txt', ZipArchive::CM_STORE);
+$zip->close();
+
+$raw = file_get_contents($file);
+$raw[strpos($raw, 'AAAA') + 2] = 'Z';
+file_put_contents($file, $raw);
+
+$zip = new ZipArchive();
+$zip->open($file);
+var_dump($zip->extractTo($dirname, 'a.txt'));
+var_dump($zip->getFromName('a.txt'));
+var_dump($zip->getFromIndex(0));
+$zip->close();
+?>
+--CLEAN--
+<?php
+$dirname = __DIR__ . '/oo_extract_crc_dir';
+@unlink($dirname . '/a.txt');
+@unlink($dirname . '/corrupt.zip');
+@rmdir($dirname);
+?>
+--EXPECTF--
+Warning: ZipArchive::extractTo(): Cannot extract "a.txt": "CRC error" in %s on line %d
+bool(false)
+
+Warning: ZipArchive::getFromName(): Cannot read entry: CRC error in %s on line %d
+bool(false)
+
+Warning: ZipArchive::getFromIndex(): Cannot read entry: CRC error in %s on line %d
+bool(false)
diff --git a/ext/zip/tests/oo_get_from_crc_empty.phpt b/ext/zip/tests/oo_get_from_crc_empty.phpt
new file mode 100644
index 00000000000..5e48059701e
--- /dev/null
+++ b/ext/zip/tests/oo_get_from_crc_empty.phpt
@@ -0,0 +1,52 @@
+--TEST--
+ZipArchive::getFrom*() rejects a CRC-corrupted empty entry
+--EXTENSIONS--
+zip
+--SKIPIF--
+<?php
+/* libzip < 1.10.0 shortcuts empty entries and never checks their CRC. */
+if (version_compare(ZipArchive::LIBZIP_VERSION, '1.10.0', '<')) die('skip libzip < 1.10.0');
+?>
+--FILE--
+<?php
+$dirname = __DIR__ . '/oo_get_from_crc_empty_dir';
+mkdir($dirname);
+$file = $dirname . '/corrupt.zip';
+
+$zip = new ZipArchive();
+$zip->open($file, ZipArchive::CREATE | ZipArchive::OVERWRITE);
+$zip->addFromString('empty.txt', '');
+$zip->setCompressionName('empty.txt', ZipArchive::CM_STORE);
+$zip->close();
+
+/* Corrupt the CRC in both the local and central directory headers. */
+$raw = file_get_contents($file);
+for ($i = 0, $length = strlen($raw); $i + 3 < $length; $i++) {
+ $signature = substr($raw, $i, 4);
+ if ($signature === "PK\x03\x04") {
+ $raw[$i + 14] = "\x01";
+ } elseif ($signature === "PK\x01\x02") {
+ $raw[$i + 16] = "\x01";
+ }
+}
+file_put_contents($file, $raw);
+
+$zip = new ZipArchive();
+$zip->open($file);
+var_dump($zip->getFromName('empty.txt'));
+var_dump($zip->getFromIndex(0));
+$zip->close();
+?>
+--CLEAN--
+<?php
+$dirname = __DIR__ . '/oo_get_from_crc_empty_dir';
+@unlink($dirname . '/empty.txt');
+@unlink($dirname . '/corrupt.zip');
+@rmdir($dirname);
+?>
+--EXPECTF--
+Warning: ZipArchive::getFromName(): Cannot read entry: CRC error in %s on line %d
+bool(false)
+
+Warning: ZipArchive::getFromIndex(): Cannot read entry: CRC error in %s on line %d
+bool(false)
diff --git a/ext/zip/tests/oo_get_from_length.phpt b/ext/zip/tests/oo_get_from_length.phpt
new file mode 100644
index 00000000000..9eae83f3fec
--- /dev/null
+++ b/ext/zip/tests/oo_get_from_length.phpt
@@ -0,0 +1,46 @@
+--TEST--
+ZipArchive::getFrom*() rejects an entry with an inconsistent uncompressed size
+--EXTENSIONS--
+zip
+--FILE--
+<?php
+$dirname = __DIR__ . '/oo_get_from_length_dir';
+mkdir($dirname);
+$file = $dirname . '/inconsistent.zip';
+
+$zip = new ZipArchive();
+$zip->open($file, ZipArchive::CREATE | ZipArchive::OVERWRITE);
+$zip->addFromString('a.txt', str_repeat('A', 10));
+$zip->setCompressionName('a.txt', ZipArchive::CM_STORE);
+$zip->close();
+
+/* Advertise 20 bytes in the central directory, but keep only 10 bytes. */
+$raw = file_get_contents($file);
+for ($i = 0, $length = strlen($raw); $i + 27 < $length; $i++) {
+ if (substr($raw, $i, 4) === "PK\x01\x02") {
+ $size = unpack('V', substr($raw, $i + 24, 4))[1];
+ $raw = substr_replace($raw, pack('V', $size + 10), $i + 24, 4);
+ break;
+ }
+}
+file_put_contents($file, $raw);
+
+$zip = new ZipArchive();
+$zip->open($file);
+var_dump($zip->getFromName('a.txt'));
+var_dump($zip->getFromIndex(0));
+$zip->close();
+?>
+--CLEAN--
+<?php
+$dirname = __DIR__ . '/oo_get_from_length_dir';
+@unlink($dirname . '/a.txt');
+@unlink($dirname . '/inconsistent.zip');
+@rmdir($dirname);
+?>
+--EXPECTF--
+Warning: ZipArchive::getFromName(): Cannot read entry: Zip archive inconsistent%s
+bool(false)
+
+Warning: ZipArchive::getFromIndex(): Cannot read entry: Zip archive inconsistent%s
+bool(false)