fix: drop privileges with a helper of our own, not setpriv
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 1m26s

Ubuntu 18.04 does not ship setpriv. The program exists in util-linux 2.31,
but Debian only began installing it at 2.32, and this image is on bionic
because PyBitmessage is Python 2. The build assertion added in the last
commit caught it, which is what it is for: `/bin/sh: 1: setpriv: not found`,
exit 127, no image pushed.

drop_privs.py does the same work with what the image already has. It sets
no_new_privs, drops the bounding set with PR_CAPBSET_DROP while CAP_SETPCAP
is still held, then optionally becomes the daemon's user. Two shapes, both in
run.sh: keep four capabilities and stay root, for the supervisor; keep none
and become uid 2000, for the daemon and everything run on its behalf.

It is better than setpriv would have been in one respect. The bounding set is
walked up to the kernel's own cap_last_cap instead of a list of names, so a
capability this image has never heard of goes too -- and the "-all" spelling
that bionic's setpriv refuses under a newer kernel is not needed at all.

Verified in a user namespace, in the arrangement run.sh uses: the outer drop
leaves 00000000000001e0, the inner leaves every capability set at zero with
no_new_privs set. The build assertion checks the same two things.
This commit is contained in:
2026-09-09 12:33:18 +00:00
parent c66cea2009
commit 2f1b2afcb4
6 changed files with 186 additions and 61 deletions
+2 -2
View File
@@ -167,8 +167,8 @@ Container images are configured using parameters passed at runtime.
before anything else. The moment that is done the script re-executes itself 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 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 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, rest -- and every start of the daemon goes through `drop_privs.py`, at uid
with all four capability sets empty and `no_new_privs` on. No file in the 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 image carries a setuid or setgid bit, so a daemon that has been taken over has
nothing left to climb. nothing left to climb.
+2 -2
View File
@@ -167,8 +167,8 @@ docker run -d \
bounding set из четырёх capability — `SETUID` и `SETGID`, чтобы запускать bounding set из четырёх capability — `SETUID` и `SETGID`, чтобы запускать
демона под его собственным пользователем, `KILL` для запасной остановки и демона под его собственным пользователем, `KILL` для запасной остановки и
`SETPCAP`, чтобы снять остальные, — а каждый запуск демона идёт через `SETPCAP`, чтобы снять остальные, — а каждый запуск демона идёт через
`setpriv`: uid 2000, все четыре набора capability пустые, `no_new_privs` `drop_privs.py`: uid 2000, все четыре набора capability пустые,
включён. Ни на одном файле образа нет бита setuid или setgid, так что `no_new_privs` включён. Ни на одном файле образа нет бита setuid или setgid, так что
захваченному демону не по чему подниматься. захваченному демону не по чему подниматься.
Три вещи может задать только вызывающая сторона, и все три стоит задать. Три вещи может задать только вызывающая сторона, и все три стоит задать.
+21 -25
View File
@@ -60,14 +60,15 @@ COPY --from=0 /usr/local/ /usr/local/
COPY ./docker/healthy_check.py /usr/local/bin/ COPY ./docker/healthy_check.py /usr/local/bin/
COPY ./docker/seed_addr_gen.py /usr/local/bin/ COPY ./docker/seed_addr_gen.py /usr/local/bin/
COPY ./docker/watchdog.py /usr/local/bin/ COPY ./docker/watchdog.py /usr/local/bin/
COPY ./docker/drop_privs.py /usr/local/bin/
COPY ./docker/run.sh /usr/local/bin/ COPY ./docker/run.sh /usr/local/bin/
# Install dependencies. util-linux carries setpriv, which run.sh uses to drop # Install dependencies. gosu is gone: run.sh drops privileges through
# to the daemon's user; gosu, which used to do that, is gone because it dropped # drop_privs.py, which does the whole job rather than only the user and needs
# the user and nothing else, and moreutils went with it because nothing in this # nothing beyond the Python already here. moreutils went with it -- nothing in
# repository ever called any of its tools. # this repository ever called any of its tools.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -yq --no-install-suggests --no-install-recommends python-setuptools util-linux \ && apt-get install -yq --no-install-suggests --no-install-recommends python-setuptools \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Create a user # Create a user
@@ -79,26 +80,20 @@ WORKDIR ${HOME}
# Generate default config # Generate default config
RUN su bitmessage -c "pybitmessage -t" RUN su bitmessage -c "pybitmessage -t"
# Prove that the two setpriv lines in run.sh do what the script leans on, in # Prove that the two privilege drops in run.sh do what the script leans on, in
# the arrangement run.sh actually uses: the supervisor's set on the outside, the # the arrangement run.sh uses: the supervisor's on the outside, the daemon's on
# daemon's on the inside. This base image and the PyBitmessage clone above are # the inside. This base image and the PyBitmessage clone above are both
# both unpinned, so an option that quietly changed meaning would otherwise ship # unpinned, so a helper that quietly stopped working would otherwise ship as a
# as a container that looks confined and is not -- the trap the sql_timeout # container that looks confined and is not -- the trap the sql_timeout greps in
# greps in the first stage avoid the same way, by failing the build instead. # the first stage avoid the same way, by failing the build instead.
# #
# The two lists are the ones run.sh carries, and they have to stay in step with # Expected: the daemon at uid 2000 with every capability set empty and
# it. Expected: the daemon at uid 2000 with every capability set empty and
# no_new_privs on, and the supervisor holding 00000000000001e0 -- CAP_KILL, # no_new_privs on, and the supervisor holding 00000000000001e0 -- CAP_KILL,
# CAP_SETGID, CAP_SETUID and CAP_SETPCAP, bits 5 to 8, and nothing else. # CAP_SETGID, CAP_SETUID and CAP_SETPCAP, bits 5 to 8, and nothing else.
RUN set -eu \ RUN set -eu \
&& none="-chown,-dac_override,-fowner,-fsetid,-kill,-setgid,-setuid" \ && drop=/usr/local/bin/drop_privs.py \
&& none="$none,-setpcap,-net_bind_service,-net_raw,-sys_chroot" \ && d="$(python "$drop" --keep=kill,setgid,setuid,setpcap -- \
&& none="$none,-mknod,-audit_write,-setfcap" \ python "$drop" --user -- \
&& sup="-chown,-dac_override,-fowner,-fsetid,-net_bind_service" \
&& sup="$sup,-net_raw,-sys_chroot,-mknod,-audit_write,-setfcap" \
&& d="$(setpriv --bounding-set="$sup" \
setpriv --reuid=2000 --regid=2000 --clear-groups --bounding-set="$none" \
--no-new-privs \
grep -E '^(Uid|Gid|CapPrm|CapEff|CapBnd|NoNewPrivs):' /proc/self/status)" \ grep -E '^(Uid|Gid|CapPrm|CapEff|CapBnd|NoNewPrivs):' /proc/self/status)" \
&& printf '%s\n' "$d" \ && printf '%s\n' "$d" \
&& printf '%s\n' "$d" | grep -qE '^Uid:[[:space:]]+2000[[:space:]]+2000[[:space:]]+2000' \ && printf '%s\n' "$d" | grep -qE '^Uid:[[:space:]]+2000[[:space:]]+2000[[:space:]]+2000' \
@@ -107,16 +102,17 @@ RUN set -eu \
&& printf '%s\n' "$d" | grep -qE '^CapEff:[[:space:]]+0{16}$' \ && printf '%s\n' "$d" | grep -qE '^CapEff:[[:space:]]+0{16}$' \
&& printf '%s\n' "$d" | grep -qE '^CapBnd:[[:space:]]+0{16}$' \ && printf '%s\n' "$d" | grep -qE '^CapBnd:[[:space:]]+0{16}$' \
&& printf '%s\n' "$d" | grep -qE '^NoNewPrivs:[[:space:]]+1$' \ && printf '%s\n' "$d" | grep -qE '^NoNewPrivs:[[:space:]]+1$' \
&& s="$(setpriv --bounding-set="$sup" grep -E '^CapBnd:' /proc/self/status)" \ && s="$(python "$drop" --keep=kill,setgid,setuid,setpcap -- \
grep -E '^CapBnd:' /proc/self/status)" \
&& printf '%s\n' "$s" \ && printf '%s\n' "$s" \
&& printf '%s\n' "$s" | grep -qE '^CapBnd:[[:space:]]+0{13}1e0$' && printf '%s\n' "$s" | grep -qE '^CapBnd:[[:space:]]+0{13}1e0$'
# Nothing here needs a setuid or setgid bit at run time, and every one of them # Nothing here needs a setuid or setgid bit at run time, and every one of them
# is a way back for a daemon that has been taken over -- the more so because the # is a way back for a daemon that has been taken over -- the more so because the
# daemon now runs with no_new_privs, which makes them the one thing that could # daemon now runs with no_new_privs, which makes them the one thing that could
# still have raised its privileges. setpriv is not among them: it is an ordinary # still have raised its privileges. The privilege drop is not among them: it is
# binary that root execs. Strip them all, then insist none is left, so a package # an ordinary script that root runs. Strip them all, then insist none is left,
# added here later cannot bring one back unnoticed. # so a package added here later cannot bring one back unnoticed.
RUN find / -xdev -type f -perm /6000 -exec chmod -s {} + \ RUN find / -xdev -type f -perm /6000 -exec chmod -s {} + \
&& [ -z "$(find / -xdev -type f -perm /6000)" ] && [ -z "$(find / -xdev -type f -perm /6000)" ]
Binary file not shown.
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/python
"""Drop privileges, then exec -- the one errand run.sh cannot do in shell.
Two shapes, and both of them are in run.sh:
drop_privs.py --keep=kill,setgid,setuid,setpcap -- sh /usr/local/bin/run.sh
drop_privs.py --user -- pybitmessage -d
The first stays root and shrinks the capability bounding set to what starting
and stopping the daemon takes. Nothing execed below it can hold more than that
for the rest of the container's life. The second keeps nothing and becomes the
daemon's own user: an empty bounding set, no capability in any set, and
no_new_privs, so not even a setuid binary could raise it -- and after the strip
in the Dockerfile the image has none left anyway.
setpriv would have been the obvious tool for this. Ubuntu 18.04 does not have
it: the program exists in util-linux 2.31 upstream, but Debian only began
installing it at 2.32, and this image is built on bionic because PyBitmessage is
Python 2. gosu, which the image used before, only ever changed the user.
The bounding set is walked up to the kernel's own cap_last_cap rather than a
list written down here, so a capability this image has never heard of is dropped
just the same. Everything that fails is fatal: a privilege drop that half worked
would leave a container looking confined and not being it.
"""
import ctypes
import os
import sys
PR_CAPBSET_DROP = 24
PR_SET_NO_NEW_PRIVS = 38
# Only the ones a caller here ever keeps. A number is accepted too, for a
# capability this table does not name.
CAP_NUMBERS = {
"kill": 5,
"setgid": 6,
"setuid": 7,
"setpcap": 8,
}
USAGE = "usage: drop_privs.py [--user] [--keep=cap,...] -- program [args]"
def die(message):
sys.stderr.write("drop_privs: %s\n" % message)
sys.exit(1)
libc = ctypes.CDLL("libc.so.6", use_errno=True)
def prctl(option, arg2):
ctypes.set_errno(0)
if libc.prctl(option, arg2, 0, 0, 0) != 0:
die("prctl(%d, %d): %s"
% (option, arg2, os.strerror(ctypes.get_errno())))
def cap_last_cap():
"""The highest capability this kernel knows.
Read rather than guessed, and a failure to read is fatal: a guess that came
in low would silently leave the capabilities above it in the bounding set.
"""
try:
handle = open("/proc/sys/kernel/cap_last_cap")
except IOError as exc:
die("cannot open /proc/sys/kernel/cap_last_cap: %s" % exc)
try:
try:
return int(handle.read().strip())
except ValueError as exc:
die("cannot read /proc/sys/kernel/cap_last_cap: %s" % exc)
finally:
handle.close()
def parse_keep(value):
keep = set()
for name in value.split(","):
name = name.strip()
if not name:
continue
if name in CAP_NUMBERS:
keep.add(CAP_NUMBERS[name])
else:
try:
keep.add(int(name))
except ValueError:
die("unknown capability %r" % name)
return keep
def main(argv):
keep = set()
become_user = False
args = argv[1:]
while args:
if args[0] == "--":
args = args[1:]
break
elif args[0] == "--user":
become_user = True
args = args[1:]
elif args[0].startswith("--keep="):
keep |= parse_keep(args[0][len("--keep="):])
args = args[1:]
else:
die("unknown option %r\n%s" % (args[0], USAGE))
if not args:
die("nothing to execute\n%s" % USAGE)
# Before anything else, so that everything below inherits it.
prctl(PR_SET_NO_NEW_PRIVS, 1)
# Dropping from the bounding set takes CAP_SETPCAP, which is still held
# here; it does not touch the sets this process is using, so the uid change
# below still works after every capability has gone out of the set.
for cap in range(0, cap_last_cap() + 1):
if cap not in keep:
prctl(PR_CAPBSET_DROP, cap)
if become_user:
uid = int(os.environ.get("USER_UID", "2000"))
gid = int(os.environ.get("USER_GID", "2000"))
# Groups first, then gid, then uid: each of the three needs a
# privilege the next one gives up.
try:
os.setgroups([])
os.setresgid(gid, gid, gid)
os.setresuid(uid, uid, uid)
except OSError as exc:
die("cannot become %d:%d: %s" % (uid, gid, exc))
try:
os.execvp(args[0], args)
except OSError as exc:
die("cannot execute %r: %s" % (args[0], exc))
if __name__ == "__main__":
main(sys.argv)
+14 -32
View File
@@ -118,39 +118,19 @@ esc() {
printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
} }
# The fourteen capabilities Docker hands a container by default, written out
# rather than as "-all". The keyword is unusable in this image: setpriv refuses
# it whenever the running kernel knows more capabilities than the headers
# setpriv was built against, and Ubuntu 18.04 (CAP_LAST_CAP 37) under a current
# kernel (40) is exactly that case -- it would exit 127 with "libcap-ng is too
# old". A capability a compose file adds beyond the default therefore survives,
# which is the honest behaviour: nothing here silently drops what it was never
# told about. Names carry no cap_ prefix; that spelling is a parse error.
BM_CAPS_NONE="-chown,-dac_override,-fowner,-fsetid,-kill,-setgid,-setuid"
BM_CAPS_NONE="$BM_CAPS_NONE,-setpcap,-net_bind_service,-net_raw,-sys_chroot"
BM_CAPS_NONE="$BM_CAPS_NONE,-mknod,-audit_write,-setfcap"
# The same list less the four the supervisor keeps: SETUID and SETGID start the
# daemon as its own user, KILL is the fallback stop, and SETPCAP is what lets
# the daemon's own line above run at all -- dropping a bounding set takes it.
BM_CAPS_SUPERVISOR="-chown,-dac_override,-fowner,-fsetid,-net_bind_service"
BM_CAPS_SUPERVISOR="$BM_CAPS_SUPERVISOR,-net_raw,-sys_chroot,-mknod"
BM_CAPS_SUPERVISOR="$BM_CAPS_SUPERVISOR,-audit_write,-setfcap"
# Run something as the daemon's own user with nothing left to escalate with: no # Run something as the daemon's own user with nothing left to escalate with: no
# capability in any set, an empty bounding set so none can ever be picked up # capability in any set, an empty bounding set so none can ever be picked up
# again, and no_new_privs so that a setuid binary could not help either. This # again, and no_new_privs so that a setuid binary could not help either. This
# replaces the earlier privilege drop, which changed the user and left # replaces the earlier privilege drop, which changed the user and left
# everything else alone. # everything else alone. What the helper does, and why it is a helper rather
# than setpriv, is at the top of drop_privs.py.
# #
# The daemon is the only thing in this container exposed to the network, and # The daemon is the only thing in this container exposed to the network, and
# this line is the whole of what it gets. The AppArmor profile this repository # this line is the whole of what it gets. The AppArmor profile this repository
# carried until 2022 agrees: the two capabilities it granted served the # carried until 2022 agrees: the two capabilities it granted served the
# AppImage's fuse mount, and PyBitmessage itself asked for none. # AppImage's fuse mount, and PyBitmessage itself asked for none.
as_bitmessage() { as_bitmessage() {
setpriv --reuid="${USER_UID:-2000}" --regid="${USER_GID:-2000}" \ /usr/bin/python /usr/local/bin/drop_privs.py --user -- "$@"
--clear-groups --bounding-set="$BM_CAPS_NONE" \
--no-new-privs "$@"
} }
# --- everything that needs root, and the moment root stops being needed ---- # --- everything that needs root, and the moment root stops being needed ----
@@ -164,13 +144,14 @@ as_bitmessage() {
# every start; without FOWNER the chmod stops the container outright. # every start; without FOWNER the chmod stops the container outright.
# #
# So the block ends by re-executing this file with a bounding set that holds # So the block ends by re-executing this file with a bounding set that holds
# none of the three. What comes back needs SETUID and SETGID to start the daemon # none of the three. What comes back keeps four capabilities: SETUID and SETGID
# as its own user, and KILL for the supervisor's fallback stop -- three of the # to start the daemon as its own user, KILL for the supervisor's fallback stop,
# fourteen capabilities Docker hands a container by default, and that is all # and SETPCAP because emptying the daemon's own bounding set takes it. Four of
# there is for the rest of its life, whatever a compromised daemon manages to # the fourteen Docker hands a container by default, and that is all there is for
# execute. The second pass runs the preamble above again: everything it computes # the rest of its life, whatever a compromised daemon manages to execute. The
# is either read back from an exported variable or recomputed identically, and # second pass runs the preamble above again: everything it computes is either
# the random seed phrase, which is neither, was exported on the first pass. # read back from an exported variable or recomputed identically, and the random
# seed phrase, which is neither, was exported on the first pass.
if [ -z "${BM_SUPERVISED:-}" ] if [ -z "${BM_SUPERVISED:-}" ]
then then
# A bind mount hands keys.dat over with the host's ownership on it. # A bind mount hands keys.dat over with the host's ownership on it.
@@ -268,10 +249,11 @@ then
# Nothing below ever touches another user's file again, so root keeps # Nothing below ever touches another user's file again, so root keeps
# only what starting and stopping the daemon takes. The marker is what # only what starting and stopping the daemon takes. The marker is what
# stops the second pass repeating this block; the Dockerfile checks # stops the second pass repeating this block; the Dockerfile checks
# what both of these setpriv lines actually leave behind. # what both of these two drops actually leave behind.
BM_SUPERVISED=1 BM_SUPERVISED=1
export BM_SUPERVISED export BM_SUPERVISED
exec setpriv --bounding-set="$BM_CAPS_SUPERVISOR" /bin/sh "$0" exec /usr/bin/python /usr/local/bin/drop_privs.py \
--keep=kill,setgid,setuid,setpcap -- /bin/sh "$0"
fi fi
# generate address from seed # generate address from seed