Safeguard
Vulnerability Analysis•

Appium CVE-2026-103975: The Zip Slip That Name Checks Can't See

Appium's second Zip Slip fix: clean entry names, a symlink entry, and a file written through it. The fix, a lab across extractors, and a lockfile check.

Safeguard Security Team
Security Research
12 min read

In March, Appium fixed a Zip Slip bug in @appium/support, the utility package behind the mobile test automation server: an archive entry named ../evil.txt could be written outside the extraction folder (CVE-2026-30973). In July it fixed a second one that the first fix couldn't see (CVE-2026-103975). No entry name contained ... One entry was a symbolic link pointing outside the folder, and the next entry was a file whose path ran through that link. Every name check passed, and the file still landed outside.

This teardown is for teams that run Appium on CI runners or device farms, and for anyone who maintains code that unpacks archives. It covers both bugs, why checking entry names can never catch the second, the fix, a lab that runs the same harmless two-entry archive through common extractors, and a checker for your lockfiles.

The short answer

CVE-2026-103975 lets an archive that Appium extracts write files outside the extraction folder through a symlink entry: one entry creates a link to a directory elsewhere, and a later entry, with a clean name, is written through it. @appium/support 7.2.5 and earlier are affected; 7.2.6 (25 July) checks the real path of every parent directory and rejects links whose targets leave the folder. Appium 3.6.0 and later bundle a fixed version. Upgrade, check that no older copy of @appium/support remains elsewhere in your lockfile, and, if you maintain an extractor, test it with a symlink archive, not just ../ names.

Two bugs, one function

CVE-2026-30973CVE-2026-103975
VectorAn entry named with ../A symlink entry, then a file written through it
Entry namesContain ..Clean: no .., not absolute
What was missingThe bounds check built an error but never threw itName checks don't resolve symlinks; link targets weren't checked
Fixed in @appium/support7.0.6 (8 March)7.2.6 (25 July)
Repository advisory9 March17 August
GitHub advisory database11 March15 October
SeverityMedium, CVSS 3.1 score 6.5Medium, CVSS 3.1 score 6.5
Reported bybugbunny-researchnikkoenggaliano

The advisory for the second bug quotes the 7.2.5 code. The extractor joined each entry name onto the destination, compared the result with the destination as strings, and threw if it fell outside. For a symlink entry, it then called fs.symlink(link, dest) with the target exactly as the archive gave it.

The string comparison is called a lexical check. It works on path text, so out/assets/note.txt is inside out/ by definition. It can't know that out/assets became a symlink to somewhere else one entry earlier. When the extractor opens out/assets/note.txt for writing, the operating system follows the link.

The advisory's impact section is direct: an attacker who controls an archive that Appium extracts can write anywhere the Appium process can, such as a config file, a shell profile or a binary on the PATH. On a CI runner or device farm host, that's whatever the service account can reach. The CVSS vector requires user interaction, because someone has to point Appium at the archive.

The fix

Commit d9eb36f, titled "Prevent extraction of external symlinks", changes three things:

  1. Parents are checked for real. Before creating or writing anything, the extractor walks each directory segment between the root and the destination, calling lstat and realpath, and rejects the entry if any real path falls outside the root.
  2. Link targets are checked. A symlink entry's target is resolved against the real parent directory, and the entry is rejected if that lands outside the root.
  3. The write destination is checked again right before the file is opened.

The commit adds an end-to-end test built from the advisory's own two-entry archive, asserting that extraction fails with "Out of bound" and that nothing appears in the outside folder. That test shape is the reusable part: if your extractor has a test for ../ names and none for symlinks, it covers half the bug class.

A lab: one archive, four extractors and two models

This script builds a zip and a tar with two entries: assets, a symlink to ../outside, and assets/note.txt, a plain file. The link target is a sibling folder inside the lab, so nothing outside the working directory is touched. Each extractor unpacks into its own empty folder, and the script checks whether note.txt appeared in outside. It also includes two short Python models of the extractor logic: one with a lexical check only, like 7.2.5, and one that resolves real parents and checks link targets, like 7.2.6. They're models, not Appium's code:

