Skip to content

Commit

Permalink
Sandbox all Dangerzone document processing within gVisor.
Browse files Browse the repository at this point in the history
This wraps the existing container image inside a gVisor-based sandbox.

gVisor is an open-source OCI-compliant container runtime.
It is a userspace reimplementation of the Linux kernel in a
memory-safe language.

It works by creating a sandboxed environment in which regular Linux
applications run, but their system calls are intercepted by gVisor.
gVisor then redirects these system calls and reinterprets them in
its own kernel. This means the host Linux kernel is isolated
from the sandboxed application, thereby providing protection against
Linux container escape attacks.

It also uses `seccomp-bpf` to provide a secondary layer of defense
against container escapes. Even if its userspace kernel gets
compromised, attackers would have to additionally have a Linux
container escape vector, and that exploit would have to fit within
the restricted `seccomp-bpf` rules that gVisor adds on itself.
  • Loading branch information
EtiennePerot committed Jun 12, 2024
1 parent 674fa79 commit 215d66a
Show file tree
Hide file tree
Showing 6 changed files with 298 additions and 28 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or
- Fix a deprecation warning in PySide6, thanks to [@naglis](https://github.com/naglis) ([issue #595](https://github.com/freedomofpress/dangerzone/issues/595))
- Make update notifications work in systems with PySide2, thanks to [@naglis](https://github.com/naglis) ([issue #788](https://github.com/freedomofpress/dangerzone/issues/788))

### Security

- Integrate Dangerzone with gVisor, a memory-safe application kernel, thanks to [@EtiennePerot](https://github.com/EtiennePerot) ([#126](https://github.com/freedomofpress/dangerzone/issues/126))
As a result of this integration, we have also improved Dangerzone's security
in the following ways:
* Prevent attacker from becoming root within the container ([#224](https://github.com/freedomofpress/dangerzone/issues/224))
* Use a restricted seccomp profile ([#225](https://github.com/freedomofpress/dangerzone/issues/225))
* Make use of user namespaces ([#228](https://github.com/freedomofpress/dangerzone/issues/228))

## Dangerzone 0.6.1

### Added
Expand Down
48 changes: 41 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ RUN mkdir /libreoffice_ext && cd libreoffice_ext \
###########################################
# Dangerzone image

FROM alpine:latest
FROM alpine:latest AS dangerzone-image

# Install dependencies
RUN apk --no-cache -U upgrade && \
Expand All @@ -68,15 +68,49 @@ COPY --from=h2orestart-dl /libreoffice_ext/ /libreoffice_ext

RUN install -dm777 "/usr/lib/libreoffice/share/extensions/"

ENV PYTHONPATH=/opt/dangerzone

RUN mkdir -p /opt/dangerzone/dangerzone
RUN touch /opt/dangerzone/dangerzone/__init__.py
COPY conversion /opt/dangerzone/dangerzone/conversion

# Add the unprivileged user
RUN adduser -s /bin/sh -D dangerzone
# Add the unprivileged user. Set the UID/GID of the dangerzone user/group to
# 1000, since we will point to it from the OCI config.
#
# NOTE: A tmpfs will be mounted over /home/dangerzone directory,
# so nothing within it from the image will be persisted.
RUN addgroup -g 1000 dangerzone && \
adduser -u 1000 -s /bin/true -G dangerzone -h /home/dangerzone -D dangerzone

###########################################
# gVisor wrapper image

FROM alpine:latest

RUN apk --no-cache -U upgrade && \
apk --no-cache add python3

RUN GVISOR_URL="https://storage.googleapis.com/gvisor/releases/release/latest/$(uname -m)"; \
wget "${GVISOR_URL}/runsc" "${GVISOR_URL}/runsc.sha512" && \
sha512sum -c runsc.sha512 && \
rm -f runsc.sha512 && \
chmod 555 runsc && \
mv runsc /usr/bin/

# Add the unprivileged `dangerzone` user.
RUN addgroup dangerzone && \
adduser -s /bin/true -G dangerzone -h /home/dangerzone -D dangerzone

# Switch to the dangerzone user for the rest of the script.
USER dangerzone

# /safezone is a directory through which Pixels to PDF receives files
VOLUME /safezone
# Copy the Dangerzone image, as created by the previous steps, into the home
# directory of the `dangerzone` user.
RUN mkdir /home/dangerzone/dangerzone-image
COPY --from=dangerzone-image / /home/dangerzone/dangerzone-image/rootfs

# Create a directory that will be used by gVisor as the place where it will
# store the state of its containers.
RUN mkdir /home/dangerzone/.containers

COPY gvisor_wrapper/entrypoint.py /

ENTRYPOINT ["/entrypoint.py"]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ See [installing Dangerzone](INSTALL.md#linux) for adding the Linux repositories
## Some features

- Sandboxes don't have network access, so if a malicious document can compromise one, it can't phone home
- Sandboxes use [gVisor](https://gvisor.dev/), an application kernel written in Go, that implements a substantial portion of the Linux system call interface.
- Dangerzone can optionally OCR the safe PDFs it creates, so it will have a text layer again
- Dangerzone compresses the safe PDF to reduce file size
- After converting, Dangerzone lets you open the safe PDF in the PDF viewer of your choice, which allows you to open PDFs and office docs in Dangerzone by default so you never accidentally open a dangerous document
Expand Down
161 changes: 161 additions & 0 deletions dangerzone/gvisor_wrapper/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/python3

import json
import os
import shlex
import subprocess
import sys
import typing

# This script wraps the command-line arguments passed to it to run as an
# unprivileged user in a gVisor sandbox.
# Its behavior can be modified with the following environment variables:
# RUNSC_DEBUG: If set, print debug messages to stderr, and log all gVisor
# output to stderr.
# RUNSC_FLAGS: If set, pass these flags to the `runsc` invocation.
# These environment variables are not passed on to the sandboxed process.


def log(message: str, *values: typing.Any) -> None:
"""Helper function to log messages if RUNSC_DEBUG is set."""
if os.environ.get("RUNSC_DEBUG"):
print(message.format(*values), file=sys.stderr)


command = sys.argv[1:]
if len(command) == 0:
log("Invoked without a command; will execute 'sh'.")
command = ["sh"]
else:
log("Invoked with command: {}", " ".join(shlex.quote(s) for s in command))

# Build and write container OCI config.
oci_config: dict[str, typing.Any] = {
"ociVersion": "1.0.0",
"process": {
"user": {
# Hardcode the UID/GID of the container image to 1000, since we're in
# control of the image creation, and we don't expect it to change.
"uid": 1000,
"gid": 1000,
},
"args": command,
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"PYTHONPATH=/opt/dangerzone",
"TERM=xterm",
],
"cwd": "/",
"capabilities": {
"bounding": [],
"effective": [],
"inheritable": [],
"permitted": [],
},
"rlimits": [
{"type": "RLIMIT_NOFILE", "hard": 4096, "soft": 4096},
],
},
"root": {"path": "rootfs", "readonly": True},
"hostname": "dangerzone",
"mounts": [
{
"destination": "/proc",
"type": "proc",
"source": "proc",
},
{
"destination": "/dev",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
{
"destination": "/sys",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/tmp",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
# LibreOffice needs a writable home directory, so just mount a tmpfs
# over it.
{
"destination": "/home/dangerzone",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
# Used for LibreOffice extensions, which are only conditionally
# installed depending on which file is being converted.
{
"destination": "/usr/lib/libreoffice/share/extensions/",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
],
"linux": {
"namespaces": [
{"type": "pid"},
{"type": "network"},
{"type": "ipc"},
{"type": "uts"},
{"type": "mount"},
],
},
}
not_forwarded_env = set(
(
"PATH",
"HOME",
"SHLVL",
"HOSTNAME",
"TERM",
"PWD",
"RUNSC_FLAGS",
"RUNSC_DEBUG",
)
)
for key_val in oci_config["process"]["env"]:
not_forwarded_env.add(key_val[: key_val.index("=")])
for key, val in os.environ.items():
if key in not_forwarded_env:
continue
oci_config["process"]["env"].append("%s=%s" % (key, val))
if os.environ.get("RUNSC_DEBUG"):
log("Command inside gVisor sandbox: {}", command)
log("OCI config:")
json.dump(oci_config, sys.stderr, indent=2, sort_keys=True)
# json.dump doesn't print a trailing newline, so print one here:
log("")
with open("/home/dangerzone/dangerzone-image/config.json", "w") as oci_config_out:
json.dump(oci_config, oci_config_out, indent=2, sort_keys=True)

# Run gVisor.
runsc_argv = [
"/usr/bin/runsc",
"--rootless=true",
"--network=none",
"--root=/home/dangerzone/.containers",
]
if os.environ.get("RUNSC_DEBUG"):
runsc_argv += ["--debug=true", "--alsologtostderr=true"]
if os.environ.get("RUNSC_FLAGS"):
runsc_argv += [x for x in shlex.split(os.environ.get("RUNSC_FLAGS", "")) if x]
runsc_argv += ["run", "--bundle=/home/dangerzone/dangerzone-image", "dangerzone"]
log(
"Running gVisor with command line: {}", " ".join(shlex.quote(s) for s in runsc_argv)
)
runsc_process = subprocess.run(
runsc_argv,
check=False,
)
log("gVisor quit with exit code: {}", runsc_process.returncode)

# We're done.
sys.exit(runsc_process.returncode)
100 changes: 83 additions & 17 deletions dangerzone/isolation_provider/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,49 @@ def get_runtime() -> str:
raise NoContainerTechException(container_tech)
return runtime

@staticmethod
def get_runtime_security_args() -> List[str]:
"""Security options applicable to the outer Dangerzone container.
Our security precautions for the outer Dangerzone container are the following:
* Do not let the container assume new privileges.
* Drop all capabilities, except for CAP_SYS_CHROOT, which is necessary for
running gVisor.
* Do not allow access to the network stack.
* Run the container as the unprivileged `dangerzone` user.
For Podman specifically, where applicable, we also add the following:
* Do not log the container's output.
* Use a newer seccomp policy (for Podman 3.x versions only).
* Do not map the host user to the container, with `--userns nomap` (available
from Podman 4.1 onwards)
- This particular argument is specified in `start_doc_to_pixels_proc()`, but
should move here once #748 is merged.
"""
if Container.get_runtime_name() == "podman":
security_args = ["--log-driver", "none"]
security_args += ["--security-opt", "no-new-privileges"]

# NOTE: Ubuntu Focal/Jammy have Podman version 3, and their seccomp policy
# does not include the `ptrace()` syscall. This system call is required for
# running gVisor, so we enforce a newer seccomp policy file in that case.
# This file has been copied as is [1] from the official Podman repo.
#
# [1] https://github.com/containers/common/blob/d3283f8401eeeb21f3c59a425b5461f069e199a7/pkg/seccomp/seccomp.json
if Container.get_runtime_version() < (4, 0):
seccomp_json_path = get_resource_path("seccomp.gvisor.json")
security_args += ["--security-opt", f"seccomp={seccomp_json_path}"]
else:
security_args = ["--security-opt=no-new-privileges:true"]

security_args += ["--cap-drop", "all"]
security_args += ["--cap-add", "SYS_CHROOT"]

security_args += ["--network=none"]
security_args += ["-u", "dangerzone"]

return security_args

@staticmethod
def install() -> bool:
"""
Expand Down Expand Up @@ -218,25 +261,12 @@ def exec_container(
extra_args: List[str] = [],
) -> subprocess.Popen:
container_runtime = self.get_runtime()

if self.get_runtime_name() == "podman":
security_args = ["--log-driver", "none"]
security_args += ["--security-opt", "no-new-privileges"]
security_args += ["--userns", "keep-id"]
else:
security_args = ["--security-opt=no-new-privileges:true"]

# drop all linux kernel capabilities
security_args += ["--cap-drop", "all"]
user_args = ["-u", "dangerzone"]
security_args = self.get_runtime_security_args()
enable_stdin = ["-i"]
set_name = ["--name", name]

prevent_leakage_args = ["--rm"]

args = (
["run", "--network", "none"]
+ user_args
["run"]
+ security_args
+ prevent_leakage_args
+ enable_stdin
Expand All @@ -245,7 +275,6 @@ def exec_container(
+ [self.CONTAINER_NAME]
+ command
)

args = [container_runtime] + args
return self.exec(args)

Expand Down Expand Up @@ -291,6 +320,36 @@ def pixels_to_pdf(
"-e",
f"OCR_LANGUAGE={ocr_lang}",
]
# XXX: Until #748 gets merged, we have to run our pixels to PDF phase in a
# container, which involves mounting two temp dirs. This does not bode well with
# gVisor for two reasons:
#
# 1. Our gVisor integration chroot()s into /home/dangerzone/dangerzone-image/rootfs,
# meaning that the location of the temp dirs must be relevant to that path.
# 2. Reading and writing to these temp dirs requires permissions which are not
# available to the user within gVisor's user namespace.
#
# For these reasons, and because the pixels to PDF phase is more trusted (and
# will soon stop being containerized), we circumvent gVisor support by doing the
# following:
#
# 1. Override our entrypoint script with a no-op command (/usr/bin/env).
# 2. Set the PYTHONPATH so that we can import the Python code within
# /home/dangerzone/dangerzone-image/rootfs
# 3. Run the container as the root user, so that it can always write to the
# mounted directories. This container is trusted, so running as root has no
# impact to the security of Dangerzone.
img_root = "/home/dangerzone/dangerzone-image/rootfs"
extra_args += [
"--entrypoint",
"/usr/bin/env",
"-e",
f"PYTHONPATH={img_root}/opt/dangerzone:{img_root}/usr/lib/python3.12/site-packages",
"-e",
f"TESSDATA_PREFIX={img_root}/usr/share/tessdata",
"-u",
"root",
]

name = self.pixels_to_pdf_container_name(document)
pixels_to_pdf_proc = self.exec_container(command, name, extra_args)
Expand Down Expand Up @@ -329,8 +388,15 @@ def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen:
"-m",
"dangerzone.conversion.doc_to_pixels",
]
# NOTE: Using `--userns nomap` is available only on Podman >= 4.1.0.
# XXX: Move this under `get_runtime_security_args()` once #748 is merged.
extra_args = []
if Container.get_runtime_name() == "podman":
if Container.get_runtime_version() >= (4, 1):
extra_args += ["--userns", "nomap"]

name = self.doc_to_pixels_container_name(document)
return self.exec_container(command, name=name)
return self.exec_container(command, name=name, extra_args=extra_args)

def terminate_doc_to_pixels_proc(
self, document: Document, p: subprocess.Popen
Expand Down
Loading

0 comments on commit 215d66a

Please sign in to comment.