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-30973 | CVE-2026-103975 | |
|---|---|---|
| Vector | An entry named with ../ | A symlink entry, then a file written through it |
| Entry names | Contain .. | Clean: no .., not absolute |
| What was missing | The bounds check built an error but never threw it | Name checks don't resolve symlinks; link targets weren't checked |
Fixed in @appium/support | 7.0.6 (8 March) | 7.2.6 (25 July) |
| Repository advisory | 9 March | 17 August |
| GitHub advisory database | 11 March | 15 October |
| Severity | Medium, CVSS 3.1 score 6.5 | Medium, CVSS 3.1 score 6.5 |
| Reported by | bugbunny-research | nikkoenggaliano |
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:
- Parents are checked for real. Before creating or writing anything, the extractor walks each directory segment between the root and the destination, calling
lstatandrealpath, and rejects the entry if any real path falls outside the root. - 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.
- 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
zipfiledoesn't create symlinks at all. It writesassetsas a regular file holding the link text, so the second entry fails. That's safe, if surprising. - Python's
tarfileblocks the link outright. Since Python 3.14,extractalluses thedatafilter by default, which refuses links that point outside the destination. Withfilter='fully_trusted'it writes straight through. On earlier versions that support filters, such as 3.12 and 3.13, passfilter='data'explicitly. Our tarfile post covers the history. - Info-ZIP
unzipandbsdtarrefuse to write through the link, but both leave the symlink itself in place, pointing outside the folder. Anything that later writes intoout/assetswill follow it.unzipdefers creating links until the end;bsdtarrefuses 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
- Upgrade to Appium 3.6.0 or later, which bundles
@appium/support7.2.6 or newer, and update drivers and plugins. - Check every copy with the script above or
npm ls @appium/support, and remove direct pins to old versions. - 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.
- 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.
- Review extraction flags in scripts.
bsdtar -P,tar --absolute-namesand Python'sfully_trustedfilter 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.