Skip to content

Commit 7bb12e4

Browse files
vlaciqkaiser
andcommitted
feat: introduce landlock based sandboxing
Co-authored-by: Quentin Kaiser <[email protected]>
1 parent c9f26e6 commit 7bb12e4

File tree

7 files changed

+224
-6
lines changed

7 files changed

+224
-6
lines changed

tests/test_cli.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
DEFAULT_SKIP_MAGIC,
1818
ExtractionConfig,
1919
)
20+
from unblob.testing import is_sandbox_available
2021
from unblob.ui import (
2122
NullProgressReporter,
2223
ProgressReporter,
@@ -425,3 +426,29 @@ def test_clear_skip_magics(
425426
assert sorted(process_file_mock.call_args.args[0].skip_magic) == sorted(
426427
skip_magic
427428
), fail_message
429+
430+
431+
@pytest.mark.skipif(
432+
not is_sandbox_available(), reason="Sandboxing is only available on Linux"
433+
)
434+
def test_sandbox_escape(tmp_path: Path):
435+
runner = CliRunner()
436+
437+
in_path = tmp_path / "input"
438+
in_path.touch()
439+
extract_dir = tmp_path / "extract-dir"
440+
params = ["--extract-dir", str(extract_dir), str(in_path)]
441+
442+
unrelated_file = tmp_path / "unrelated"
443+
444+
process_file_mock = mock.MagicMock(
445+
side_effect=lambda *_args, **_kwargs: unrelated_file.write_text(
446+
"sandbox escape"
447+
)
448+
)
449+
with mock.patch.object(unblob.cli, "process_file", process_file_mock):
450+
result = runner.invoke(unblob.cli.cli, params)
451+
452+
assert result.exit_code != 0
453+
assert isinstance(result.exception, PermissionError)
454+
process_file_mock.assert_called_once()

tests/test_sandbox.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from unblob.processing import ExtractionConfig
6+
from unblob.sandbox import Sandbox
7+
from unblob.testing import is_sandbox_available
8+
9+
pytestmark = pytest.mark.skipif(
10+
not is_sandbox_available(), reason="Sandboxing only works on Linux"
11+
)
12+
13+
14+
@pytest.fixture
15+
def log_path(tmp_path):
16+
return tmp_path / "unblob.log"
17+
18+
19+
@pytest.fixture
20+
def extraction_config(extraction_config, tmp_path):
21+
extraction_config.extract_root = tmp_path / "extract" / "root"
22+
# parent has to exist
23+
extraction_config.extract_root.parent.mkdir()
24+
return extraction_config
25+
26+
27+
@pytest.fixture
28+
def sandbox(extraction_config: ExtractionConfig, log_path: Path):
29+
return Sandbox(extraction_config, log_path, None)
30+
31+
32+
def test_necessary_resources_can_be_created_in_sandbox(
33+
sandbox: Sandbox, extraction_config: ExtractionConfig, log_path: Path
34+
):
35+
directory_in_extract_root = extraction_config.extract_root / "path" / "to" / "dir"
36+
file_in_extract_root = directory_in_extract_root / "file"
37+
38+
sandbox.run(extraction_config.extract_root.mkdir, parents=True)
39+
sandbox.run(directory_in_extract_root.mkdir, parents=True)
40+
41+
sandbox.run(file_in_extract_root.touch)
42+
sandbox.run(file_in_extract_root.write_text, "file content")
43+
44+
# log-file is already opened
45+
log_path.touch()
46+
sandbox.run(log_path.write_text, "log line")
47+
48+
49+
def test_access_outside_sandbox_is_not_possible(sandbox: Sandbox, tmp_path: Path):
50+
unrelated_dir = tmp_path / "unrelated" / "path"
51+
unrelated_file = tmp_path / "unrelated-file"
52+
53+
with pytest.raises(PermissionError):
54+
sandbox.run(unrelated_dir.mkdir, parents=True)
55+
56+
with pytest.raises(PermissionError):
57+
sandbox.run(unrelated_file.touch)

unblob/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
ExtractionConfig,
3434
process_file,
3535
)
36+
from .sandbox import Sandbox
3637
from .ui import NullProgressReporter, RichConsoleProgressReporter
3738

3839
logger = get_logger()
@@ -321,7 +322,8 @@ def cli(
321322
)
322323

323324
logger.info("Start processing file", file=file)
324-
process_results = process_file(config, file, report_file)
325+
sandbox = Sandbox(config, log_path, report_file)
326+
process_results = sandbox.run(process_file, config, file, report_file)
325327
if verbose == 0:
326328
if skip_extraction:
327329
print_scan_report(process_results)

unblob/pool.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,9 @@ def make_pool(process_num, handler, result_callback) -> Union[SinglePool, MultiP
203203

204204

205205
def _on_terminate(signum, frame):
206-
with contextlib.suppress(StopIteration):
207-
while True:
208-
pool = next(iter(pools))
209-
pool.close(immediate=True)
206+
pools_snapshot = list(pools)
207+
for pool in pools_snapshot:
208+
pool.close(immediate=True)
210209

211210
if callable(orig_signal_handlers[signum]):
212211
orig_signal_handlers[signum](signum, frame)

unblob/processing.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ def get_carve_dir_for(self, path: Path) -> Path:
117117
return self._get_output_path(path.with_name(path.name + self.carve_suffix))
118118

119119

120-
@terminate_gracefully
121120
def process_file(
122121
config: ExtractionConfig, input_path: Path, report_file: Optional[Path] = None
123122
) -> ProcessResult:

unblob/sandbox.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import ctypes
2+
import sys
3+
import threading
4+
from pathlib import Path
5+
from typing import Callable, Iterable, Optional, Type, TypeVar
6+
7+
from structlog import get_logger
8+
from unblob_native.sandbox import (
9+
AccessFS,
10+
SandboxError,
11+
restrict_access,
12+
)
13+
14+
if sys.version_info >= (3, 10):
15+
from typing import ParamSpec
16+
else:
17+
from typing_extensions import ParamSpec
18+
19+
from unblob.processing import ExtractionConfig
20+
21+
logger = get_logger()
22+
23+
P = ParamSpec("P")
24+
R = TypeVar("R")
25+
26+
27+
class Sandbox:
28+
"""Configures restricted file-systems to run functions in.
29+
30+
When calling ``run()``, a separate thread will be configured with
31+
minimum required file-system permissions. All subprocesses spawned
32+
from that thread will honor the restrictions.
33+
"""
34+
35+
def __init__(
36+
self,
37+
config: ExtractionConfig,
38+
log_path: Path,
39+
report_file: Optional[Path],
40+
extra_passthrough: Iterable[AccessFS] = (),
41+
):
42+
self.passthrough = [
43+
# Python, shared libraries, extractor binaries and so on
44+
AccessFS.read("/"),
45+
# Multiprocessing
46+
AccessFS.read_write("/dev/shm"), # noqa: S108
47+
# Extracted contents
48+
AccessFS.read_write(config.extract_root),
49+
AccessFS.make_dir(config.extract_root.parent),
50+
AccessFS.read_write(log_path),
51+
*extra_passthrough,
52+
]
53+
54+
if report_file:
55+
self.passthrough += [
56+
AccessFS.read_write(report_file),
57+
AccessFS.make_reg(report_file.parent),
58+
]
59+
60+
def run(self, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
61+
"""Run callback with restricted filesystem access."""
62+
exception = None
63+
result = None
64+
65+
def _run_in_thread(callback, *args, **kwargs):
66+
nonlocal exception, result
67+
68+
self._try_enter_sandbox()
69+
try:
70+
result = callback(*args, **kwargs)
71+
except BaseException as e:
72+
exception = e
73+
74+
thread = threading.Thread(
75+
target=_run_in_thread, args=(callback, *args), kwargs=kwargs
76+
)
77+
thread.start()
78+
79+
try:
80+
thread.join()
81+
except KeyboardInterrupt:
82+
raise_in_thread(thread, KeyboardInterrupt)
83+
thread.join()
84+
85+
if exception:
86+
raise exception # pyright: ignore[reportGeneralTypeIssues]
87+
return result # pyright: ignore[reportReturnType]
88+
89+
def _try_enter_sandbox(self):
90+
try:
91+
restrict_access(*self.passthrough)
92+
except SandboxError:
93+
logger.warning(
94+
"Sandboxing FS access is unavailable on this system, skipping."
95+
)
96+
97+
98+
def raise_in_thread(thread: threading.Thread, exctype: Type) -> None:
99+
if thread.ident is None:
100+
raise RuntimeError("Thread is not started")
101+
102+
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
103+
ctypes.c_ulong(thread.ident), ctypes.py_object(exctype)
104+
)
105+
106+
# success
107+
if res == 1:
108+
return
109+
110+
# Need to revert the call to restore interpreter state
111+
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(thread.ident), None)
112+
113+
# Thread could have exited since
114+
if res == 0:
115+
return
116+
117+
# Something bad have happened
118+
raise RuntimeError("Could not raise exception in thread", thread.ident)

unblob/testing.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import binascii
22
import glob
33
import io
4+
import platform
45
import shlex
56
import subprocess
67
from pathlib import Path
@@ -10,6 +11,7 @@
1011
from lark.lark import Lark
1112
from lark.visitors import Discard, Transformer
1213
from pytest_cov.embed import cleanup_on_sigterm
14+
from unblob_native.sandbox import AccessFS, SandboxError, restrict_access
1315

1416
from unblob.finder import build_hyperscan_database
1517
from unblob.logging import configure_logger
@@ -217,3 +219,17 @@ def start(self, s):
217219
rv.write(line.data)
218220

219221
return rv.getvalue()
222+
223+
224+
def is_sandbox_available():
225+
is_sandbox_available = True
226+
227+
try:
228+
restrict_access(AccessFS.read_write("/"))
229+
except SandboxError:
230+
is_sandbox_available = False
231+
232+
if platform.architecture == "x86_64" and platform.system == "linux":
233+
assert is_sandbox_available, "Sandboxing should work at least on Linux-x86_64"
234+
235+
return is_sandbox_available

0 commit comments

Comments
 (0)