#!/usr/bin/env python3
"""Does an extractor write through a symlink entry? A harmless, local test.

Builds a zip and a tar with two entries: "assets", a symlink to "../outside",
and "assets/note.txt", a plain file. Every entry name is clean (no "..", not
absolute). Each extractor unpacks into its own empty folder; if note.txt shows
up in the sibling "outside" folder, the extractor followed the link.
"""
import io, os, pathlib, shutil, subprocess, sys, tarfile, zipfile

work = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "work").resolve()
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
LINK, TARGET, FILE = "assets", "../outside", "assets/note.txt"

def build_zip(path):
    with zipfile.ZipFile(path, "w") as z:
        link = zipfile.ZipInfo(LINK)
        link.create_system = 3                     # Unix, so the mode bits are honoured
        link.external_attr = 0o120777 << 16        # S_IFLNK: the body is the link target
        link.compress_type = zipfile.ZIP_DEFLATED
        z.writestr(link, TARGET)
        z.writestr(FILE, "written through the link\n", zipfile.ZIP_DEFLATED)

def build_tar(path):
    with tarfile.open(path, "w") as t:
        link = tarfile.TarInfo(LINK); link.type = tarfile.SYMTYPE; link.linkname = TARGET
        t.addfile(link)
        data = b"written through the link\n"
        f = tarfile.TarInfo(FILE); f.size = len(data)
        t.addfile(f, io.BytesIO(data))

build_zip(work / "link.zip")
build_tar(work / "link.tar")

def py_zip(src, out):
    with zipfile.ZipFile(src) as z:
        z.extractall(out)

def py_tar(flt):
    def run(src, out):
        with tarfile.open(src) as t:
            t.extractall(out, filter=flt) if flt else t.extractall(out)
    return run

def is_symlink_entry(info):
    return (info.external_attr >> 16) & 0o170000 == 0o120000

def naive_extract(src, out):
    """Models @appium/support <= 7.2.5: a lexical name check, link targets taken as-is."""
    root = os.path.abspath(out)
    with zipfile.ZipFile(src) as z:
        for info in z.infolist():
            dest = os.path.normpath(os.path.join(root, info.filename))
            if os.path.commonpath([dest, root]) != root:        # never resolves symlinks
                raise ValueError(f"out of bound path {info.filename}")
            os.makedirs(os.path.dirname(dest), exist_ok=True)
            if is_symlink_entry(info):
                os.symlink(z.read(info).decode(), dest)
            else:
                with open(dest, "wb") as f:                     # follows any symlinked parent
                    f.write(z.read(info))

def contained(path, root):
    return os.path.commonpath([path, root]) == root

def fixed_extract(src, out):
    """Models the 7.2.6 approach: resolve real parents and check every link target."""
    root = os.path.realpath(out)
    with zipfile.ZipFile(src) as z:
        for info in z.infolist():
            dest = os.path.normpath(os.path.join(root, info.filename))
            parent = os.path.dirname(dest)
            if not contained(dest, root):
                raise ValueError(f"out of bound path {info.filename}")
            os.makedirs(parent, exist_ok=True)
            real_parent = os.path.realpath(parent)
            if not contained(real_parent, root):
                raise ValueError(f"out of bound path {info.filename} (through a symlink)")
            if is_symlink_entry(info):
                target = z.read(info).decode()
                if not contained(os.path.normpath(os.path.join(real_parent, target)), root):
                    raise ValueError(f"out of bound symlink target {target!r}")
                os.symlink(target, dest)
            else:
                with open(os.path.join(real_parent, os.path.basename(dest)), "xb") as f:
                    f.write(z.read(info))

def cmd(*argv):
    def run(src, out):
        r = subprocess.run([a.format(src=src, out=out) for a in argv], capture_output=True, text=True)
        if r.returncode:
            raise RuntimeError((r.stderr or r.stdout).strip().splitlines()[-1])
    return run

