GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-928X-9MPW-8H56

Vulnerability from github – Published: 2026-09-02 21:35 – Updated: 2026-09-02 21:35
VLAI
Summary
Grav: Decompression Bomb via ZipArchiver - Missing Extraction Limits
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:

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;
    }
}
Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…