#!/usr/bin/env python3 # Copyright 2021 Simon McVittie # SPDX-License-Identifier: LGPL-2.0-or-later import errno import logging import os import subprocess import sys import tempfile import termios import unittest try: import seccomp except ImportError: print('1..0 # SKIP cannot import seccomp Python module') sys.exit(0) # This is the @default set from systemd as of 2021-10-11 DEFAULT_SET = set(''' brk cacheflush clock_getres clock_getres_time64 clock_gettime clock_gettime64 clock_nanosleep clock_nanosleep_time64 execve exit exit_group futex futex_time64 get_robust_list get_thread_area getegid getegid32 geteuid geteuid32 getgid getgid32 getgroups getgroups32 getpgid getpgrp getpid getppid getrandom getresgid getresgid32 getresuid getresuid32 getrlimit getsid gettid gettimeofday getuid getuid32 membarrier mmap mmap2 munmap nanosleep pause prlimit64 restart_syscall rseq rt_sigreturn sched_getaffinity sched_yield set_robust_list set_thread_area set_tid_address set_tls sigreturn time ugetrlimit '''.split()) # This is the @basic-io set from systemd BASIC_IO_SET = set(''' _llseek close close_range dup dup2 dup3 lseek pread64 preadv preadv2 pwrite64 pwritev pwritev2 read readv write writev '''.split()) # This is the @filesystem-io set from systemd FILESYSTEM_SET = set(''' access chdir chmod close creat faccessat faccessat2 fallocate fchdir fchmod fchmodat fcntl fcntl64 fgetxattr flistxattr fremovexattr fsetxattr fstat fstat64 fstatat64 fstatfs fstatfs64 ftruncate ftruncate64 futimesat getcwd getdents getdents64 getxattr inotify_add_watch inotify_init inotify_init1 inotify_rm_watch lgetxattr link linkat listxattr llistxattr lremovexattr lsetxattr lstat lstat64 mkdir mkdirat mknod mknodat newfstatat oldfstat oldlstat oldstat open openat openat2 readlink readlinkat removexattr rename renameat renameat2 rmdir setxattr stat stat64 statfs statfs64 statx symlink symlinkat truncate truncate64 unlink unlinkat utime utimensat utimensat_time64 utimes '''.split()) # Miscellaneous syscalls used during process startup, at least on x86_64 ALLOWED = DEFAULT_SET | BASIC_IO_SET | FILESYSTEM_SET | set(''' arch_prctl ioctl madvise mprotect mremap prctl readdir umask '''.split()) # Syscalls we will try to use, expecting them to be either allowed or # blocked by our allow and/or deny lists TRY_SYSCALLS = [ 'chmod', 'chroot', 'clone3', 'ioctl TIOCNOTTY', 'ioctl TIOCSTI CVE-2019-10063', 'ioctl TIOCSTI', 'listen', 'prctl', ] class Test(unittest.TestCase): def setUp(self) -> None: here = os.path.dirname(os.path.abspath(__file__)) if 'G_TEST_SRCDIR' in os.environ: self.test_srcdir = os.getenv('G_TEST_SRCDIR') + '/tests' else: self.test_srcdir = here if 'G_TEST_BUILDDIR' in os.environ: self.test_builddir = os.getenv('G_TEST_BUILDDIR') + '/tests' else: self.test_builddir = here self.bwrap = os.getenv('BWRAP', 'bwrap') self.try_syscall = os.path.join(self.test_builddir, 'try-syscall') completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', 'true', ], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=2, ) if completed.returncode != 0: raise unittest.SkipTest( 'cannot run bwrap (does it need to be setuid?)' ) def tearDown(self) -> None: pass def test_no_seccomp(self) -> None: for syscall in TRY_SYSCALLS: print('# {} without seccomp'.format(syscall)) completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', self.try_syscall, syscall, ], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=2, ) if ( syscall == 'ioctl TIOCSTI CVE-2019-10063' and completed.returncode == errno.ENOENT ): print('# Cannot test 64-bit syscall parameter on 32-bit') continue if syscall == 'clone3': # If the kernel supports it, we didn't block it so # it fails with EFAULT. If the kernel doesn't support it, # it'll fail with ENOSYS instead. self.assertIn( completed.returncode, (errno.ENOSYS, errno.EFAULT), ) elif syscall.startswith('ioctl') or syscall == 'listen': self.assertEqual(completed.returncode, errno.EBADF) else: self.assertEqual(completed.returncode, errno.EFAULT) def test_seccomp_allowlist(self) -> None: with tempfile.TemporaryFile() as allowlist_temp: allowlist = seccomp.SyscallFilter(seccomp.ERRNO(errno.ENOSYS)) if os.uname().machine == 'x86_64': # Allow Python and try-syscall to be different word sizes allowlist.add_arch(seccomp.Arch.X86) for syscall in ALLOWED: try: allowlist.add_rule(seccomp.ALLOW, syscall) except Exception as e: print('# Cannot add {} to allowlist: {!r}'.format(syscall, e)) allowlist.export_bpf(allowlist_temp) for syscall in TRY_SYSCALLS: print('# allowlist vs. {}'.format(syscall)) allowlist_temp.seek(0, os.SEEK_SET) completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--seccomp', str(allowlist_temp.fileno()), self.try_syscall, syscall, ], pass_fds=(allowlist_temp.fileno(),), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=2, ) if ( syscall == 'ioctl TIOCSTI CVE-2019-10063' and completed.returncode == errno.ENOENT ): print('# Cannot test 64-bit syscall parameter on 32-bit') continue if syscall.startswith('ioctl'): # We allow this, so it is executed (and in this simple # example, immediately fails) self.assertEqual(completed.returncode, errno.EBADF) elif syscall in ('chroot', 'listen', 'clone3'): # We don't allow these, so they fail with ENOSYS. # clone3 might also be failing with ENOSYS because # the kernel genuinely doesn't support it. self.assertEqual(completed.returncode, errno.ENOSYS) else: # We allow this, so it is executed (and in this simple # example, immediately fails) self.assertEqual(completed.returncode, errno.EFAULT) def test_seccomp_denylist(self) -> None: with tempfile.TemporaryFile() as denylist_temp: denylist = seccomp.SyscallFilter(seccomp.ALLOW) if os.uname().machine == 'x86_64': # Allow Python and try-syscall to be different word sizes denylist.add_arch(seccomp.Arch.X86) # Using ECONNREFUSED here because it's unlikely that any of # these syscalls will legitimately fail with that code, so # if they fail like this, it will be as a result of seccomp. denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chmod') denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chroot') denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'prctl') denylist.add_rule( seccomp.ERRNO(errno.ECONNREFUSED), 'ioctl', seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI), ) denylist.export_bpf(denylist_temp) for syscall in TRY_SYSCALLS: print('# denylist vs. {}'.format(syscall)) denylist_temp.seek(0, os.SEEK_SET) completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--seccomp', str(denylist_temp.fileno()), self.try_syscall, syscall, ], pass_fds=(denylist_temp.fileno(),), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=2, ) if ( syscall == 'ioctl TIOCSTI CVE-2019-10063' and completed.returncode == errno.ENOENT ): print('# Cannot test 64-bit syscall parameter on 32-bit') continue if syscall == 'clone3': # If the kernel supports it, we didn't block it so # it fails with EFAULT. If the kernel doesn't support it, # it'll fail with ENOSYS instead. self.assertIn( completed.returncode, (errno.ENOSYS, errno.EFAULT), ) elif syscall in ('ioctl TIOCNOTTY', 'listen'): # Not on the denylist self.assertEqual(completed.returncode, errno.EBADF) else: # We blocked all of these self.assertEqual(completed.returncode, errno.ECONNREFUSED) def test_seccomp_stacked(self, allowlist_first=False) -> None: with tempfile.TemporaryFile( ) as allowlist_temp, tempfile.TemporaryFile( ) as denylist_temp: # This filter is a simplified version of what Flatpak wants allowlist = seccomp.SyscallFilter(seccomp.ERRNO(errno.ENOSYS)) denylist = seccomp.SyscallFilter(seccomp.ALLOW) if os.uname().machine == 'x86_64': # Allow Python and try-syscall to be different word sizes allowlist.add_arch(seccomp.Arch.X86) denylist.add_arch(seccomp.Arch.X86) for syscall in ALLOWED: try: allowlist.add_rule(seccomp.ALLOW, syscall) except Exception as e: print('# Cannot add {} to allowlist: {!r}'.format(syscall, e)) denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chmod') denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chroot') denylist.add_rule( seccomp.ERRNO(errno.ECONNREFUSED), 'ioctl', seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI), ) # All seccomp programs except the last must allow prctl(), # because otherwise we wouldn't be able to add the remaining # seccomp programs. We document that the last program can # block prctl, so test that. if allowlist_first: denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'prctl') allowlist.export_bpf(allowlist_temp) denylist.export_bpf(denylist_temp) for syscall in TRY_SYSCALLS: print('# stacked vs. {}'.format(syscall)) allowlist_temp.seek(0, os.SEEK_SET) denylist_temp.seek(0, os.SEEK_SET) if allowlist_first: fds = [allowlist_temp.fileno(), denylist_temp.fileno()] else: fds = [denylist_temp.fileno(), allowlist_temp.fileno()] completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--add-seccomp-fd', str(fds[0]), '--add-seccomp-fd', str(fds[1]), self.try_syscall, syscall, ], pass_fds=fds, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=2, ) if ( syscall == 'ioctl TIOCSTI CVE-2019-10063' and completed.returncode == errno.ENOENT ): print('# Cannot test 64-bit syscall parameter on 32-bit') continue if syscall == 'ioctl TIOCNOTTY': # Not denied by the denylist, and allowed by the allowlist self.assertEqual(completed.returncode, errno.EBADF) elif syscall in ('clone3', 'listen'): # We didn't deny these, so the denylist has no effect # and we fall back to the allowlist, which doesn't # include them either. # clone3 might also be failing with ENOSYS because # the kernel genuinely doesn't support it. self.assertEqual(completed.returncode, errno.ENOSYS) elif syscall == 'chroot': # This is denied by the denylist *and* not allowed by # the allowlist. The result depends which one we added # first: the most-recently-added filter "wins". if allowlist_first: self.assertEqual( completed.returncode, errno.ECONNREFUSED, ) else: self.assertEqual(completed.returncode, errno.ENOSYS) elif syscall == 'prctl': # We can only put this on the denylist if the denylist # is the last to be added. if allowlist_first: self.assertEqual( completed.returncode, errno.ECONNREFUSED, ) else: self.assertEqual(completed.returncode, errno.EFAULT) else: # chmod is allowed by the allowlist but blocked by the # denylist. Denying takes precedence over allowing, # regardless of order. self.assertEqual(completed.returncode, errno.ECONNREFUSED) def test_seccomp_stacked_allowlist_first(self) -> None: self.test_seccomp_stacked(allowlist_first=True) def test_seccomp_invalid(self) -> None: with tempfile.TemporaryFile( ) as allowlist_temp, tempfile.TemporaryFile( ) as denylist_temp: completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--add-seccomp-fd', '-1', 'true', ], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) self.assertIn(b'bwrap: Invalid fd: -1\n', completed.stderr) self.assertEqual(completed.returncode, 1) completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--seccomp', '0a', 'true', ], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) self.assertIn(b'bwrap: Invalid fd: 0a\n', completed.stderr) self.assertEqual(completed.returncode, 1) completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--add-seccomp-fd', str(denylist_temp.fileno()), '--seccomp', str(allowlist_temp.fileno()), 'true', ], pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) self.assertIn( b'bwrap: --seccomp cannot be combined with --add-seccomp-fd\n', completed.stderr, ) self.assertEqual(completed.returncode, 1) completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--seccomp', str(allowlist_temp.fileno()), '--add-seccomp-fd', str(denylist_temp.fileno()), 'true', ], pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) self.assertIn( b'--add-seccomp-fd cannot be combined with --seccomp', completed.stderr, ) self.assertEqual(completed.returncode, 1) completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--add-seccomp-fd', str(allowlist_temp.fileno()), '--add-seccomp-fd', str(allowlist_temp.fileno()), 'true', ], pass_fds=(allowlist_temp.fileno(), allowlist_temp.fileno()), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) self.assertIn( b"bwrap: Can't read seccomp data: ", completed.stderr, ) self.assertEqual(completed.returncode, 1) allowlist_temp.write(b'\x01') allowlist_temp.seek(0, os.SEEK_SET) completed = subprocess.run( [ self.bwrap, '--ro-bind', '/', '/', '--add-seccomp-fd', str(denylist_temp.fileno()), '--add-seccomp-fd', str(allowlist_temp.fileno()), 'true', ], pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) self.assertIn( b'bwrap: Invalid seccomp data, must be multiple of 8\n', completed.stderr, ) self.assertEqual(completed.returncode, 1) def main(): logging.basicConfig(level=logging.DEBUG) try: from tap.runner import TAPTestRunner except ImportError: TAPTestRunner = None # type: ignore if TAPTestRunner is not None: runner = TAPTestRunner() runner.set_stream(True) unittest.main(testRunner=runner) else: print('# tap.runner not available, using simple TAP output') print('1..1') program = unittest.main(exit=False) if program.result.wasSuccessful(): print('ok 1 - %r' % program.result) else: print('not ok 1 - %r' % program.result) sys.exit(1) if __name__ == '__main__': main()