Isolation of libraries is surprisingly easy

My general attitude towards running services is that they should sit at the bottom of a deep dark hole, where requests arrive, get processed within the service, and then a response is emitted. But what happens if you have a library that is itself a danger to the rest of the service? I certainly wouldn’t want a memory corruption bug in a library to negatively affect the service as a whole.

So let’s say I have no choice but to deploy some potentially insecure native-code library. In this example we’ll pick on libmagic. Really we should be using polyfile, but let’s not worry about that right now. Is it possible to isolate any potential libmagic memory corruption issues from my service as a whole?

Yep, it’s quite possible, maybe even easy. When you want to handle a request, you can fork a subprocess, drop privileges, and perform the operation, and return results back to the main process. The simplest and probably most secure method would be to do this as a single-shot operation, returning results to stdout.

In the example below I do something slightly more complex, using pyro5 to establish (what could be) a long-lived server process just for answering queries to libmagic. This maximizes performance (fewer forks), but at the cost that Request 1 could corrupt the results seen by Request 2.

Isolation is enforced by seccomp policy, ensuring that the Python process is only able to open files read-only (it needs to import code after all), and operate on existing file descriptors. Like all good examples, we’re leaving two exercises to the reader:

  1. only allow opening the specific files that Python might need to import
  2. prevent resource exhuastion issues (e.g. mmap in a tight loop)

The whole shebang is less than 100 lines of Python.

import os
import socket
import sys
import Pyro5.api
import Pyro5.server
import pyseccomp as seccomp
import serpent


@Pyro5.server.expose
class MagicServer:
    def from_buffer(self, data):
        from magic import from_buffer

        data = serpent.tobytes(data)
        return from_buffer(data)


def pyro5_server(sock):
    with Pyro5.api.Daemon(connected_socket=sock) as daemon:
        daemon.register(MagicServer, objectId="magic.server")
        daemon.requestLoop()


def drop_privileges(sock):
    # I would also use namespaces, but most of the interesting ones require
    # CAP_SYS_ADMIN
    sc_filter = seccomp.SyscallFilter(seccomp.ERRNO(seccomp.errno.EPERM))
    fileno = sock.fileno()

    sc_filter.add_rule(
        seccomp.ALLOW, "sendto", seccomp.Arg(0, seccomp.EQ, fileno)
    )

    sc_filter.add_rule(
        seccomp.ALLOW, "recvfrom", seccomp.Arg(0, seccomp.EQ, fileno)
    )
    sc_filter.add_rule(seccomp.ALLOW, "read")
    sc_filter.add_rule(seccomp.ALLOW, "write")
    sc_filter.add_rule(seccomp.ALLOW, "close")
    sc_filter.add_rule(seccomp.ALLOW, "lseek")
    sc_filter.add_rule(seccomp.ALLOW, "getpeername")
    sc_filter.add_rule(seccomp.ALLOW, "getsockname")
    sc_filter.add_rule(seccomp.ALLOW, "getrandom")
    sc_filter.add_rule(seccomp.ALLOW, "mmap")
    sc_filter.add_rule(seccomp.ALLOW, "munmap")
    sc_filter.add_rule(seccomp.ALLOW, "shutdown")
    sc_filter.add_rule(seccomp.ALLOW, "mprotect")
    sc_filter.add_rule(seccomp.ALLOW, "uname")

    # A bpf filter to restrict to sys.path would be nice
    sc_filter.add_rule(seccomp.ALLOW, "getdents64")
    sc_filter.add_rule(seccomp.ALLOW, "newfstatat")

    allowed_flags = os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC | os.O_DIRECTORY
    disallowed_flags = (1 << 32) - 1 - allowed_flags
    sc_filter.add_rule(
        seccomp.ALLOW,
        "openat",
        seccomp.Arg(0, seccomp.EQ, 4294967196),
        seccomp.Arg(2, seccomp.MASKED_EQ, disallowed_flags, 0),
    )

    sc_filter.load()


def parent_process(sock):
    buf = open(sys.argv[1], "rb").read(2048)
    with Pyro5.api.Proxy("magic.server", connected_socket=sock) as magic_proxy:
        result = magic_proxy.from_buffer(buf)
        print("Magic type:", result)


def main():
    parent_sock, child_sock = socket.socketpair()
    pid = os.fork()

    if pid == 0:
        parent_sock.close()
        drop_privileges(child_sock)
        pyro5_server(child_sock)
    else:
        child_sock.close()
        parent_process(parent_sock)


if __name__ == "__main__":
    raise SystemExit(main())