├── .gitignore ├── MANIFEST.in ├── README ├── pysandbox ├── Dockerfile ├── __init__.py ├── __main__.py ├── executor.py ├── namekeeper.py ├── purifier.py └── sww.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pysandbox/Dockerfile 2 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | PySandbox 2 | ------- 3 | 4 | *Unstable* 5 | 6 | PySandbox is a backend for untrusted python sources. It is based on CPython's bytecode format and containers. It works step by step through the pipeline of pysandbox. 7 | 8 | Client > Purifier > PySandbox > Executor > NameKeeper > Container 9 | 10 | Client 11 | ------ 12 | Sends untrusted python code as a raw string. E.g; 13 | 14 | print(2 + 2) 15 | 16 | Purifier 17 | -------- 18 | Checks for any potential threats in code. Raises Insecure exception if sees any. 19 | 20 | PySandbox 21 | ------- 22 | Responsible for managing containers and sending data to them. Takes user code that is checked from Purifier. Compiles it to bytecode, marshals it and encodes it before shipping it via JSON. Starts container in every action of running command. 23 | 24 | Uses container caching by giving id values to every container it started. If given id not found in instances; it will start new and after it is done with it, it will pause instead of kill. `quit` method should be called before exitting. 25 | 26 | The preffered encoding is base64. 27 | PySandbox uses ports in a range of 1765 to nth container running same time 28 | 29 | Executor 30 | -------- 31 | An HTTP service that takes base64 encoded python bytecode. It executes bytecode in a listened environment. Routes standard out and error file descriptors to a buffer and returns it as a json format. That listened environment provided by NameKeeper. 32 | 33 | Request: {'code': ''} 34 | Response (200): {'result': {'out': '', 'err': '-alpine image that contains executor 45 | -------------------------------------------------------------------------------- /pysandbox/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | 3 | COPY sww.py /src/sww.py 4 | COPY namekeeper.py /src/namekeeper.py 5 | COPY executor.py /src/executor.py 6 | CMD ["python", "/src/executor.py"] 7 | -------------------------------------------------------------------------------- /pysandbox/__init__.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import atexit 3 | import json 4 | import marshal 5 | import os 6 | import socket 7 | import time 8 | from base64 import b64encode 9 | from contextlib import contextmanager 10 | from pathlib import Path 11 | from urllib.request import Request, urlopen 12 | 13 | import docker 14 | 15 | from pysandbox.purifier import Insecure, Purifier 16 | 17 | DOCKER_FILE_PATH = os.fspath(Path(__file__).parent) 18 | 19 | class PySandbox: 20 | def __init__(self, docker_client, api_client): 21 | self._docker_client = docker_client 22 | self._api_client = api_client 23 | self._image = self.obtain_image() 24 | 25 | self._instances = {} 26 | self._ports = {} 27 | 28 | self._delay = 1.5 29 | self._purifier = Purifier() 30 | atexit.register(self.quit) 31 | 32 | def run_cmd(self, code, idx=0): 33 | """Runs python code on the containers, 34 | code is self-explaining, step-by-step""" 35 | 36 | tree = ast.parse(code) 37 | try: 38 | self._purifier.visit(tree) 39 | 40 | code = compile(tree, "", "exec") 41 | code = marshal.dumps(code) 42 | code = b64encode(code) 43 | 44 | payload = {"code": code.decode()} 45 | payload = json.dumps(payload) 46 | 47 | with self.run_instance(idx) as container: 48 | request = Request( 49 | f"http://localhost:{self._ports[container]}", 50 | data=payload.encode(), 51 | method="POST", 52 | ) 53 | 54 | response = urlopen(request) 55 | response = json.loads(response.read().decode()) 56 | 57 | except Insecure as exc: 58 | response = {"result": "FAIL", "msg": exc} 59 | 60 | return response 61 | 62 | @contextmanager 63 | def run_instance(self, idx): 64 | new = self._instances.get(idx) 65 | if new: 66 | container = new 67 | else: 68 | port = self._get_free_port() 69 | container = self._docker_client.containers.run( 70 | "pysandbox", ports={"18888/tcp": port}, detach=True 71 | ) 72 | self._ports[container] = port 73 | self._instances[idx] = container 74 | try: 75 | if new: 76 | container.unpause() 77 | else: 78 | time.sleep(self._delay) 79 | yield container 80 | finally: 81 | container.pause() 82 | 83 | def quit(self): 84 | for container in self._instances.copy(): 85 | self.quit_single(container) 86 | 87 | self._ports = {} 88 | 89 | def quit_single(self, idx): 90 | return self._instances.pop(idx).kill() 91 | 92 | def obtain_image(self, force_build = False): 93 | try: 94 | if force_build: 95 | raise docker.errors.ImageNotFound(None) 96 | return self._docker_client.images.get("pysandbox") 97 | except docker.errors.ImageNotFound: 98 | return self._docker_client.images.build( 99 | path=DOCKER_FILE_PATH, tag="pysandbox" 100 | )[0] 101 | 102 | def _get_free_port(self, plus=1): 103 | port = max(self._ports.values(), default=1764) + plus 104 | if self.check_empty_port(port): 105 | return self._get_free_port(plus=(port - 1764) + 1) 106 | else: 107 | return port 108 | 109 | @staticmethod 110 | def check_empty_port(port): 111 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 112 | return not bool(sock.connect_ex(("127.0.0.1", port))) 113 | -------------------------------------------------------------------------------- /pysandbox/__main__.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import docker 4 | 5 | from pysandbox import PySandbox 6 | 7 | 8 | def main(pysandbox): 9 | cmd = "print('hello world')" 10 | while cmd != "!q": 11 | t0 = time.time() 12 | res = pysandbox.run_cmd(cmd) 13 | t1 = time.time() 14 | print(res, f"{t1 - t0} seconds") 15 | cmd = input("cmd> ") 16 | pysandbox.quit() 17 | 18 | 19 | if __name__ == "__main__": 20 | docker_client = docker.from_env() 21 | api_client = docker.APIClient(base_url="unix://var/run/docker.sock") 22 | pysandbox = PySandbox(docker_client, api_client) 23 | main(pysandbox) 24 | -------------------------------------------------------------------------------- /pysandbox/executor.py: -------------------------------------------------------------------------------- 1 | import json 2 | import marshal 3 | import os 4 | from base64 import b64decode 5 | from contextlib import redirect_stderr, redirect_stdout 6 | from http.server import HTTPServer, SimpleHTTPRequestHandler 7 | from io import StringIO 8 | 9 | from .namekeeper import NameKeeper 10 | 11 | MARSHAL_RAISES = (KeyError, ValueError, TypeError, EOFError) 12 | nk = NameKeeper() 13 | 14 | def execute(code): 15 | global nk 16 | 17 | out = StringIO() 18 | err = StringIO() 19 | with redirect_stdout(out), redirect_stderr(err): 20 | _exec = exec 21 | with nk.secure_scope(): 22 | _exec(code) 23 | 24 | return {"out": out.getvalue(), "err": err.getvalue()} 25 | 26 | class Handler(SimpleHTTPRequestHandler): 27 | def do_POST(self, *args, **kwargs): 28 | content_length = int(self.headers["Content-Length"]) 29 | raw_body = self.rfile.read(content_length).decode() 30 | print(raw_body) 31 | body = json.loads(raw_body) 32 | 33 | try: 34 | code = marshal.loads(b64decode(body["code"])) 35 | self.respond(self.execute(code), 200) 36 | except MARSHAL_RAISES as exc: 37 | print(exc) 38 | self.respond("FAIL", 400) 39 | except Exception as exc: 40 | print(exc) 41 | self.respond("FAIL", 500) 42 | 43 | def respond(self, result, code): 44 | self.send_response(code) 45 | self.end_headers() 46 | self.wfile.write(json.dumps({"result": result}).encode()) 47 | 48 | class Executor: 49 | def __init__(self, host="", port=18888, handler=Handler): 50 | self._httpd = HTTPServer((host, port), handler) 51 | self._httpd.serve_forever() 52 | 53 | 54 | if __name__ == "__main__": 55 | executor = Executor(port=os.environ.get("EXECUTOR_PORT", 18888), handler=Handler) 56 | -------------------------------------------------------------------------------- /pysandbox/namekeeper.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import faulthandler 3 | import types 4 | from collections import defaultdict 5 | from contextlib import contextmanager 6 | 7 | from .sww import get_members 8 | 9 | FORBID_ACCESS = ["open", "exec", "eval", "compile", "input"] 10 | FORBID_BY_TYPE_ACCESS = { 11 | types.FunctionType: ["__code__", "__globals__", "__closure__"], 12 | types.FrameType: ["f_locals", "f_globals", "f_builtins", "f_code"], 13 | types.GeneratorType: ["gi_frame", "gi_code"], 14 | type: ["__subclasses__", "__bases__"], 15 | } 16 | 17 | faulthandler.enable() 18 | 19 | 20 | class NameKeeper: 21 | @contextmanager 22 | def secure_scope(self): 23 | _fa = {} 24 | _fbta = defaultdict(dict) 25 | _imp = None 26 | try: 27 | for forbidden in FORBID_ACCESS: 28 | _fa[forbidden] = builtins.__dict__.pop(forbidden) 29 | 30 | for typ, forbids in FORBID_BY_TYPE_ACCESS.items(): 31 | members = get_members(typ) 32 | for forbidden in forbids: 33 | _fbta[typ][forbidden] = members.pop(forbidden) 34 | 35 | _imp = builtins.__import__ 36 | builtins.__import__ = self._dummy 37 | yield 38 | finally: 39 | for unforbidden, val in _fa.items(): 40 | builtins.__dict__[unforbidden] = val 41 | 42 | for typ, unforbids in _fbta.items(): 43 | members = get_members(typ) 44 | for unforbidden, val in unforbids.items(): 45 | members[unforbidden] = val 46 | 47 | builtins.__import__ = _imp 48 | 49 | @staticmethod 50 | def _dummy(*args, **kwargs): 51 | return None 52 | -------------------------------------------------------------------------------- /pysandbox/purifier.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | 4 | class Insecure(Exception): 5 | pass 6 | 7 | 8 | def insecure(func): 9 | def wrapper(instance, node): 10 | try: 11 | func(instance, node) 12 | except Insecure: 13 | raise Insecure( 14 | f"{node.__class__.__name__} found in line {node.lineno}:{node.col_offset}" 15 | ) 16 | 17 | return wrapper 18 | 19 | 20 | class Purifier(ast.NodeVisitor): 21 | """Tries to dedect *basic* security falls. Of course it can 22 | be hacked by using dot access model, bytecode etc. The purpose 23 | is avoiding as much as we can""" 24 | 25 | @insecure 26 | def visit_Import(*args): 27 | raise Insecure 28 | 29 | @insecure 30 | def visit_ImportFrom(*args): 31 | raise Insecure 32 | 33 | @insecure 34 | def visit_Attribute(self, node): 35 | if isinstance(node.value, ast.NameConstant): 36 | raise Insecure 37 | -------------------------------------------------------------------------------- /pysandbox/sww.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | 3 | 4 | class PyObject(ctypes.Structure): 5 | pass 6 | 7 | 8 | PyObject._fields_ = [("ob_refcnt", ctypes.c_int), ("ob_type", ctypes.POINTER(PyObject))] 9 | 10 | 11 | class Members(PyObject): 12 | _fields_ = [("dict", ctypes.POINTER(PyObject))] 13 | 14 | 15 | def get_members(typ: type) -> dict: 16 | attrs = getattr(typ, "__dict__", typ.__name__) 17 | pointer = Members.from_address(id(attrs)) 18 | 19 | _dummy = {} 20 | ctypes.pythonapi.PyDict_SetItem( 21 | ctypes.py_object(_dummy), ctypes.py_object(typ.__name__), pointer.dict 22 | ) 23 | 24 | return _dummy[typ.__name__] 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from setuptools import setup, find_packages 3 | 4 | current_dir = Path(__file__).parent.resolve() 5 | 6 | with open(current_dir / "README", encoding="utf-8") as f: 7 | long_description = f.read() 8 | 9 | setup( 10 | name="PySandbox", 11 | version="0.3.0", 12 | packages=find_packages(), 13 | url="https://github.com/isidentical/pysandbox", 14 | description="Safe evaluation of untrusted code on containers", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | install_requires=['docker'], 18 | ) 19 | --------------------------------------------------------------------------------