Traversing a path into the Home Assistant Supervisor API without a token
- Identifier
- CVE-2023-27482
- Software
- Home Assistant
- Affected
- Home Assistant Core before 2023.3.2 and Home Assistant Supervisor before
- Fixed in
- Home Assistant Core 2023.3.2 with Supervisor 2023.03.3
- Reported by
- Joseph Surin and Victor Kahan, elttam
- Disclosed
- 08 March 2023
NO_AUTH = re.compile(r"^(?:" r"|app/.*" r"|[store\/]*addons/[^/]+/(logo|icon)" r")$")That is the list of things you're allowed to fetch from a Home Assistant instance without logging in. It's a short list on purpose: the frontend's JavaScript bundle, and the little icons for add-ons. The browser needs both before there's a session to authenticate. Everything else needs a token.
Joseph Surin and Victor Kahan at elttam spent February and March of 2023 taking it apart. Their write-up follows one bypass turning into three. The filter was never the whole story. What happened on either side of it was. The result is CVE-2023-27482, and it affected about three quarters of all Home Assistant installations.
The Supervisor#
If you install Home Assistant on a Raspberry Pi using the image they publish, you don't get one program. You get a small appliance OS running Docker, with a container called the Supervisor sitting above everything else. The Supervisor installs and removes add-ons, takes backups and applies OS updates. It also hands out the Docker socket to add-ons that ask nicely. Home Assistant Core, the Python application you actually interact with, is just another container that the Supervisor manages.
The Supervisor has an HTTP API, and Core needs to call it. The settings pages in the web UI are how you install add-ons and take backups. So Core proxies. Here is the view:
class HassIOView(HomeAssistantView):
"""Hass.io view to handle base part."""
name = "api:hassio"
url = "/api/hassio/{path:.+}"
requires_auth = Falserequires_auth = False looks alarming and isn't, quite. Authentication is done by hand inside the handler so that the allow-list can exist:
async def _handle(
self, request: web.Request, path: str
) -> web.Response | web.StreamResponse:
"""Route data to Hass.io."""
hass = request.app["hass"]
if _need_auth(hass, path) and not request[KEY_AUTHENTICATED]:
return web.Response(status=HTTPStatus.UNAUTHORIZED)
return await self._command_proxy(path, request)_need_auth is where NO_AUTH gets matched. If the path matches, no token is required, and _command_proxy forwards it to the Supervisor. The Supervisor trusts whatever Core sends, because Core is the thing that's supposed to have done the checking.
So app/.* is unauthenticated. And app/../supervisor/info matches app/.*. And it isn't a path under app/.
That's the bug, all of it. The rest of the story is that Home Assistant had a path traversal filter, and getting the traversal past the filter took three tries.
While we're looking at that regex: [store\/]* is a character class, not a group. It matches any run of the characters s, t, o, r, e and /, in any order, including none of them. Somebody meant (store/)?. The bug doesn't turn on it. If you were auditing this file, it's the line that would make you slow down.
Attempt one: double encoding#
Core's HTTP integration has middleware that blocks obvious attack strings, path traversal included:
# File Injections
r"|(\.\.//?)+" # ../../anywhere
r"|[a-zA-Z0-9_]=/([a-z0-9_.]//?)+" # .html?v=/.//testIt runs that against request.path, which aiohttp gives you URL-decoded. Once. So app/.%2e/supervisor/info decodes to app/../supervisor/info and gets blocked. Encode it twice and app/.%252e/supervisor/info decodes to app/.%2e/supervisor/info, which contains no ../ and sails through.
The other end is what makes it work. _command_proxy builds the outbound request with aiohttp.ClientSession. The URL it passes goes through yarl, which unquotes what's left and then normalises the result. .%2e/ becomes ../ becomes gone, along with the segment before it. The Supervisor receives a request for /supervisor/info and answers it.
$ http http://192.168.1.20:8123/api/hassio/app/.%252e/supervisor/info
HTTP/1.1 200 OK
Content-Type: application/json
Server: Python/3.10 aiohttp/3.8.3
{
"data": {
"arch": "amd64",
...That's the whole exploit. No authentication anywhere in it. From there the write-up walks to root. Install the SSH & Web Terminal add-on over the same channel. POST to its security endpoint to set protected: false, POST a config with a password of your choosing, restart it, ssh in. The add-on runs privileged with /run/docker.sock mounted, so you are root on the host. Their transcript ends with docker container ls listing the victim's entire stack. Full backups are the quieter version of the same thing. A backup contains .storage/auth with the authentication keys in it, and secrets.yaml with everything else.
Attempt two: a tab character#
Home Assistant 2023.3.0 fixed the double encoding by decoding until the string stops changing:
def _recursive_unquote(value: str) -> str:
"""Handle values that are encoded multiple times."""
if (unquoted := unquote(value)) != value:
unquoted = _recursive_unquote(unquoted)
return unquotedReasonable patch. It closes the encoding trick. It leaves the actual flaw in place, which is that the string checked by the filter and the string sent by the client are produced by different code.
So Surin, who had found the original bug in February, went and read the client. yarl.URL calls urlsplit from the standard library, and urlsplit begins with this:
for b in _UNSAFE_URL_BYTES_TO_REMOVE:
url = url.replace(b, "")
scheme = scheme.replace(b, "")where _UNSAFE_URL_BYTES_TO_REMOVE = ['\t', '\r', '\n']. Tab, carriage return and newline are deleted from URLs, per the WHATWG spec, silently, before anything else happens.
The filter regex is looking for ... A tab is not a dot. app/.%09./core/info contains ., a tab, ., and recursive decoding doesn't change that. Then urlsplit throws the tab away and the client sends app/../.
$ http http://192.168.104.182:8123/api/hassio/app/.%09./core/info
HTTP/1.1 200 OKAttempt three: a forwarded header#
The next round of patches moved defence into the Supervisor itself. A middleware called core_proxy decides whether a request arriving from Core is legitimate. It makes that decision by reading headers:
for idx, (key, value) in enumerate(request.raw_headers):
if key in (b"Authorization", b"X-Hassio-Key"):
authorization_index = idx
elif key == b"Content-Type":
content_type_index = idx
elif key == b"X-Hass-User-ID":
user_request = True
elif key == b"X-Hass-Is-Admin":
admin_request = value == b"1"
elif key == b"X-Ingress-Path":
ingress_request = True
if user_request or admin_request:
return await handler(request)Core sets X-Hass-User-ID and X-Hass-Is-Admin on requests it makes on behalf of a logged-in user. To the Supervisor, seeing those headers means an authenticated human is behind the request.
Home Assistant has a second proxy, /api/hassio_ingress, which forwards browser traffic to add-ons that serve their own web UIs. It copies the user's headers through, and it decides which ones to drop with a denylist: content-length, content-encoding, transfer-encoding, a few websocket ones. Anything not on that list gets copied verbatim.
X-Hass-Is-Admin is not on that list.
$ http http://192.168.1.208:8123/api/hassio_ingress/.%09./supervisor/info X-Hass-Is-Admin:1
HTTP/1.1 200 OKVictor Kahan found that one on 26 March; Supervisor 2023.03.3 shipped on the 29th.
The mitigations landed in stages: Core 2023.3.0 on 1 March, Supervisor 2023.03.1 on the 8th, Core 2023.3.2 on the 9th, Supervisor 2023.03.2 on the 22nd, 2023.03.3 on the 29th. Anything at Core 2023.3.2 with Supervisor 2023.03.3 or later is clear.
Home Assistant's own disclosure post adds two points. Container and Core installations were never affected, since neither has a Supervisor to talk to. The Supervisor auto-updates, so most people got the fix without doing anything. Their analysis puts the flaw in Home Assistant since the Supervisor was introduced in 2017.
The three-quarters figure is not an estimate. It comes from Home Assistant's own opt-in integration analytics, which elttam quote at 74.9%.