Nightjar

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
python
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:

python
class HassIOView(HomeAssistantView):
    """Hass.io view to handle base part."""

    name = "api:hassio"
    url = "/api/hassio/{path:.+}"
    requires_auth = False

requires_auth = False looks alarming and isn't, quite. Authentication is done by hand inside the handler so that the allow-list can exist:

python
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:

python
# File Injections
r"|(\.\.//?)+"  # ../../anywhere
r"|[a-zA-Z0-9_]=/([a-z0-9_.]//?)+"  # .html?v=/.//test

It 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.

one request, four readings of the path client sends /api/hassio/app/.%252e/supervisor/info no token, no session Core checks app/.%2e/supervisor/info decoded once, no ../ found, matches app/.* so no token needed Core sends /supervisor/info yarl unquotes again, then normalises the ../ away Supervisor answers 200 OK, JSON body sees no traversal, and trusts Core to have checked
The string the filter inspects and the string the Supervisor receives are produced by different code, one unquoting apart.
text
$ 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:

python
def _recursive_unquote(value: str) -> str:
    """Handle values that are encoded multiple times."""
    if (unquoted := unquote(value)) != value:
        unquoted = _recursive_unquote(unquoted)
    return unquoted

Reasonable 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:

python
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/../.

app/.%09./core/info, after recursive unquoting app/ . \t . /core/info regex sees no ../ in this string, so the traversal filter passes it urlsplit deletes the tab app/ . . /core/info client sends app/../core/info normalised to /core/info before it leaves
The two dots are separated by one byte the filter counts and the URL parser removes.
text
$ http http://192.168.104.182:8123/api/hassio/app/.%09./core/info
HTTP/1.1 200 OK

Attempt 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:

python
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.

a header the browser chose X-Hass-Is-Admin: 1 set by the attacker ingress denylist content-length content-encoding transfer-encoding this header is not listed so it is copied verbatim admin_request = True handler runs, no auth
The ingress proxy drops a fixed list of headers and forwards the rest, including the one the Supervisor reads as proof of an admin.
text
$ http http://192.168.1.208:8123/api/hassio_ingress/.%09./supervisor/info X-Hass-Is-Admin:1
HTTP/1.1 200 OK

Victor 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%.

Sources

  1. 1elttam advisory: Home Assistant Supervisor authentication bypass write-upgithub.com
  2. 2Disclosure: Supervisor security vulnerabilityhome-assistant.io