Skip to content

Commit ae028fa

Browse files
committed
Sandbox all Dangerzone document processing within gVisor.
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. Fixes #126 Fixes #224 Fixes #225 Fixes #228
1 parent 674fa79 commit ae028fa

File tree

6 files changed

+298
-28
lines changed

6 files changed

+298
-28
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or
1212
- Fix a deprecation warning in PySide6, thanks to [@naglis](https://github.com/naglis) ([issue #595](https://github.com/freedomofpress/dangerzone/issues/595))
1313
- Make update notifications work in systems with PySide2, thanks to [@naglis](https://github.com/naglis) ([issue #788](https://github.com/freedomofpress/dangerzone/issues/788))
1414

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

1726
### Added

Dockerfile

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ RUN mkdir /libreoffice_ext && cd libreoffice_ext \
5050
###########################################
5151
# Dangerzone image
5252

53-
FROM alpine:latest
53+
FROM alpine:latest AS dangerzone-image
5454

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

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

71-
ENV PYTHONPATH=/opt/dangerzone
72-
7371
RUN mkdir -p /opt/dangerzone/dangerzone
7472
RUN touch /opt/dangerzone/dangerzone/__init__.py
7573
COPY conversion /opt/dangerzone/dangerzone/conversion
7674

77-
# Add the unprivileged user
78-
RUN adduser -s /bin/sh -D dangerzone
75+
# Add the unprivileged user. Set the UID/GID of the dangerzone user/group to
76+
# 1000, since we will point to it from the OCI config.
77+
#
78+
# NOTE: A tmpfs will be mounted over /home/dangerzone directory,
79+
# so nothing within it from the image will be persisted.
80+
RUN addgroup -g 1000 dangerzone && \
81+
adduser -u 1000 -s /bin/true -G dangerzone -h /home/dangerzone -D dangerzone
82+
83+
###########################################
84+
# gVisor wrapper image
85+
86+
FROM alpine:latest
87+
88+
RUN apk --no-cache -U upgrade && \
89+
apk --no-cache add python3
90+
91+
RUN GVISOR_URL="https://storage.googleapis.com/gvisor/releases/release/latest/$(uname -m)"; \
92+
wget "${GVISOR_URL}/runsc" "${GVISOR_URL}/runsc.sha512" && \
93+
sha512sum -c runsc.sha512 && \
94+
rm -f runsc.sha512 && \
95+
chmod 555 runsc && \
96+
mv runsc /usr/bin/
97+
98+
# Add the unprivileged `dangerzone` user.
99+
RUN addgroup dangerzone && \
100+
adduser -s /bin/true -G dangerzone -h /home/dangerzone -D dangerzone
101+
102+
# Switch to the dangerzone user for the rest of the script.
79103
USER dangerzone
80104

81-
# /safezone is a directory through which Pixels to PDF receives files
82-
VOLUME /safezone
105+
# Copy the Dangerzone image, as created by the previous steps, into the home
106+
# directory of the `dangerzone` user.
107+
RUN mkdir /home/dangerzone/dangerzone-image
108+
COPY --from=dangerzone-image / /home/dangerzone/dangerzone-image/rootfs
109+
110+
# Create a directory that will be used by gVisor as the place where it will
111+
# store the state of its containers.
112+
RUN mkdir /home/dangerzone/.containers
113+
114+
COPY gvisor_wrapper/entrypoint.py /
115+
116+
ENTRYPOINT ["/entrypoint.py"]

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ See [installing Dangerzone](INSTALL.md#linux) for adding the Linux repositories
3737
## Some features
3838

3939
- Sandboxes don't have network access, so if a malicious document can compromise one, it can't phone home
40+
- Sandboxes use [gVisor](https://gvisor.dev/), an application kernel written in Go, that implements a substantial portion of the Linux system call interface.
4041
- Dangerzone can optionally OCR the safe PDFs it creates, so it will have a text layer again
4142
- Dangerzone compresses the safe PDF to reduce file size
4243
- 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
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/python3
2+
3+
import json
4+
import os
5+
import shlex
6+
import subprocess
7+
import sys
8+
import typing
9+
10+
# This script wraps the command-line arguments passed to it to run as an
11+
# unprivileged user in a gVisor sandbox.
12+
# Its behavior can be modified with the following environment variables:
13+
# RUNSC_DEBUG: If set, print debug messages to stderr, and log all gVisor
14+
# output to stderr.
15+
# RUNSC_FLAGS: If set, pass these flags to the `runsc` invocation.
16+
# These environment variables are not passed on to the sandboxed process.
17+
18+
19+
def log(message: str, *values: typing.Any) -> None:
20+
"""Helper function to log messages if RUNSC_DEBUG is set."""
21+
if os.environ.get("RUNSC_DEBUG"):
22+
print(message.format(*values), file=sys.stderr)
23+
24+
25+
command = sys.argv[1:]
26+
if len(command) == 0:
27+
log("Invoked without a command; will execute 'sh'.")
28+
command = ["sh"]
29+
else:
30+
log("Invoked with command: {}", " ".join(shlex.quote(s) for s in command))
31+
32+
# Build and write container OCI config.
33+
oci_config: dict[str, typing.Any] = {
34+
"ociVersion": "1.0.0",
35+
"process": {
36+
"user": {
37+
# Hardcode the UID/GID of the container image to 1000, since we're in
38+
# control of the image creation, and we don't expect it to change.
39+
"uid": 1000,
40+
"gid": 1000,
41+
},
42+
"args": command,
43+
"env": [
44+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
45+
"PYTHONPATH=/opt/dangerzone",
46+
"TERM=xterm",
47+
],
48+
"cwd": "/",
49+
"capabilities": {
50+
"bounding": [],
51+
"effective": [],
52+
"inheritable": [],
53+
"permitted": [],
54+
},
55+
"rlimits": [
56+
{"type": "RLIMIT_NOFILE", "hard": 4096, "soft": 4096},
57+
],
58+
},
59+
"root": {"path": "rootfs", "readonly": True},
60+
"hostname": "dangerzone",
61+
"mounts": [
62+
{
63+
"destination": "/proc",
64+
"type": "proc",
65+
"source": "proc",
66+
},
67+
{
68+
"destination": "/dev",
69+
"type": "tmpfs",
70+
"source": "tmpfs",
71+
"options": ["nosuid", "noexec", "nodev"],
72+
},
73+
{
74+
"destination": "/sys",
75+
"type": "tmpfs",
76+
"source": "tmpfs",
77+
"options": ["nosuid", "noexec", "nodev", "ro"],
78+
},
79+
{
80+
"destination": "/tmp",
81+
"type": "tmpfs",
82+
"source": "tmpfs",
83+
"options": ["nosuid", "noexec", "nodev"],
84+
},
85+
# LibreOffice needs a writable home directory, so just mount a tmpfs
86+
# over it.
87+
{
88+
"destination": "/home/dangerzone",
89+
"type": "tmpfs",
90+
"source": "tmpfs",
91+
"options": ["nosuid", "noexec", "nodev"],
92+
},
93+
# Used for LibreOffice extensions, which are only conditionally
94+
# installed depending on which file is being converted.
95+
{
96+
"destination": "/usr/lib/libreoffice/share/extensions/",
97+
"type": "tmpfs",
98+
"source": "tmpfs",
99+
"options": ["nosuid", "noexec", "nodev"],
100+
},
101+
],
102+
"linux": {
103+
"namespaces": [
104+
{"type": "pid"},
105+
{"type": "network"},
106+
{"type": "ipc"},
107+
{"type": "uts"},
108+
{"type": "mount"},
109+
],
110+
},
111+
}
112+
not_forwarded_env = set(
113+
(
114+
"PATH",
115+
"HOME",
116+
"SHLVL",
117+
"HOSTNAME",
118+
"TERM",
119+
"PWD",
120+
"RUNSC_FLAGS",
121+
"RUNSC_DEBUG",
122+
)
123+
)
124+
for key_val in oci_config["process"]["env"]:
125+
not_forwarded_env.add(key_val[: key_val.index("=")])
126+
for key, val in os.environ.items():
127+
if key in not_forwarded_env:
128+
continue
129+
oci_config["process"]["env"].append("%s=%s" % (key, val))
130+
if os.environ.get("RUNSC_DEBUG"):
131+
log("Command inside gVisor sandbox: {}", command)
132+
log("OCI config:")
133+
json.dump(oci_config, sys.stderr, indent=2, sort_keys=True)
134+
# json.dump doesn't print a trailing newline, so print one here:
135+
log("")
136+
with open("/home/dangerzone/dangerzone-image/config.json", "w") as oci_config_out:
137+
json.dump(oci_config, oci_config_out, indent=2, sort_keys=True)
138+
139+
# Run gVisor.
140+
runsc_argv = [
141+
"/usr/bin/runsc",
142+
"--rootless=true",
143+
"--network=none",
144+
"--root=/home/dangerzone/.containers",
145+
]
146+
if os.environ.get("RUNSC_DEBUG"):
147+
runsc_argv += ["--debug=true", "--alsologtostderr=true"]
148+
if os.environ.get("RUNSC_FLAGS"):
149+
runsc_argv += [x for x in shlex.split(os.environ.get("RUNSC_FLAGS", "")) if x]
150+
runsc_argv += ["run", "--bundle=/home/dangerzone/dangerzone-image", "dangerzone"]
151+
log(
152+
"Running gVisor with command line: {}", " ".join(shlex.quote(s) for s in runsc_argv)
153+
)
154+
runsc_process = subprocess.run(
155+
runsc_argv,
156+
check=False,
157+
)
158+
log("gVisor quit with exit code: {}", runsc_process.returncode)
159+
160+
# We're done.
161+
sys.exit(runsc_process.returncode)

dangerzone/isolation_provider/container.py

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,49 @@ def get_runtime() -> str:
8585
raise NoContainerTechException(container_tech)
8686
return runtime
8787

88+
@staticmethod
89+
def get_runtime_security_args() -> List[str]:
90+
"""Security options applicable to the outer Dangerzone container.
91+
92+
Our security precautions for the outer Dangerzone container are the following:
93+
* Do not let the container assume new privileges.
94+
* Drop all capabilities, except for CAP_SYS_CHROOT, which is necessary for
95+
running gVisor.
96+
* Do not allow access to the network stack.
97+
* Run the container as the unprivileged `dangerzone` user.
98+
99+
For Podman specifically, where applicable, we also add the following:
100+
* Do not log the container's output.
101+
* Use a newer seccomp policy (for Podman 3.x versions only).
102+
* Do not map the host user to the container, with `--userns nomap` (available
103+
from Podman 4.1 onwards)
104+
- This particular argument is specified in `start_doc_to_pixels_proc()`, but
105+
should move here once #748 is merged.
106+
"""
107+
if Container.get_runtime_name() == "podman":
108+
security_args = ["--log-driver", "none"]
109+
security_args += ["--security-opt", "no-new-privileges"]
110+
111+
# NOTE: Ubuntu Focal/Jammy have Podman version 3, and their seccomp policy
112+
# does not include the `ptrace()` syscall. This system call is required for
113+
# running gVisor, so we enforce a newer seccomp policy file in that case.
114+
# This file has been copied as is [1] from the official Podman repo.
115+
#
116+
# [1] https://github.com/containers/common/blob/d3283f8401eeeb21f3c59a425b5461f069e199a7/pkg/seccomp/seccomp.json
117+
if Container.get_runtime_version() < (4, 0):
118+
seccomp_json_path = get_resource_path("seccomp.gvisor.json")
119+
security_args += ["--security-opt", f"seccomp={seccomp_json_path}"]
120+
else:
121+
security_args = ["--security-opt=no-new-privileges:true"]
122+
123+
security_args += ["--cap-drop", "all"]
124+
security_args += ["--cap-add", "SYS_CHROOT"]
125+
126+
security_args += ["--network=none"]
127+
security_args += ["-u", "dangerzone"]
128+
129+
return security_args
130+
88131
@staticmethod
89132
def install() -> bool:
90133
"""
@@ -218,25 +261,12 @@ def exec_container(
218261
extra_args: List[str] = [],
219262
) -> subprocess.Popen:
220263
container_runtime = self.get_runtime()
221-
222-
if self.get_runtime_name() == "podman":
223-
security_args = ["--log-driver", "none"]
224-
security_args += ["--security-opt", "no-new-privileges"]
225-
security_args += ["--userns", "keep-id"]
226-
else:
227-
security_args = ["--security-opt=no-new-privileges:true"]
228-
229-
# drop all linux kernel capabilities
230-
security_args += ["--cap-drop", "all"]
231-
user_args = ["-u", "dangerzone"]
264+
security_args = self.get_runtime_security_args()
232265
enable_stdin = ["-i"]
233266
set_name = ["--name", name]
234-
235267
prevent_leakage_args = ["--rm"]
236-
237268
args = (
238-
["run", "--network", "none"]
239-
+ user_args
269+
["run"]
240270
+ security_args
241271
+ prevent_leakage_args
242272
+ enable_stdin
@@ -245,7 +275,6 @@ def exec_container(
245275
+ [self.CONTAINER_NAME]
246276
+ command
247277
)
248-
249278
args = [container_runtime] + args
250279
return self.exec(args)
251280

@@ -291,6 +320,36 @@ def pixels_to_pdf(
291320
"-e",
292321
f"OCR_LANGUAGE={ocr_lang}",
293322
]
323+
# XXX: Until #748 gets merged, we have to run our pixels to PDF phase in a
324+
# container, which involves mounting two temp dirs. This does not bode well with
325+
# gVisor for two reasons:
326+
#
327+
# 1. Our gVisor integration chroot()s into /home/dangerzone/dangerzone-image/rootfs,
328+
# meaning that the location of the temp dirs must be relevant to that path.
329+
# 2. Reading and writing to these temp dirs requires permissions which are not
330+
# available to the user within gVisor's user namespace.
331+
#
332+
# For these reasons, and because the pixels to PDF phase is more trusted (and
333+
# will soon stop being containerized), we circumvent gVisor support by doing the
334+
# following:
335+
#
336+
# 1. Override our entrypoint script with a no-op command (/usr/bin/env).
337+
# 2. Set the PYTHONPATH so that we can import the Python code within
338+
# /home/dangerzone/dangerzone-image/rootfs
339+
# 3. Run the container as the root user, so that it can always write to the
340+
# mounted directories. This container is trusted, so running as root has no
341+
# impact to the security of Dangerzone.
342+
img_root = "/home/dangerzone/dangerzone-image/rootfs"
343+
extra_args += [
344+
"--entrypoint",
345+
"/usr/bin/env",
346+
"-e",
347+
f"PYTHONPATH={img_root}/opt/dangerzone:{img_root}/usr/lib/python3.12/site-packages",
348+
"-e",
349+
f"TESSDATA_PREFIX={img_root}/usr/share/tessdata",
350+
"-u",
351+
"root",
352+
]
294353

295354
name = self.pixels_to_pdf_container_name(document)
296355
pixels_to_pdf_proc = self.exec_container(command, name, extra_args)
@@ -329,8 +388,15 @@ def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen:
329388
"-m",
330389
"dangerzone.conversion.doc_to_pixels",
331390
]
391+
# NOTE: Using `--userns nomap` is available only on Podman >= 4.1.0.
392+
# XXX: Move this under `get_runtime_security_args()` once #748 is merged.
393+
extra_args = []
394+
if Container.get_runtime_name() == "podman":
395+
if Container.get_runtime_version() >= (4, 1):
396+
extra_args += ["--userns", "nomap"]
397+
332398
name = self.doc_to_pixels_container_name(document)
333-
return self.exec_container(command, name=name)
399+
return self.exec_container(command, name=name, extra_args=extra_args)
334400

335401
def terminate_doc_to_pixels_proc(
336402
self, document: Document, p: subprocess.Popen

0 commit comments

Comments
 (0)