From 486812238953cb7850449fb167d13dbc4c8312c2 Mon Sep 17 00:00:00 2001 From: Tudor Brindus Date: Sun, 12 Sep 2021 11:42:02 -0400 Subject: [PATCH] executors: implement compiler sandboxing Current limitations: * doesn't sandbox renames * doesn't sandbox utimensat * doesn't sandbox various f* syscalls, e.g. fchmod Nonetheless, it plugs the obvious holes like allowing #include on any path readable by the judge. The functionality that remains un-sanboxed would likely require a compiler exploit to abuse. --- .flake8 | 1 + dmoj/cptbox/filesystem_policies.py | 7 +- dmoj/cptbox/isolate.py | 32 +-- dmoj/executors/CBL.py | 4 + dmoj/executors/D.py | 4 + dmoj/executors/DART.py | 10 +- dmoj/executors/GROOVY.py | 4 + dmoj/executors/HASK.py | 6 + dmoj/executors/KOTLIN.py | 5 + dmoj/executors/OCAML.py | 4 + dmoj/executors/PAS.py | 4 + dmoj/executors/RKT.py | 4 + dmoj/executors/RUST.py | 8 + dmoj/executors/SCALA.py | 9 + dmoj/executors/SCM.py | 4 + dmoj/executors/SWIFT.py | 5 + dmoj/executors/ZIG.py | 5 + dmoj/executors/compiled_executor.py | 208 +++++++++++------- dmoj/executors/java_executor.py | 2 +- dmoj/executors/mixins.py | 6 +- dmoj/executors/mono_executor.py | 2 +- .../tests/sandbox_py3_mkdir/helloworld.py | 2 - .../tests/sandbox_py3_mkdir/test.yml | 6 - 23 files changed, 233 insertions(+), 109 deletions(-) delete mode 100644 testsuite/helloworld/tests/sandbox_py3_mkdir/helloworld.py delete mode 100644 testsuite/helloworld/tests/sandbox_py3_mkdir/test.yml diff --git a/.flake8 b/.flake8 index ecc013b71..2569be0d7 100644 --- a/.flake8 +++ b/.flake8 @@ -12,6 +12,7 @@ ignore = C818 # trailing comma on bare tuple prohibited per-file-ignores = # F403: import *, F405: name comes from import * + ./dmoj/executors/compiled_executor.py:F403,F405 ./dmoj/cptbox/isolate.py:F403,F405 ./dmoj/cptbox/tracer.py:F403,F405 # F821: undefined name, flake8 incorrectly thinks name is deleted diff --git a/dmoj/cptbox/filesystem_policies.py b/dmoj/cptbox/filesystem_policies.py index 76f059eab..a8df7038b 100644 --- a/dmoj/cptbox/filesystem_policies.py +++ b/dmoj/cptbox/filesystem_policies.py @@ -57,7 +57,7 @@ def _add_rule(self, rule: FilesystemAccessRule) -> None: if rule.path == '/': return self._finalize_root_rule(rule) - path = rule.path + path = os.path.expanduser(rule.path) assert os.path.abspath(path) == path, 'FilesystemAccessRule must specify a normalized, absolute path to rule' *directory_path, final_component = path.split('/')[1:] @@ -69,6 +69,11 @@ def _add_rule(self, rule: FilesystemAccessRule) -> None: self._finalize_rule(node, final_component, rule) + # Add symlink targets too + real_path = os.path.realpath(path) + if real_path != path: + self._add_rule(type(rule)(real_path)) + def _assert_rule_type(self, rule: FilesystemAccessRule) -> None: if os.path.exists(rule.path): is_dir = os.path.isdir(rule.path) diff --git a/dmoj/cptbox/isolate.py b/dmoj/cptbox/isolate.py index 3b10b9403..447d2ed1b 100644 --- a/dmoj/cptbox/isolate.py +++ b/dmoj/cptbox/isolate.py @@ -58,8 +58,6 @@ def __init__(self, read_fs, write_fs=None, writable=(1, 2)): sys_lstat: self.check_file_access('lstat', 0), sys_lstat64: self.check_file_access('lstat64', 0), sys_fstatat: self.check_file_access_at('fstatat'), - sys_mkdir: ACCESS_EPERM, - sys_unlink: ACCESS_EPERM, sys_tgkill: self.do_kill, sys_kill: self.do_kill, sys_prctl: self.do_prctl, @@ -82,6 +80,7 @@ def __init__(self, read_fs, write_fs=None, writable=(1, 2)): sys_sched_getscheduler: ALLOW, sys_sched_get_priority_min: ALLOW, sys_sched_get_priority_max: ALLOW, + sys_sched_setscheduler: ALLOW, sys_timerfd_create: ALLOW, sys_timer_create: ALLOW, sys_timer_settime: ALLOW, @@ -158,6 +157,7 @@ def __init__(self, read_fs, write_fs=None, writable=(1, 2)): if 'freebsd' in sys.platform: self.update( { + sys_mkdir: ACCESS_EPERM, sys_obreak: ALLOW, sys_sysarch: ALLOW, sys_sysctl: ALLOW, # TODO: More strict? @@ -205,7 +205,9 @@ def is_write_flags(self, open_flags: int) -> bool: return False - def check_file_access(self, syscall, argument, is_open=False) -> HandlerCallback: + def check_file_access(self, syscall, argument, is_write=None, is_open=False) -> HandlerCallback: + assert is_write is None or not is_open + def check(debugger: Debugger) -> bool: file_ptr = getattr(debugger, 'uarg%d' % argument) try: @@ -217,7 +219,7 @@ def check(debugger: Debugger) -> bool: log.warning('Denied access via syscall %s to path with invalid unicode: %r', syscall, e.object) return ACCESS_ENOENT(debugger) - file, error = self._file_access_check(file, debugger, is_open) + file, error = self._file_access_check(file, debugger, is_open, is_write=is_write) if not error: return True @@ -226,10 +228,11 @@ def check(debugger: Debugger) -> bool: return check - def check_file_access_at(self, syscall, is_open=False) -> HandlerCallback: + def check_file_access_at(self, syscall, argument=1, is_open=False, is_write=None) -> HandlerCallback: def check(debugger: Debugger) -> bool: + file_ptr = getattr(debugger, 'uarg%d' % argument) try: - file = debugger.readstr(debugger.uarg1) + file = debugger.readstr(file_ptr) except MaxLengthExceeded as e: log.warning('Denied access via syscall %s to overly long path: %r', syscall, e.args[0]) return ACCESS_ENAMETOOLONG(debugger) @@ -237,7 +240,9 @@ def check(debugger: Debugger) -> bool: log.warning('Denied access via syscall %s to path with invalid unicode: %r', syscall, e.object) return ACCESS_ENOENT(debugger) - file, error = self._file_access_check(file, debugger, is_open, dirfd=debugger.arg0, flag_reg=2) + file, error = self._file_access_check( + file, debugger, is_open, is_write=is_write, dirfd=debugger.arg0, flag_reg=2 + ) if not error: return True @@ -247,7 +252,7 @@ def check(debugger: Debugger) -> bool: return check def _file_access_check( - self, rel_file, debugger, is_open, flag_reg=1, dirfd=AT_FDCWD + self, rel_file, debugger, is_open, is_write=None, flag_reg=1, dirfd=AT_FDCWD ) -> Tuple[str, Optional[ErrnoHandlerCallback]]: # Either process called open(NULL, ...), or we failed to read the path # in cptbox. Either way this call should not be allowed; if the path @@ -257,7 +262,8 @@ def _file_access_check( if rel_file is None: return '(nil)', ACCESS_EFAULT - is_write = is_open and self.is_write_flags(getattr(debugger, 'uarg%d' % flag_reg)) + if is_write is None and is_open: + is_write = self.is_write_flags(getattr(debugger, 'uarg%d' % flag_reg)) fs_jail = self.write_fs_jail if is_write else self.read_fs_jail try: @@ -273,11 +279,11 @@ def _file_access_check( # the same file, and check the accessibility of both. # # This works, except when the child process uses /proc/self, which refers to something else in this process. - # Therefore, we "project" it by changing it to /proc/[pid] for computing the realpath and doing the samefile + # Therefore, we "project" it by changing it to /proc/[tid] for computing the realpath and doing the samefile # check. However, we still keep it as /proc/self when checking access rules. projected = normalized = '/' + os.path.normpath(file).lstrip('/') if normalized.startswith('/proc/self'): - file = os.path.join(f'/proc/{debugger.pid}', os.path.relpath(file, '/proc/self')) + file = os.path.join(f'/proc/{debugger.tid}', os.path.relpath(file, '/proc/self')) projected = '/' + os.path.normpath(file).lstrip('/') real = os.path.realpath(file) @@ -299,14 +305,14 @@ def _file_access_check( return normalized, ACCESS_EACCES if normalized != real: - proc_dir = f'/proc/{debugger.pid}' + proc_dir = f'/proc/{debugger.tid}' if real.startswith(proc_dir): real = os.path.join('/proc/self', os.path.relpath(real, proc_dir)) if not fs_jail.check(real): return real, ACCESS_EACCES - return real, None + return normalized, None def get_full_path(self, debugger: Debugger, file: str, dirfd: int = AT_FDCWD) -> str: dirfd = (dirfd & 0x7FFFFFFF) - (dirfd & 0x80000000) diff --git a/dmoj/executors/CBL.py b/dmoj/executors/CBL.py index 98786f857..343984385 100644 --- a/dmoj/executors/CBL.py +++ b/dmoj/executors/CBL.py @@ -1,5 +1,6 @@ import subprocess +from dmoj.cptbox.filesystem_policies import ExactFile from dmoj.executors.compiled_executor import CompiledExecutor @@ -9,6 +10,9 @@ class Executor(CompiledExecutor): command = 'cobc' address_grace = 131072 compile_output_index = 0 + compiler_read_fs = [ + ExactFile('/etc/gnucobol/default.conf'), + ] test_program = """\ IDENTIFICATION DIVISION. PROGRAM-ID. HELLO-WORLD. diff --git a/dmoj/executors/D.py b/dmoj/executors/D.py index 481ec6df1..19b656d18 100644 --- a/dmoj/executors/D.py +++ b/dmoj/executors/D.py @@ -1,3 +1,4 @@ +from dmoj.cptbox.filesystem_policies import ExactFile from dmoj.executors.compiled_executor import CompiledExecutor @@ -6,6 +7,9 @@ class Executor(CompiledExecutor): name = 'D' address_grace = 32768 command = 'dmd' + compiler_read_fs = [ + ExactFile('/etc/dmd.conf'), + ] test_program = """\ import std.stdio; diff --git a/dmoj/executors/DART.py b/dmoj/executors/DART.py index 7a817edca..848f602bd 100644 --- a/dmoj/executors/DART.py +++ b/dmoj/executors/DART.py @@ -1,13 +1,19 @@ +from dmoj.cptbox.filesystem_policies import ExactFile, RecursiveDir from dmoj.executors.compiled_executor import CompiledExecutor -# Running DART normally results in unholy memory usage -# Thankfully compiling it results in something...far more sane +# Running Dart normally results in unholy memory usage +# Thankfully compiling it results in something... far more sane class Executor(CompiledExecutor): ext = 'dart' name = 'DART' nproc = -1 # Dart uses a really, really large number of threads command = 'dart' + compiler_read_fs = [ + # Dart shells out... + ExactFile('/bin/sh'), + RecursiveDir('/proc/self/fd'), + ] test_program = """ void main() { print("echo: Hello, World!"); diff --git a/dmoj/executors/GROOVY.py b/dmoj/executors/GROOVY.py index b85567193..d4ca7f7d6 100644 --- a/dmoj/executors/GROOVY.py +++ b/dmoj/executors/GROOVY.py @@ -1,5 +1,6 @@ import os import subprocess +from pathlib import Path from dmoj.executors.java_executor import JavaExecutor from dmoj.utils.unicode import utf8text @@ -29,6 +30,9 @@ def get_cmdline(self, **kwargs): def get_compile_args(self): return [self.get_compiler(), self._code] + def get_compile_env(self): + return {'JAVA_HOME': str(Path(os.path.realpath(self.get_vm())).parent.parent)} + @classmethod def get_versionable_commands(cls): return [('groovyc', cls.get_compiler()), ('java', cls.get_vm())] diff --git a/dmoj/executors/HASK.py b/dmoj/executors/HASK.py index 2522f0e66..626175bd2 100644 --- a/dmoj/executors/HASK.py +++ b/dmoj/executors/HASK.py @@ -1,3 +1,4 @@ +from dmoj.cptbox.filesystem_policies import RecursiveDir from dmoj.executors.compiled_executor import CompiledExecutor from dmoj.executors.mixins import NullStdoutMixin @@ -6,6 +7,11 @@ class Executor(NullStdoutMixin, CompiledExecutor): ext = 'hs' name = 'HASK' command = 'ghc' + compiler_read_fs = [ + RecursiveDir('/proc/self/task'), + RecursiveDir('/var/lib/ghc'), + ] + test_program = """\ main = do a <- getContents diff --git a/dmoj/executors/KOTLIN.py b/dmoj/executors/KOTLIN.py index 3cf9154e4..9e61f7233 100644 --- a/dmoj/executors/KOTLIN.py +++ b/dmoj/executors/KOTLIN.py @@ -1,3 +1,4 @@ +from dmoj.cptbox.filesystem_policies import ExactFile from dmoj.executors.java_executor import JavaExecutor @@ -7,6 +8,10 @@ class Executor(JavaExecutor): compiler = 'kotlinc' compiler_time_limit = 20 + compiler_read_fs = [ + ExactFile('/bin/uname'), + ExactFile('/bin/bash'), + ] vm = 'kotlin_vm' test_program = """\ diff --git a/dmoj/executors/OCAML.py b/dmoj/executors/OCAML.py index 742d47fe5..26d61cf89 100644 --- a/dmoj/executors/OCAML.py +++ b/dmoj/executors/OCAML.py @@ -1,3 +1,4 @@ +from dmoj.cptbox.filesystem_policies import RecursiveDir from dmoj.executors.compiled_executor import CompiledExecutor @@ -8,6 +9,9 @@ class Executor(CompiledExecutor): ext = 'ml' name = 'OCAML' command = 'ocamlfind' + compiler_read_fs = [ + RecursiveDir('~/.opam'), + ] test_program = """ open! Base open! Core diff --git a/dmoj/executors/PAS.py b/dmoj/executors/PAS.py index c120fdde2..5a1b876b1 100644 --- a/dmoj/executors/PAS.py +++ b/dmoj/executors/PAS.py @@ -1,3 +1,4 @@ +from dmoj.cptbox.filesystem_policies import ExactFile from dmoj.executors.compiled_executor import CompiledExecutor from dmoj.executors.mixins import NullStdoutMixin @@ -6,6 +7,9 @@ class Executor(NullStdoutMixin, CompiledExecutor): ext = 'pas' name = 'PAS' command = 'fpc' + compiler_read_fs = [ + ExactFile('/etc/fpc.cfg'), + ] test_program = """\ var line : string; begin diff --git a/dmoj/executors/RKT.py b/dmoj/executors/RKT.py index 16015a3a7..ccbb287b8 100644 --- a/dmoj/executors/RKT.py +++ b/dmoj/executors/RKT.py @@ -6,6 +6,10 @@ class Executor(CompiledExecutor): ext = 'rkt' name = 'RKT' fs = [RecursiveDir('/etc/racket'), ExactFile('/etc/passwd')] + compiler_read_fs = [ + RecursiveDir('/etc/racket'), + RecursiveDir('~/.local/share/racket'), + ] command = 'racket' diff --git a/dmoj/executors/RUST.py b/dmoj/executors/RUST.py index 81ee667f4..33f1a9de6 100644 --- a/dmoj/executors/RUST.py +++ b/dmoj/executors/RUST.py @@ -1,5 +1,6 @@ import os +from dmoj.cptbox.filesystem_policies import ExactFile, RecursiveDir from dmoj.executors.compiled_executor import CompiledExecutor from dmoj.utils.os_ext import bool_env @@ -77,6 +78,13 @@ class Executor(CompiledExecutor): command = 'cargo' test_program = HELLO_WORLD_PROGRAM compiler_time_limit = 20 + compiler_read_fs = [ + RecursiveDir('/home'), + ExactFile('/etc/resolv.conf'), + ] + compiler_write_fs = [ + RecursiveDir('~/.cargo'), + ] def create_files(self, problem_id, source_code, *args, **kwargs): os.mkdir(self._file('src')) diff --git a/dmoj/executors/SCALA.py b/dmoj/executors/SCALA.py index cf7f4a829..1a5865b8a 100644 --- a/dmoj/executors/SCALA.py +++ b/dmoj/executors/SCALA.py @@ -1,6 +1,7 @@ import os import subprocess +from dmoj.cptbox.filesystem_policies import ExactFile, RecursiveDir from dmoj.executors.java_executor import JavaExecutor from dmoj.utils.unicode import utf8text @@ -12,6 +13,14 @@ class Executor(JavaExecutor): compiler = 'scalac' compiler_time_limit = 20 + compiler_read_fs = [ + ExactFile('/bin/uname'), + ExactFile('/bin/readlink'), + ExactFile('/bin/grep'), + ExactFile('/bin/stty'), + ExactFile('/bin/bash'), + RecursiveDir('/etc/alternatives'), + ] vm = 'scala_vm' test_program = """\ diff --git a/dmoj/executors/SCM.py b/dmoj/executors/SCM.py index 3c6f54b6a..34f40969f 100644 --- a/dmoj/executors/SCM.py +++ b/dmoj/executors/SCM.py @@ -1,3 +1,4 @@ +from dmoj.cptbox.filesystem_policies import RecursiveDir from dmoj.executors.compiled_executor import CompiledExecutor @@ -6,6 +7,9 @@ class Executor(CompiledExecutor): name = 'SCM' command = 'chicken-csc' command_paths = ['chicken-csc', 'csc'] + compiler_read_fs = [ + RecursiveDir('/var/lib/chicken'), + ] test_program = '(import chicken.io) (map print (read-lines))' def get_compile_args(self): diff --git a/dmoj/executors/SWIFT.py b/dmoj/executors/SWIFT.py index 410810c3a..fcfda281f 100644 --- a/dmoj/executors/SWIFT.py +++ b/dmoj/executors/SWIFT.py @@ -1,3 +1,4 @@ +from dmoj.cptbox.filesystem_policies import RecursiveDir from dmoj.executors.compiled_executor import CompiledExecutor @@ -5,6 +6,10 @@ class Executor(CompiledExecutor): ext = 'swift' name = 'SWIFT' command = 'swiftc' + compiler_read_fs = [ + RecursiveDir('~/.cache'), + ] + compiler_write_fs = compiler_read_fs test_program = 'print(readLine()!)' def get_compile_args(self): diff --git a/dmoj/executors/ZIG.py b/dmoj/executors/ZIG.py index 958027442..c2e8b2053 100644 --- a/dmoj/executors/ZIG.py +++ b/dmoj/executors/ZIG.py @@ -1,3 +1,4 @@ +from dmoj.cptbox.filesystem_policies import RecursiveDir from dmoj.executors.compiled_executor import CompiledExecutor @@ -6,6 +7,10 @@ class Executor(CompiledExecutor): name = 'ZIG' command = 'zig' compiler_time_limit = 30 + compiler_read_fs = [ + RecursiveDir('~/.cache'), + ] + compiler_write_fs = compiler_read_fs test_program = """ const std = @import("std"); diff --git a/dmoj/executors/compiled_executor.py b/dmoj/executors/compiled_executor.py index 0e58043a4..017609b93 100644 --- a/dmoj/executors/compiled_executor.py +++ b/dmoj/executors/compiled_executor.py @@ -2,19 +2,22 @@ import hashlib import os import pty -import signal -import subprocess -import threading -import time -from typing import Callable, Dict, List, Optional +import sys +from typing import Dict, List, Optional, Sequence import pylru +from dmoj.cptbox import IsolateTracer, TracedPopen +from dmoj.cptbox.filesystem_policies import ExactFile, FilesystemAccessRule, RecursiveDir +from dmoj.cptbox.handlers import ALLOW +from dmoj.cptbox.syscalls import * from dmoj.error import CompileError, OutputLimitExceeded +from dmoj.executors.base_executor import BaseExecutor +from dmoj.executors.mixins import BASE_FILESYSTEM, BASE_WRITE_FILESYSTEM from dmoj.judgeenv import env from dmoj.utils.communicate import safe_communicate +from dmoj.utils.error import print_protection_fault from dmoj.utils.unicode import utf8bytes -from .base_executor import BaseExecutor # A lot of executors must do initialization during their constructors, which is @@ -69,47 +72,104 @@ def __call__(self, *args, **kwargs) -> 'CompiledExecutor': return obj -class TimedPopen(subprocess.Popen): - def __init__(self, *args, **kwargs): - self._time = kwargs.pop('time_limit', None) - super().__init__(*args, **kwargs) - - self._is_ole = False - self.timed_out = False - if self._time: - # Spawn thread to kill process after it times out - self._shocker = threading.Thread(target=self._shocker_thread) - self._shocker.start() - - def mark_ole(self): - self._is_ole = True - - @property - def is_ole(self): - return self._is_ole - - def _shocker_thread(self) -> None: - # Though this shares a name with the shocker thread used for submissions, where the process shocker thread - # is a fine scalpel that ends a TLE process with surgical precision, this is more like a rusty hatchet - # that beheads a misbehaving compiler. - # - # It's not very accurate: time starts ticking in the next line, regardless of whether the process is - # actually running, and the time is updated in 0.25s intervals. Nonetheless, it serves the purpose of - # not allowing the judge to die. - # - # See - start_time = time.time() +class CompilerIsolateTracer(IsolateTracer): + def __init__(self, tmpdir, read_fs, write_fs, *args, **kwargs): + read_fs += BASE_FILESYSTEM + [ + RecursiveDir(tmpdir), + ExactFile('/bin/strip'), + RecursiveDir('/usr/x86_64-linux-gnu'), + ] + write_fs += BASE_WRITE_FILESYSTEM + [RecursiveDir(tmpdir)] + super().__init__(read_fs, *args, write_fs=write_fs, **kwargs) + + # FIXME: big hack to force execve to be handled even with seccomp. + def handle_execve(debugger): + return True + + self.update( + { + # Process spawning system calls + sys_fork: ALLOW, + sys_vfork: ALLOW, + sys_execve: handle_execve, + # Directory system calls + sys_mkdir: self.check_file_access('mkdir', 0, is_write=True), + sys_mkdirat: self.check_file_access_at('mkdirat', is_write=True), + sys_rmdir: self.check_file_access('rmdir', 0, is_write=True), + # Linking system calls + sys_link: self.check_file_access('link', 1, is_write=True), + sys_linkat: self.check_file_access_at('linkat', argument=3, is_write=True), + sys_unlink: self.check_file_access('unlink', 0, is_write=True), + sys_unlinkat: self.check_file_access_at('unlinkat', is_write=True), + sys_symlink: self.check_file_access('symlink', 1, is_write=True), + # Miscellaneous other filesystem system calls + sys_chdir: self.check_file_access('chdir', 0), + sys_chmod: self.check_file_access('chmod', 0, is_write=True), + # FIXME: Mono breaks if we don't implement Linux's special + # handling for UTIME_OMIT. This is undocumented but nonethelss + # relied on. + sys_utimensat: ALLOW, + sys_statx: self.check_file_access_at('statx'), + sys_umask: ALLOW, + sys_flock: ALLOW, + sys_fsync: ALLOW, + sys_fadvise64: ALLOW, + # FIXME: this allows changing any FD that is open, not just RW ones. + sys_fchmodat: ALLOW, + sys_fchmod: ALLOW, + sys_fallocate: ALLOW, + sys_ftruncate: ALLOW, + # FIXME: this doesn't validate the source nor target + sys_rename: ALLOW, + sys_renameat: ALLOW, + # I/O system calls + sys_readv: ALLOW, + sys_pwrite64: ALLOW, + sys_sendfile: ALLOW, + # Event loop system calls + sys_epoll_create: ALLOW, + sys_epoll_create1: ALLOW, + sys_epoll_ctl: ALLOW, + sys_epoll_wait: ALLOW, + sys_epoll_pwait: ALLOW, + sys_timerfd_settime: ALLOW, + sys_eventfd2: ALLOW, + sys_waitid: ALLOW, + sys_wait4: ALLOW, + # Network system calls, we don't sandbox these + sys_socket: ALLOW, + sys_socketpair: ALLOW, + sys_connect: ALLOW, + sys_setsockopt: ALLOW, + sys_getsockname: ALLOW, + sys_sendmmsg: ALLOW, + sys_recvfrom: ALLOW, + sys_sendto: ALLOW, + # Miscellaneous other system calls + sys_msync: ALLOW, + sys_clock_nanosleep: ALLOW, + sys_memfd_create: ALLOW, + sys_rt_sigsuspend: ALLOW, + } + ) - while self.returncode is None: - if time.time() - start_time > self._time: - self.timed_out = True - try: - os.killpg(self.pid, signal.SIGKILL) - except OSError: - # This can happen if the process exits quickly - pass - break - time.sleep(0.25) + # FreeBSD-specific syscalls + if 'freebsd' in sys.platform: + self.update( + { + sys_rfork: ALLOW, + sys_procctl: ALLOW, + sys_cap_rights_limit: ALLOW, + # FIXME: this allows changing any FD that is open, not just RW ones. + sys_posix_fadvise: ALLOW, + sys_posix_fallocate: ALLOW, + sys_setrlimit: ALLOW, + sys_cap_ioctls_limit: ALLOW, + sys_cap_fcntls_limit: ALLOW, + sys_cap_enter: ALLOW, + sys_utimes: self.check_file_access('utimes', 0), + } + ) class CompiledExecutor(BaseExecutor, metaclass=_CompiledExecutorMeta): @@ -122,6 +182,9 @@ class CompiledExecutor(BaseExecutor, metaclass=_CompiledExecutorMeta): _executable: Optional[str] = None _code: Optional[str] = None + compiler_read_fs: Sequence[FilesystemAccessRule] = [] + compiler_write_fs: Sequence[FilesystemAccessRule] = [] + def __init__(self, problem_id: str, source_code: bytes, *args, **kwargs): super().__init__(problem_id, source_code, **kwargs) self.warning = None @@ -145,32 +208,7 @@ def get_compile_env(self) -> Optional[dict]: def get_compile_popen_kwargs(self) -> dict: return {} - def create_executable_limits(self) -> Optional[Callable[[], None]]: - try: - import resource - from dmoj.utils.os_ext import oom_score_adj, OOM_SCORE_ADJ_MAX - - def limit_executable(): - os.setpgrp() - - # Mark compiler process as first to die in an OOM situation, just to ensure that the judge will not - # be killed. - try: - oom_score_adj(OOM_SCORE_ADJ_MAX) - except FileNotFoundError: - pass - except Exception: - import traceback - - traceback.print_exc() - - resource.setrlimit(resource.RLIMIT_FSIZE, (self.executable_size, self.executable_size)) - - return limit_executable - except ImportError: - return None - - def create_compile_process(self, args: List[str]) -> TimedPopen: + def create_compile_process(self, args: List[str]) -> TracedPopen: # Some languages may insist on providing certain functionality (e.g. colored highlighting of errors) if they # feel they are connected to a terminal. Some are more persistent than others in enforcing this, so this hack # aims to provide a convincing-enough lie to the runtime so that it starts singing in color. @@ -180,17 +218,25 @@ def create_compile_process(self, args: List[str]) -> TimedPopen: # Some runtimes *cough cough* Swift *cough cough* actually check the environment variables too. env = self.get_compile_env() or os.environ.copy() env['TERM'] = 'xterm' + # Instruct compilers to put their temporary files into the submission directory, + # so that we can allow it as writeable, rather than of all of /tmp. + assert self._dir is not None + env['TMPDIR'] = self._dir - proc = TimedPopen( - args, + proc = TracedPopen( + [utf8bytes(a) for a in args], **{ + 'executable': utf8bytes(args[0]), + 'security': CompilerIsolateTracer(self._dir, self.compiler_read_fs, self.compiler_write_fs), 'stderr': _slave, 'stdout': _slave, 'stdin': _slave, - 'cwd': self._dir, + 'cwd': utf8bytes(self._dir), 'env': env, - 'preexec_fn': self.create_executable_limits(), - 'time_limit': self.compiler_time_limit, + 'nproc': -1, + 'fsize': self.executable_size, + 'time': self.compiler_time_limit or 0, + 'memory': 0, **self.get_compile_popen_kwargs(), } ) @@ -219,7 +265,7 @@ def __getattr__(self, attr): os.close(_slave) return proc - def get_compile_output(self, process: TimedPopen) -> bytes: + def get_compile_output(self, process: TracedPopen) -> bytes: # Use safe_communicate because otherwise, malicious submissions can cause a compiler # to output hundreds of megabytes of data as output before being killed by the time limit, # which effectively murders the MySQL database waiting on the site server. @@ -230,8 +276,10 @@ def get_compile_output(self, process: TimedPopen) -> bytes: output = b'compiler output too long (> 64kb)' if self.is_failed_compile(process): - if process.timed_out: + if process.is_tle: output = b'compiler timed out (> %d seconds)' % self.compiler_time_limit + if process.protection_fault: + print_protection_fault(process.protection_fault) self.handle_compile_error(output) return output @@ -239,7 +287,7 @@ def get_compile_output(self, process: TimedPopen) -> bytes: def get_compiled_file(self) -> str: return self._file(self.problem) - def is_failed_compile(self, process: TimedPopen) -> bool: + def is_failed_compile(self, process: TracedPopen) -> bool: return process.returncode != 0 def handle_compile_error(self, output: bytes) -> None: diff --git a/dmoj/executors/java_executor.py b/dmoj/executors/java_executor.py index 308bd227b..e2b45d809 100644 --- a/dmoj/executors/java_executor.py +++ b/dmoj/executors/java_executor.py @@ -71,7 +71,7 @@ def create_files(self, problem_id, source_code, *args, **kwargs): self._agent_file = JAVA_SANDBOX def get_compile_popen_kwargs(self): - return {'executable': self.get_compiler()} + return {'executable': utf8bytes(self.get_compiler())} def get_compiled_file(self): return None diff --git a/dmoj/executors/mixins.py b/dmoj/executors/mixins.py index 7b5d994b4..69cfad11a 100644 --- a/dmoj/executors/mixins.py +++ b/dmoj/executors/mixins.py @@ -3,7 +3,7 @@ import re import shutil import sys -from typing import Any, List, Tuple, Union +from typing import Any, List, Sequence, Tuple, Union from dmoj.cptbox import IsolateTracer, TracedPopen, syscalls from dmoj.cptbox.filesystem_policies import ExactDir, ExactFile, FilesystemAccessRule, RecursiveDir @@ -83,8 +83,8 @@ class PlatformExecutorMixin(metaclass=abc.ABCMeta): data_grace = 0 fsize = 0 personality = 0x0040000 # ADDR_NO_RANDOMIZE - fs: List[FilesystemAccessRule] = [] - write_fs: List[FilesystemAccessRule] = [] + fs: Sequence[FilesystemAccessRule] = [] + write_fs: Sequence[FilesystemAccessRule] = [] syscalls: List[Union[str, Tuple[str, Any]]] = [] def _add_syscalls(self, sec): diff --git a/dmoj/executors/mono_executor.py b/dmoj/executors/mono_executor.py index 4ebb853fb..d912f4548 100644 --- a/dmoj/executors/mono_executor.py +++ b/dmoj/executors/mono_executor.py @@ -34,10 +34,10 @@ class MonoExecutor(CompiledExecutor): data_grace = 65536 cptbox_popen_class = MonoTracedPopen fs = [RecursiveDir('/etc/mono')] + compiler_read_fs = fs # Mono sometimes forks during its crashdump procedure, but continues even if # the call to fork fails. syscalls = [ - 'sched_setscheduler', 'wait4', 'rt_sigsuspend', 'msync', diff --git a/testsuite/helloworld/tests/sandbox_py3_mkdir/helloworld.py b/testsuite/helloworld/tests/sandbox_py3_mkdir/helloworld.py deleted file mode 100644 index 4f6d56aa3..000000000 --- a/testsuite/helloworld/tests/sandbox_py3_mkdir/helloworld.py +++ /dev/null @@ -1,2 +0,0 @@ -import os -os.mkdir('test') diff --git a/testsuite/helloworld/tests/sandbox_py3_mkdir/test.yml b/testsuite/helloworld/tests/sandbox_py3_mkdir/test.yml deleted file mode 100644 index 36d143c1e..000000000 --- a/testsuite/helloworld/tests/sandbox_py3_mkdir/test.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: PY3 -time: 2 -memory: 65536 -source: helloworld.py -expect: IR -feedback: PermissionError