From 520420556ad9f2871cd8c5910645193508cb4082 Mon Sep 17 00:00:00 2001 From: Timotej Lazar Date: Wed, 14 Oct 2015 17:57:09 +0200 Subject: Use sandbox for testing Python programs Use interpreter.py for running tests as well as interactive sessions. Signals are now sent with "sandbox kill", so terminator is not needed anymore. --- python/runner/interpreter.py | 121 +++++++++++++++++++---------- python/runner/terminator.c | 31 -------- python/util.py | 2 + scripts/deploy/codeq_refresh_and_deploy.sh | 6 +- server/python_session.py | 107 ++++++++++--------------- 5 files changed, 127 insertions(+), 140 deletions(-) delete mode 100644 python/runner/terminator.c diff --git a/python/runner/interpreter.py b/python/runner/interpreter.py index da60d72..722bc33 100755 --- a/python/runner/interpreter.py +++ b/python/runner/interpreter.py @@ -1,6 +1,8 @@ #!/usr/bin/python3 -u import code +import io +import pickle import sys import seccomp @@ -43,49 +45,88 @@ f.add_rule(seccomp.ALLOW, "access") f.add_rule(seccomp.ALLOW, "select") f.load() -class MyConsole(code.InteractiveConsole): - def interact(self, banner=None): - if banner is not None: - self.write('{}\n'.format(banner)) +if len(sys.argv) > 1 and sys.argv[1] == 'run': + # Receive a pickled tuple (code, expr, stdin) on standard input, run it, + # and print a pickled tuple (result, out, err, exc) on standard output. + data = sys.stdin.buffer.read() + code, expr, stdin = pickle.loads(data) - buffer = [] - prompt = '>>> ' - while True: - try: - line = input(prompt) - # Assume we are running the user's program; silence the prompt. - if line == 'exec("""\\': - self.write('\n') - prompt = '' + result, out, err, exc = None, None, None, None + try: + sys.stdin = io.StringIO(stdin) + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + env = {} + if code: + exec(code, env) + if expr: + result = eval(expr, env) + except Exception as ex: + # Exception is not JSON serializable, so return traceback as string + # (without the first entry, which is this function). + import traceback + e_type, e_value, e_tb = sys.exc_info() + stack = traceback.extract_tb(e_tb) + exc = ''.join( + ['Traceback (most recent call last):\n'] + + [' line {}, in {}\n'.format(lineno, name) + (line+'\n' if line else '') + for filename, lineno, name, line in stack[1:]] + + traceback.format_exception_only(e_type, e_value) + ).rstrip() + finally: + out = sys.stdout.getvalue() + err = sys.stderr.getvalue() + sys.stdin.close() + sys.stdout.close() + sys.stderr.close() + outdata = pickle.dumps((result, out, err, exc)) + sys.__stdout__.buffer.write(outdata) + +else: + # Run an interactive console. + class MyConsole(code.InteractiveConsole): + def interact(self, banner=None): + if banner is not None: + self.write('{}\n'.format(banner)) + + buffer = [] + prompt = '>>> ' + while True: + try: + line = input(prompt) + # Assume we are running the user's program; silence the prompt. + if line == 'exec("""\\': + self.write('\n') + prompt = '' - buffer.append(line) - source = '\n'.join(buffer) - more = self.runsource(source) - if more: - if prompt: - prompt = '... ' - else: + buffer.append(line) + source = '\n'.join(buffer) + more = self.runsource(source) + if more: + if prompt: + prompt = '... ' + else: + prompt = '>>> ' + buffer = [] + except KeyboardInterrupt: prompt = '>>> ' buffer = [] - except KeyboardInterrupt: - prompt = '>>> ' - buffer = [] - self.write('\n') - except ValueError: - break - except EOFError: - break + self.write('\n') + except ValueError: + break + except EOFError: + break - def runcode(self, code): - try: - exec(code, self.locals) - except KeyboardInterrupt: - self.write('^C') - raise - except SystemExit as ex: - raise - except: - # Show traceback for all other exceptions. - self.showtraceback() + def runcode(self, code): + try: + exec(code, self.locals) + except KeyboardInterrupt: + self.write('^C') + raise + except SystemExit as ex: + raise + except: + # Show traceback for all other exceptions. + self.showtraceback() -MyConsole().interact() + MyConsole().interact() diff --git a/python/runner/terminator.c b/python/runner/terminator.c deleted file mode 100644 index 9eaca83..0000000 --- a/python/runner/terminator.c +++ /dev/null @@ -1,31 +0,0 @@ -#include -#include -#include -#include -#include - -int main(int argc, char* argv[]) -{ - if (argc < 4) { - fprintf(stderr, "usage: %s USERNAME PID SIGNAL\n", argv[0]); - return 1; - } - - // switch user (requires root or "setcap cap_setuid,cap_setgid+ep") - char const* username = argv[1]; - struct passwd const* pw = getpwnam(username); - if (!pw) { - fprintf(stderr, "no such user: %s\n", username); - return 1; - } - int ret = 0; - if ((ret = setgid(pw->pw_gid)) != 0) - fprintf(stderr, "setgid returned %d\n", ret); - if ((ret = setuid(pw->pw_uid)) != 0) - fprintf(stderr, "setuid returned %d\n", ret); - - pid_t pid = atol(argv[2]); - int signum = atoi(argv[3]); - kill(pid, signum); - return 0; -} diff --git a/python/util.py b/python/util.py index ce89558..8c1a0c7 100644 --- a/python/util.py +++ b/python/util.py @@ -59,6 +59,8 @@ def get_exception_desc(exc): return [{'id':'eof_error'}] if 'timed out' in exc: return [{'id':'timed_out'}] + if 'sandbox violation' in exc: + return [{'id': 'sandbox_violation'}] if 'NameError' in exc: return [{'id':'name_error', 'args': {'message': exc}}] elif 'TypeError' in exc: diff --git a/scripts/deploy/codeq_refresh_and_deploy.sh b/scripts/deploy/codeq_refresh_and_deploy.sh index f0a0aeb..e44739b 100755 --- a/scripts/deploy/codeq_refresh_and_deploy.sh +++ b/scripts/deploy/codeq_refresh_and_deploy.sh @@ -36,10 +36,10 @@ if git diff --name-status origin/$CODEQ_GIT_BRANCH | cut -c3- | grep -v \\.gitig BUILD_WEB_RESOURCES=1 fi - # rebuild sandbox binaries if out of date - for prog in python/runner/sandbox python/runner/terminator; do + # rebuild sandbox if out of date + for prog in python/runner/sandbox; do if ! make -q "${prog}"; then - make -s "${prog}" && setcap cap_setuid,cap_setgid+ep "${prog}" + make "${prog}" && setcap cap_setuid,cap_setgid+ep "${prog}" fi done diff --git a/server/python_session.py b/server/python_session.py index e1be4ba..39bd8f4 100644 --- a/server/python_session.py +++ b/server/python_session.py @@ -4,6 +4,7 @@ from fcntl import fcntl, F_GETFL, F_SETFL import io import multiprocessing import os +import pickle import selectors import signal import subprocess @@ -36,22 +37,34 @@ class PythonSession(server.LanguageSession): # Launch processes. futures = [] for expr, stdin in inputs: - conn_parent, conn_child = multiprocessing.Pipe() - p = multiprocessing.Process(target=_run_exec, args=(conn_child, code, expr, stdin)) - p.start() - futures.append((p, conn_parent)) + p = subprocess.Popen(proc_args + ['run'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + data = pickle.dumps((code, expr, stdin)) + p.stdin.write(data) + futures.append(p) # Wait for results. results = [] start = time.monotonic() - for p, conn in futures: + for proc in futures: now = time.monotonic() real_timeout = max(0, timeout - (now - start)) - if conn.poll(real_timeout): - results.append(conn.recv()) - else: - results.append((None, None, None, 'timed out')) - p.terminate() + try: + stdout, _ = proc.communicate(timeout=real_timeout) + result = pickle.loads(stdout) + results.append(result) + except subprocess.TimeoutExpired: + results.append((None, '', '', 'timed out')) + except EOFError: + results.append((None, '', '', 'sandbox violation')) + if proc.poll() is None: + try: + proc_kill(proc, signal.SIGKILL) + except: + pass + return results def exec(self, program): @@ -114,23 +127,21 @@ class PythonSession(server.LanguageSession): hint_type.instantiate(hint, self._sent_hints) self._sent_hints.extend(hints) -def _interpreter(control, callback): - basedir = os.path.split(os.path.dirname(os.path.realpath(__file__)))[0] - script = os.path.join(basedir, 'python', 'runner', 'interpreter.py') - - # If the sandbox wrapper exists, use it to switch to user "nobody" and - # enforce additional limits. Unless the daemon is running as root we are - # not able to signal nobody's PIDs, so switch user again for the killing. - sandbox = os.path.join(basedir, 'python', 'runner', 'sandbox') - terminator = os.path.join(basedir, 'python', 'runner', 'terminator') - if os.path.exists(sandbox) and os.path.exists(terminator): - newuser = 'nobody' # TODO make this configurable - args = [sandbox, newuser, script] - kill = lambda proc, sig: subprocess.call([terminator, newuser, str(proc.pid), str(sig)]) - else: - args = [script] - kill = lambda proc, sig: proc.send_signal(sig) +# If the sandbox wrapper exists, use it to switch to user "nobody" and enforce +# additional limits. Unless the daemon is running as root we are not able to +# signal nobody's PIDs, so switch user again for the killing. +_basedir = os.path.split(os.path.dirname(os.path.realpath(__file__)))[0] +_script = os.path.join(_basedir, 'python', 'runner', 'interpreter.py') +_sandbox = os.path.join(_basedir, 'python', 'runner', 'sandbox') +if os.path.exists(_sandbox): + newuser = 'nobody' # TODO make this configurable + proc_args = [_sandbox, newuser, _script] + proc_kill = lambda proc, sig: subprocess.call([_sandbox, newuser, 'kill', '-{}'.format(sig), str(proc.pid)]) +else: + proc_args = [_script] + proc_kill = lambda proc, sig: proc.send_signal(sig) +def _interpreter(control, callback): done = False proc = None @@ -156,7 +167,7 @@ def _interpreter(control, callback): proc.stdin.write(data.encode('utf-8')) proc.stdin.flush() elif cmd == 'stop': - kill(proc, signal.SIGINT) + proc_kill(proc, signal.SIGINT) elif cmd == 'done': done = True except: @@ -181,7 +192,7 @@ def _interpreter(control, callback): selector.unregister(conn) if proc.poll() is None: # Process has not terminated yet, make sure it does. - kill(proc, signal.SIGKILL) + proc_kill(proc, signal.SIGKILL) proc = None length = newlines = 0 callback('Interpreter restarted.\n') @@ -190,7 +201,7 @@ def _interpreter(control, callback): while not done: # Ensure the interpreter process is running. if proc is None: - proc = subprocess.Popen(args, + proc = subprocess.Popen(proc_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) @@ -210,42 +221,6 @@ def _interpreter(control, callback): # We are done, kill the child. selector.close() if proc is not None: - kill(proc, signal.SIGKILL) - -# Execute [code] and evaluate [expr]. Input is given by the string [stdin]. -# Return result of evaluation, the contents of stdout and stderr, and the -# exception traceback. -# TODO sandbox this -def _run_exec(conn, code, expr=None, stdin=''): - result, out, err, exc = None, None, None, None - sys.stdin = io.StringIO(stdin) - sys.stdout = io.StringIO() - sys.stderr = io.StringIO() - try: - env = {} - if code: - exec(code, env) - if expr: - result = eval(expr, env) - except Exception as ex: - # Exception is not JSON serializable, so return traceback as string - # (without the first entry, which is this function). - import traceback - e_type, e_value, e_tb = sys.exc_info() - stack = traceback.extract_tb(e_tb) - exc = ''.join( - ['Traceback (most recent call last):\n'] + - [' line {}, in {}\n'.format(lineno, name) + (line+'\n' if line else '') - for filename, lineno, name, line in stack[1:]] + - traceback.format_exception_only(e_type, e_value) - ).rstrip() - finally: - out = sys.stdout.getvalue() - err = sys.stderr.getvalue() - sys.stdin.close() - sys.stdout.close() - sys.stderr.close() - conn.send((result, out, err, exc)) + proc_kill(proc, signal.SIGKILL) server.language_session_handlers['python'] = lambda user_session, problem_id, language_identifier, group_identifier, problem_identifier: PythonSession(lambda text: user_session.send({'event': 'terminal_output', 'text': text})) - -- cgit v1.2.1