CVE-2026-64825Critical (CVSS 9.3)Fixed4 min read
Unauthenticated root file write via Home Assistant's onboarding backup upload
The onboarding backup-upload route stayed unauthenticated during a fresh install's setup window, and the destination path was built from a name field inside the uploaded archive. An unauthenticated network client could write a file to any directory the Home Assistant process could reach, as root in the default Docker image.
- Vendor
- Home Assistant
- Product
- Core (Onboarding backup upload)
- Weakness
- CWE-22
- Affected
- < 2026.6.0
- Fixed in
- 2026.6.0
- Advisory
- GHSA-5hxg-r395-fqxx
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:L
Disclosure timeline
May 22, 2026
Reported privately to the Home Assistant security team, with an end-to-end PoC verified against Core 2026.5.4.
May 28, 2026
Fix merged upstream: PR #172368 validated the backup name field, shipping in Core 2026.6.0.
Jul 21, 2026
Advisory GHSA-5hxg-r395-fqxx published and CVE-2026-64825 assigned.
Home Assistant's onboarding flow is deliberately unauthenticated: a fresh install has no admin account yet, so the first setup screens have to work before any credential exists. One of the routes reachable in that window accepts a backup archive and stores it for later restore. The path where it gets stored was built from a field inside the archive itself, and nothing stopped that field from being an absolute path pointing anywhere on disk.
The fix
Core 2026.6.0 validates the backup name at both sinks that build a path from it (PR #172368). A prior fix, #167211, had already closed the same class of bug for the multipart upload's filename, but only by checking the Content-Disposition header. The inner backup.json name field is a separate attacker-controlled input on the same code path, and it wasn't covered. #172368 adds a containment check in CoreLocalBackupAgent.get_new_backup_path as defense in depth, covering both the upload receive path and the websocket-driven backup-generate path.
The bug
POST /api/onboarding/backup/upload is registered by homeassistant/components/backup/onboarding.py:
class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView):
...
NoAuthBaseOnboardingView sets requires_auth = False. The only gate is a decorator that checks if self._data["done"]: raise HTTPUnauthorized. On a fresh install, homeassistant/components/onboarding/__init__.py initializes that state as data = {"done": []}, an empty list, which is falsy in Python. The check that's supposed to close the window once setup is complete never fires on the install state it's meant to guard.
Past that gate, the destination path comes from backup.json's name field, read inside the uploaded tar:
name = cast(str, data["name"])
That value flows through suggested_filename_from_name_date, which joins whitespace-separated tokens with _ and does nothing else, no separator stripping, no absolute-path rejection, into get_new_backup_path:
def get_new_backup_path(self, backup):
return self._backup_dir / suggested_filename(backup)
The / there is pathlib.Path.__truediv__, and it has a sharp edge: when the right-hand operand is absolute, it discards the left one entirely.
>>> Path("/config/backups") / "/etc/foo"
PosixPath("/etc/foo")
Code that reads as "build a path inside my backup directory" actually means "let the archive's own metadata choose the directory if it wants to." make_backup_dir then runs path.mkdir(exist_ok=True) on whatever that resolved to, and shutil.move writes the uploaded tar there.
Exploitation
Tested end to end against ghcr.io/home-assistant/home-assistant:2026.5.4, where the HA process runs as uid=0(root).
First, confirm the window is open:
$ curl -sS http://target:8123/api/onboarding
[{"step":"user","done":false},{"step":"core_config","done":false}, ...]
And confirm normal backup endpoints do enforce auth, as a control:
$ curl -sS -X POST http://target:8123/api/backup/upload -w 'HTTP %{http_code}\n'
401: Unauthorized
Then upload a tar whose inner backup.json sets name to an absolute path, no Authorization header, no cookie, no API key:
$ python3 exploit.py http://target:8123 /etc/HA-PWNED-ETC
[*] onboarding state: data['done'] empty, window open
[*] HTTP 201
[*] body: {"backup_id":"933f7c0c"}
$ docker exec ha-target ls -la /etc/ | grep PWNED
-rw-r--r-- 1 root root 10240 May 23 05:42 HA-PWNED-ETC_2026-05-23_05.42_00000000.tar
The same primitive landed root-owned files at five distinct locations in testing: /tmp, /etc, /usr/local/bin, /config/.storage, and a path shaped to collide with HA's own auth-provider state file. A forced _<date>_<time>_<random>.tar suffix is appended to the name, so a single request can't overwrite an exact existing filename, but the directory and the file's contents are entirely the attacker's choice.
Impact
An unauthenticated network client, reachable at whatever address HA's setup wizard advertises on the LAN (or publicly, for installs fronted by cloud ingress before onboarding finishes), gets a write primitive into any directory the HA process can reach. On its own that's disk exhaustion and persistent contamination of /config. Composed with the sibling bug in Home Assistant's backup-restore extraction, it's the pre-auth leg of a full root code-execution chain: this bug is how an attacker with no credentials at all gets their tar onto disk in the first place; the other bug is what happens when that tar is later restored.
If you run Home Assistant
Upgrade to Core 2026.6.0 or later. If you're pinned to an older release, don't leave a fresh install's onboarding window reachable from a network you don't trust, HA advertises itself via zeroconf, so an unfinished setup is easy to find on a LAN.