cases = [
    ("Python zipfile.extractall", "link.zip", py_zip),
    ("Python tarfile, default filter", "link.tar", py_tar(None)),
    ("Python tarfile, filter='fully_trusted'", "link.tar", py_tar("fully_trusted")),
    ("Info-ZIP unzip", "link.zip", cmd("unzip", "-q", "{src}", "-d", "{out}")),
    ("bsdtar (zip)", "link.zip", cmd("bsdtar", "-xf", "{src}", "-C", "{out}")),
    ("bsdtar (tar)", "link.tar", cmd("bsdtar", "-xf", "{src}", "-C", "{out}")),
    ("bsdtar -P (tar)", "link.tar", cmd("bsdtar", "-P", "-xf", "{src}", "-C", "{out}")),
    ("Model: lexical check only (<= 7.2.5)", "link.zip", naive_extract),
    ("Model: realpath + target check (7.2.6)", "link.zip", fixed_extract),
]

print(f"Python {sys.version.split()[0]}")
for i, (name, archive, fn) in enumerate(cases):
    case = work / f"case{i}"
    out, outside = case / "out", case / "outside"
    out.mkdir(parents=True); outside.mkdir()
    try:
        fn(str(work / archive), str(out))
        err = ""
    except Exception as e:  # noqa: BLE001
        err = "error: " + " ".join(str(e).replace(str(case) + "/", "").split())[:58]
    escaped = (outside / "note.txt").exists()
    link = out / LINK
    kind = "symlink" if link.is_symlink() else "dir" if link.is_dir() else "file" if link.exists() else "absent"
    verdict = "ESCAPED" if escaped else "contained"
    print(f"{name:42} {verdict:9} assets={kind:7} {err}")

On macOS with Python 3.14.7, Apple's build of Info-ZIP UnZip 6.00, and bsdtar 3.5.3 (libarchive 3.7.4):

$ python3 -I symlink_lab.py work
Python 3.14.7
Python zipfile.extractall                  contained assets=file    error: [Errno 20] Not a directory: 'out/assets/note.txt'
Python tarfile, default filter             contained assets=absent  error: 'assets' would link to 'outside', which is outside the des
Python tarfile, filter='fully_trusted'     ESCAPED   assets=symlink 
Info-ZIP unzip                             contained assets=symlink error: unable to process assets/note.txt.
bsdtar (zip)                               contained assets=symlink error: bsdtar: Error exit delayed from previous errors.
bsdtar (tar)                               contained assets=symlink error: bsdtar: Error exit delayed from previous errors.
bsdtar -P (tar)                            ESCAPED   assets=symlink 
Model: lexical check only (<= 7.2.5)       ESCAPED   assets=symlink 
Model: realpath + target check (7.2.6)     contained assets=absent  error: out of bound symlink target '../outside'

What the results show:

  • Python's zipfile doesn't create symlinks at all. It writes assets as a regular file holding the link text, so the second entry fails. That's safe, if surprising.
  • Python's tarfile blocks the link outright. Since Python 3.14, extractall uses the data filter by default, which refuses links that point outside the destination. With filter='fully_trusted' it writes straight through. On earlier versions that support filters, such as 3.12 and 3.13, pass filter='data' explicitly. Our tarfile post covers the history.
  • Info-ZIP unzip and bsdtar refuse to write through the link, but both leave the symlink itself in place, pointing outside the folder. Anything that later writes into out/assets will follow it. unzip defers creating links until the end; bsdtar refuses to extract through symlinks unless you pass -P, which turns that protection off, and the lab shows the file escaping when you do.
  • The lexical-only model escapes, and the realpath model refuses with an out-of-bound symlink target error. That's the whole bug and the whole fix in two lines of output.

The realpath approach has one limit worth knowing. It checks and then writes, so a process that can modify the extraction folder at the same moment could still race it. Extracting into a fresh directory that only your process can write to removes that window.

Check your lockfiles

Fixing Appium itself isn't always enough, because npm can install several copies of @appium/support at once: one for Appium, one for a driver or plugin, one pinned by a test helper. This script lists every copy in a package-lock.json and says which fix each one is missing:

#!/usr/bin/env python3
"""List every @appium/support copy in an npm lockfile and flag the Zip Slip fixes it lacks.

  appium_support_check.py package-lock.json [...]

CVE-2026-30973 ("../" entry names) is fixed in 7.0.6; CVE-2026-103975 (symlink
entries) in 7.2.6. Standard library only; reads files.
"""
import json
import pathlib
import re
import sys

