Whitespace in Sudo Commands
While configuring Buildbot to build Debian packages via pbuilder, I needed some way to give the buildworker user permission to run pbuilder build as root. pbuilder requires root in order to unpack (and remove) the build directory, set up a separate network namespace, and use the chroot(2) system call when actually invoking the build.
The most commonly used command in this situation is sudo, but it has some annoying shortcomings that I discovered while getting it to work with pbuilder.
Initial setup
First of all, we need to obtain the command that pbuilder is actually trying to run as root. By default it uses sudo, so all we have to do is attempt a build, and examine the system log with journalctl -p err -e -o cat SYSLOG_IDENTIFIER=sudo:
buildworker : user NOT in sudoers ; TTY=unknown ; PWD=/srv/buildworker/worker/igd-exporter/build ; USER=root ; COMMAND=/usr/sbin/pbuilder --execute --bindmounts /srv/buildworker/worker/igd-exporter -- /usr/lib/pbuilder/pdebuild-internal /srv/buildworker/worker/igd-exporter/build --debbuildopts --debbuildopts --uid 115 --gid 122 --pbuildersatisfydepends /usr/lib/pbuilder/pbuilder-satisfydepends
So far, so good. We can start by placing this command into the sudoers file:
Defaults: buildworker env_keep+=_DIST, env_keep+=BUILD_NUMBER buildworker ALL = (root:root) NOPASSWD:NOSETENV: /usr/sbin/pbuilder --execute --bindmounts /srv/buildworker/worker/igd-exporter -- /usr/lib/pbuilder/pdebuild-internal /srv/buildworker/worker/igd-exporter/build --debbuildopts --debbuildopts --uid 115 --gid 122 --pbuildersatisfydepends /usr/lib/pbuilder/pbuilder-satisfydepends
NOSETENV: prevents the user from being able to run the command with arbitrary environment variables and should be used wherever possible! In this case we use the env_keep option to permit the user to set a couple of environment variables required by the build.
We've already but up a Sudo limitation: it has no support for regular expressions, so simple wildcard matching is the only way to make one rule match multiple commands. However, wildcard matching is extremely dangerous in sudo so I never use it. Instead, I decided to duplicate the above user specification for each package built. It's not ideal, but I only have a few to deal with so it's not the end of the world.
The problem
I found that sudo continued to deny the buildworker user permission to run the command, logging buildworker : command not allowed ;.
After double-checking that I hadn't made any typos, I configured sudo debug logging and made the following discovery:
sudo[29378] -> cmnd_matches @ /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:338 sudo[29378] -> command_matches @ /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:396 sudo[29378] -> command_matches_normal @ /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:672 sudo[29378] -> command_args_match @ /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:365 sudo[29378] <- command_args_match @ /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:385 := false sudo[29378] <- command_matches_normal @ /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:700 := false sudo[29378] user command "/usr/sbin/pbuilder --execute --bindmounts /srv/buildworker/worker/igd-exporter -- /usr/lib/pbuilder/pdebuild-internal /srv/buildworker/worker/igd-exporter/build --debbuildopts --debbuildopts --uid 115 --gid 122 --pbuildersatisfydepends /usr/lib/pbuilder/pbuilder-satisfydepends" matches sudoers command "/usr/sbin/pbuilder --execute --bindmounts /srv/buildworker/worker/igd-exporter -- /usr/lib/pbuilder/pdebuild-internal /srv/buildworker/worker/igd-exporter/build --debbuildopts --debbuildopts --uid 115 --gid 122 --pbuildersatisfydepends /usr/lib/pbuilder/pbuilder-satisfydepends": false @ command_matches() /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:437 sudo[29378] <- command_matches @ /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:438 := false sudo[29378] <- cmnd_matches @ /build/sudo-3SUl34/sudo-1.8.10p3/plugins/sudoers/match.c:358 := -1
This log message shows both the command that the user tried to run, and the specification from the sudoers file against which it is being matched. Examining them closely, we can see the difference:
/usr/sbin/pbuilder --execute --bindmounts /srv/buildworker/worker/igd-exporter -- /usr/lib/pbuilder/pdebuild-internal /srv/buildworker/worker/igd-exporter/build --debbuildopts --debbuildopts --uid 115 --gid 122 --pbuildersatisfydepends /usr/lib/pbuilder/pbuilder-satisfydepends /usr/sbin/pbuilder --execute --bindmounts /srv/buildworker/worker/igd-exporter -- /usr/lib/pbuilder/pdebuild-internal /srv/buildworker/worker/igd-exporter/build --debbuildopts [- -]--debbuildopts [- -]--uid 115 --gid 122 --pbuildersatisfydepends /usr/lib/pbuilder/pbuilder-satisfydepends
Whitespace issues? That's odd, I would have expected sudo to compare each member of the user-provided argv against the result of splitting the command specification using the same rules as the shell. However, a quick look at match.d shows us that the user-provided command is passed as a plain string:
Ugh. Examining pbuilder's source code shows us that the empty arguments in the command it wants to run as root are unavoidable:
1 ${PBUILDERROOTCMD} \ 2 ${PDEBUILD_PBUILDER} \ 3 --execute \ 4 ${EXTRA_CONFIGFILE[@]/#/--configfile } \ 5 --bindmounts $(readlink -f ..) \ 6 "$@" \ 7 -- \ 8 /usr/lib/pbuilder/pdebuild-internal \ 9 ${PWD} \ 10 --debbuildopts "" \ 11 --debbuildopts "${DEBBUILDOPTS}" \ 12 --uid "${BUILDRESULTUID}" \ 13 --gid "${BUILDRESULTGID}" \ 14 --pbuildersatisfydepends "$PBUILDERSATISFYDEPENDSCMD"
This means that my original command spec would never have worked, because I did not indicate the presence of empty entries in the argument list when I wrote the spec; nor is there any way to specify the presence or absence of empty arguments within an argument in a command specification.
Wrapper script as a solution
I created a wrapper script which buildworker is allowed to run with any arguments:
Defaults: buildworker env_keep+=_DIST, env_keep+=BUILD_NUMBER buildworker ALL = (root:root) NOPASSWD:NOSETENV: /usr/local/bin/sudo-pbuilder
Another Sudo subtlety is that a command specification with no arguments allows the command to be run with any arguments. To really specify that no arguments may be provided, the specification should end with a single "" after the command.
The script checks the user-provided arguments against a list. Since the script can only be modified by root, it's safe to do whatever checks we want before executing the user-provided command.
1 #!/usr/bin/python3 2 3 import os 4 import re 5 import sys 6 7 spec = [ 8 'pbuilder', '--execute', 9 '--bindmounts', re.compile(r'/srv/buildworker/worker/[0-9A-Za-z_\-]+'), 10 '--', 11 '/usr/lib/pbuilder/pdebuild-internal', 12 re.compile(r'/srv/buildworker/worker/[0-9A-Za-z_\-]+/build'), 13 '--debbuildopts', '', '--debbuildopts', '', 14 '--uid', '115', '--gid', '122', 15 '--pbuildersatisfydepends', '/usr/lib/pbuilder/pbuilder-satisfydepends', 16 ] 17 18 def check_str(s, a): 19 if s != a: 20 print('Expected: {!r}\nReceived: {!r}'.format(s, a), file=sys.stderr) 21 sys.exit(1) 22 23 def check_re(s, a): 24 if not s.fullmatch(a): 25 print('Expected: match {!r}\nReceived: {!r}'.format(s, a), file=sys.stderr) 26 sys.exit(1) 27 28 def check_arg(s, a): 29 if isinstance(s, str): 30 check_str(s, a) 31 elif isinstance(s, re._pattern_type): 32 check_re(s, a) 33 else: 34 print('Unknown spec type: {!r}'.format(s), file=sys.stderr) 35 sys.exit(1) 36 37 def check_args(spec, args): 38 args = args.copy() 39 for s in spec: 40 try: 41 a = args.pop(0) 42 except IndexError: 43 print('Too few arguments passed to match {!r}'.format(s), file=sys.stderr) 44 sys.exit(1) 45 else: 46 check_arg(s, a) 47 48 if len(args) > 0: 49 print('Additional arguments passed: {!r}'.format(args), file=sys.stderr) 50 sys.exit(1) 51 52 if __name__ == '__main__': 53 check_args(spec, sys.argv[1:]) 54 os.execl('/usr/bin/cgexec', 'cgexec', '-g', 'net_cls:pbuilder', *sys.argv[1:]) 55 56 # vim: ts=8 sts=4 sw=4 et
This allows for a couple of extra enhancements:
- Regular expressions can be used to allow arguments to match files only within the build directory
We can run the user-provided command via cgexec which allows the firewall to recognize packets from the pbuilder chroot.
Finally we have to tell pbuilder to use this script when it wants to run something as root, rather than its default of sudo -E. This is done by running pdebuild with the following arguments:
--pbuilderroot 'sudo -n /usr/local/bin/sudo-pbuilder'
Other alternatives
I've been meaning to investigate other alternatives to Sudo for a while.
I like the simplicity of doas, but doas.conf, like sudoers, treats the command specification as a string rather than an argument vector; therefore there is similarly no way to specify an empty argument, or an argument containing whitespace.
I like that userv says that it is not a general-purpose tool for interactive use, but instead the administrator configures services that users may be permitted to run. I also like that it runs as a service, that forks to run programs on behalf of the client in a sanitized environment, rather than using setuid for privilege elevation.
remctl has a similar model to that of userv, but allows for commands to be run remotely.
Good old OpenSSH can also be used to allow a user to run a restricted set of commands, by making use of a carefully-crafted authorized_keys file; and it can obviously be used remotely too.
polkit, but would require quite a bit of setup and configuration unless pkexec is used, but pkexec relies on setuid for privilege escalation as well
Other options listed at Sudo Alternatives.