feat: the daemon keeps no privilege, and the container drops what it can
Build docker image and push to registry.bitdeals.org / main-build-job (push) Failing after 3m41s

PyBitmessage is a Python 2 daemon parsing untrusted data from an open port,
so the question is not whether it can be taken over but what is left once it
has been. Until now: uid 2000, but the full fourteen capabilities Docker
hands a container in the bounding set, no_new_privs off, and a root PID 1
holding all fourteen for the life of the container.

run.sh now needs root exactly once. keys.dat can arrive from a bind mount
owned by anyone, so it is chowned, given mode 600 and read first; the block
that does it ends by re-executing the file with a bounding set of four --
SETUID and SETGID to start the daemon as its own user, KILL for the fallback
stop, SETPCAP to drop the rest. Every start of the daemon then goes through
setpriv rather than gosu: uid 2000, every capability set empty, no_new_privs
on. gosu changed the user and left everything else alone; moreutils went with
it, nothing here ever called any of its tools.

No file in the image carries a setuid or setgid bit any more, which is what
makes no_new_privs worth having: there is nothing left to climb.

Both setpriv lines are checked at build time, in the arrangement run.sh uses,
against uid, capability sets and no_new_privs read back from /proc. The base
image and the PyBitmessage clone are both unpinned, so an option that
quietly changed meaning would otherwise ship as a container that looks
confined and is not. Two things that check caught while it was being written:
Ubuntu 18.04's setpriv refuses the "all" keyword under a kernel that knows
more capabilities than its headers did (40 against 37), and capability names
there carry no cap_ prefix. Hence the lists written out by hand.

A caller that sets cap_drop: ALL now needs seven back, not six: SETPCAP joins
CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID and KILL, because dropping a
bounding set takes it. The example compose file and both READMEs say so, and
say what else only a caller can set: read_only with tmpfs, and pids_limit.
This commit is contained in:
2026-09-09 10:29:32 +00:00
parent 88b5b896e1
commit c66cea2009
5 changed files with 253 additions and 86 deletions
+18
View File
@@ -161,3 +161,21 @@ Container images are configured using parameters passed at runtime.
practical risk is resource exhaustion rather than code execution. Keep
`BITMESSAGE_MAXTOTALCONNECTIONS` low and put memory and CPU limits on the
container.
- **The daemon holds no privilege of its own, and the container drops what it
can.** `run.sh` needs root exactly once, at startup: `keys.dat` can arrive
from a bind mount owned by anyone, so it is chowned, given mode 600 and read
before anything else. The moment that is done the script re-executes itself
with a bounding set of four capabilities -- `SETUID` and `SETGID` to start the
daemon as its own user, `KILL` for the fallback stop, `SETPCAP` to drop the
rest -- and every start of the daemon goes through `setpriv`, at uid 2000,
with all four capability sets empty and `no_new_privs` on. No file in the
image carries a setuid or setgid bit, so a daemon that has been taken over has
nothing left to climb.
Three things only the caller can set, and all three are worth setting.
`cap_drop: ALL` with `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID`, `SETGID`,
`KILL` and `SETPCAP` added back: those seven are what the startup above needs,
and the container cannot narrow what the healthcheck and `docker exec` run
with -- only this can. `read_only: true`, with `tmpfs` for `/tmp` and `/run`;
the daemon writes only into its own home. And `pids_limit`, next to the memory
and CPU limits the note above asks for.