PKG = "@appium/support"
FIXES = [((7, 0, 6), "CVE-2026-30973 (../ entry names)"),
         ((7, 2, 6), "CVE-2026-103975 (symlink entries)")]


def vtuple(v):
    m = re.match(r"(\d+)\.(\d+)\.(\d+)", v)
    return tuple(map(int, m.groups())) if m else None


def from_npm(text):
    data = json.loads(text)
    for path, meta in data.get("packages", {}).items():
        if path.endswith("node_modules/" + PKG) and "version" in meta:
            yield path or PKG, meta["version"]
    def walk(deps, trail):  # lockfileVersion 1
        for name, meta in (deps or {}).items():
            if name == PKG:
                yield "/".join(trail + [name]), meta.get("version", "")
            yield from walk(meta.get("dependencies"), trail + [name])
    if "packages" not in data:
        yield from walk(data.get("dependencies"), [])


def check(path):
    p = pathlib.Path(path)
    text = p.read_text()
    found = list(from_npm(text))
    print(f"{path}: {len(found)} copies of {PKG}")
    bad = 0
    for where, ver in sorted(set(found)):
        vt = vtuple(ver)
        missing = [label for fixed, label in FIXES if vt and vt[0] == fixed[0] and vt < fixed]
        if vt and vt[0] < 7:
            missing = ["older major line; upgrade to 7.2.6 or later"]
        bad += bool(missing)
        print(f"  {'VULNERABLE' if missing else 'ok        '} {ver:8} {where}")
        for label in missing:
            print(f"             missing fix for {label}")
    return bad


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    sys.exit(1 if sum(check(a) for a in sys.argv[1:]) else 0)

On three lockfiles generated with npm install --package-lock-only --ignore-scripts: Appium 3.5.2 from June, Appium 3.8.0, and Appium 3.8.0 in a project that also pins @appium/support 7.2.5 directly:

$ python3 appium_support_check.py jun-runner/package-lock.json oct-runner/package-lock.json mixed-runner/package-lock.json
jun-runner/package-lock.json: 1 copies of @appium/support
  VULNERABLE 7.2.5    node_modules/@appium/support
             missing fix for CVE-2026-103975 (symlink entries)
oct-runner/package-lock.json: 1 copies of @appium/support
  ok         7.2.8    node_modules/@appium/support
mixed-runner/package-lock.json: 5 copies of @appium/support
  ok         7.2.8    node_modules/@appium/base-driver/node_modules/@appium/support
  ok         7.2.8    node_modules/@appium/base-plugin/node_modules/@appium/support
  ok         7.2.8    node_modules/@appium/docutils/node_modules/@appium/support
  VULNERABLE 7.2.5    node_modules/@appium/support
             missing fix for CVE-2026-103975 (symlink entries)
  ok         7.2.8    node_modules/appium/node_modules/@appium/support
(exit 1)

The third case is the one to look for. Appium's own copies are current, but the top-level copy, the one a project's own scripts import, is still vulnerable.

What to do

  1. Upgrade to Appium 3.6.0 or later, which bundles @appium/support 7.2.6 or newer, and update drivers and plugins.
  2. Check every copy with the script above or npm ls @appium/support, and remove direct pins to old versions.
  3. Only give Appium archives from sources you control, such as your own build artifacts, and run it as a user that can't write to its own binaries or shell profiles.
  4. If you maintain an extractor, test the symlink case. Either refuse symlink entries unless you need them, or resolve real parents and check link targets as the 7.2.6 fix does. Extract into a fresh private directory.
  5. Review extraction flags in scripts. bsdtar -P, tar --absolute-names and Python's fully_trusted filter all switch off the protections the lab relied on. Our Zip Slip explainer covers the basic name-based case.

The fair view

Appium handled the second report well. The fix came with a test built from the reporter's archive, and the advisory explains plainly how it differs from the first bug. Exploitation needs someone to feed Appium a hostile archive, which is why it's rated medium.

But the pair is a clean example of a pattern worth expecting. A path-traversal fix that validates names closes the door that was reported. The filesystem has other doors, symlinks and hard links among them, and they only show up when the check asks the operating system where a path really goes. When you see a Zip Slip fix in your dependencies, it's worth asking whether it checked names or real paths.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.