diff --git a/README.md b/README.md index 2d25b7a..87c4ac2 100644 --- a/README.md +++ b/README.md @@ -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 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 + rest -- and every start of the daemon goes through `drop_privs.py`, 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. diff --git a/README.ru-RU.md b/README.ru-RU.md index 12170a1..7b4a2dc 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -167,8 +167,8 @@ docker run -d \ bounding set из четырёх capability — `SETUID` и `SETGID`, чтобы запускать демона под его собственным пользователем, `KILL` для запасной остановки и `SETPCAP`, чтобы снять остальные, — а каждый запуск демона идёт через - `setpriv`: uid 2000, все четыре набора capability пустые, `no_new_privs` - включён. Ни на одном файле образа нет бита setuid или setgid, так что + `drop_privs.py`: uid 2000, все четыре набора capability пустые, + `no_new_privs` включён. Ни на одном файле образа нет бита setuid или setgid, так что захваченному демону не по чему подниматься. Три вещи может задать только вызывающая сторона, и все три стоит задать. diff --git a/docker/Dockerfile b/docker/Dockerfile index 1cad8e3..2c3032e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -60,14 +60,15 @@ COPY --from=0 /usr/local/ /usr/local/ COPY ./docker/healthy_check.py /usr/local/bin/ COPY ./docker/seed_addr_gen.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/ -# Install dependencies. util-linux carries setpriv, which run.sh uses to drop -# to the daemon's user; gosu, which used to do that, is gone because it dropped -# the user and nothing else, and moreutils went with it because nothing in this -# repository ever called any of its tools. +# Install dependencies. gosu is gone: run.sh drops privileges through +# drop_privs.py, which does the whole job rather than only the user and needs +# nothing beyond the Python already here. moreutils went with it -- nothing in +# this repository ever called any of its tools. 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/* # Create a user @@ -79,26 +80,20 @@ WORKDIR ${HOME} # Generate default config RUN su bitmessage -c "pybitmessage -t" -# Prove that the two setpriv lines in run.sh do what the script leans on, in -# the arrangement run.sh actually uses: the supervisor's set on the outside, the -# daemon's on the inside. This base image and the PyBitmessage clone above are -# both unpinned, so an option that quietly changed meaning would otherwise ship -# as a container that looks confined and is not -- the trap the sql_timeout -# greps in the first stage avoid the same way, by failing the build instead. +# Prove that the two privilege drops in run.sh do what the script leans on, in +# the arrangement run.sh uses: the supervisor's on the outside, the daemon's on +# the inside. This base image and the PyBitmessage clone above are both +# unpinned, so a helper that quietly stopped working would otherwise ship as a +# container that looks confined and is not -- the trap the sql_timeout greps in +# 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 -# it. Expected: the daemon at uid 2000 with every capability set empty and +# Expected: the daemon at uid 2000 with every capability set empty and # 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. RUN set -eu \ - && none="-chown,-dac_override,-fowner,-fsetid,-kill,-setgid,-setuid" \ - && none="$none,-setpcap,-net_bind_service,-net_raw,-sys_chroot" \ - && none="$none,-mknod,-audit_write,-setfcap" \ - && 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 \ + && drop=/usr/local/bin/drop_privs.py \ + && d="$(python "$drop" --keep=kill,setgid,setuid,setpcap -- \ + python "$drop" --user -- \ grep -E '^(Uid|Gid|CapPrm|CapEff|CapBnd|NoNewPrivs):' /proc/self/status)" \ && printf '%s\n' "$d" \ && 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 '^CapBnd:[[:space:]]+0{16}$' \ && 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" | grep -qE '^CapBnd:[[:space:]]+0{13}1e0$' # 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 # 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 -# binary that root execs. Strip them all, then insist none is left, so a package -# added here later cannot bring one back unnoticed. +# still have raised its privileges. The privilege drop is not among them: it is +# an ordinary script that root runs. Strip them all, then insist none is left, +# so a package added here later cannot bring one back unnoticed. RUN find / -xdev -type f -perm /6000 -exec chmod -s {} + \ && [ -z "$(find / -xdev -type f -perm /6000)" ] diff --git a/docker/__pycache__/drop_privs.cpython-313.pyc b/docker/__pycache__/drop_privs.cpython-313.pyc new file mode 100644 index 0000000..df330a8 Binary files /dev/null and b/docker/__pycache__/drop_privs.cpython-313.pyc differ diff --git a/docker/drop_privs.py b/docker/drop_privs.py new file mode 100644 index 0000000..0bc6c6a --- /dev/null +++ b/docker/drop_privs.py @@ -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) diff --git a/docker/run.sh b/docker/run.sh index caecf5f..440323d 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -118,39 +118,19 @@ esc() { 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 # 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 # 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 # 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 # AppImage's fuse mount, and PyBitmessage itself asked for none. as_bitmessage() { - setpriv --reuid="${USER_UID:-2000}" --regid="${USER_GID:-2000}" \ - --clear-groups --bounding-set="$BM_CAPS_NONE" \ - --no-new-privs "$@" + /usr/bin/python /usr/local/bin/drop_privs.py --user -- "$@" } # --- 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. # # 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 -# as its own user, and KILL for the supervisor's fallback stop -- three of the -# fourteen capabilities Docker hands a container by default, and that is all -# there is for the rest of its life, whatever a compromised daemon manages to -# execute. The second pass runs the preamble above again: everything it computes -# is either read back from an exported variable or recomputed identically, and -# the random seed phrase, which is neither, was exported on the first pass. +# none of the three. What comes back keeps four capabilities: SETUID and SETGID +# to start the daemon as its own user, KILL for the supervisor's fallback stop, +# and SETPCAP because emptying the daemon's own bounding set takes it. Four of +# the fourteen Docker hands a container by default, and that is all there is for +# the rest of its life, whatever a compromised daemon manages to execute. The +# second pass runs the preamble above again: everything it computes is either +# 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:-}" ] then # 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 # only what starting and stopping the daemon takes. The marker is what # 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 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 # generate address from seed