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.
148 lines
4.5 KiB
Python
148 lines
4.5 KiB
Python
#!/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)
|