1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
|
#!/usr/bin/python3
# Copyright 2026 Helmut Grohne <helmut@subdivi.de>
# SPDX-License-Identifier: LGPL-2.0-or-later
"""This is a wrapper around mmdebstrap. The containment of mmdebstrap
--mode=unshare is less than ideal. In particular, maintainer scripts may
perform a container escape. While they still are restricted to the namespaces
and the subuid range, this gives access to the rest of the filesystem for
reading and some of it such as /tmp for writing.
This wrapper builds a different containment where much of the host's root
filesystem is hidden away and much of the rest is locked up in read-only bind
mounts. mmdebstrap itself is then run with --mode=root.
"""
import asyncio
import contextlib
import os
import pathlib
import signal
import sys
if __file__.split("/")[-2:-1] == ["examples"]:
sys.path.insert(0, "/".join(__file__.split("/")[:-2]))
import linuxnamespaces
from linuxnamespaces import CloneFlags
import pconr
async def main() -> None:
uidmap = [
linuxnamespaces.IDAllocation.loadsubid("uid").allocatemap(65536),
]
gidmap = [
linuxnamespaces.IDAllocation.loadsubid("gid").allocatemap(65536),
]
# Reparent children
linuxnamespaces.prctl_set_child_subreaper(True)
async with contextlib.AsyncExitStack() as stack:
conn = stack.enter_context(await pconr.launch_manager())
# Initially, we unshare just a user and mount namespace. They're used
# to set up an initial filesystem hierarchy that is later sealed. When
# we unshare again with more namespaces, the mounts below will become
# immutable.
namespaces = CloneFlags.NEWUSER | CloneFlags.NEWNS
await conn.unshare(namespaces)
linuxnamespaces.newidmaps(conn.pid, uidmap, gidmap)
# Select additional namespaces for the second unshare.
namespaces |= CloneFlags.NEWPID | CloneFlags.NEWIPC | CloneFlags.NEWUTS
# Writing uid_map requires the parent process to be dumpable, see
# man user_namespaces.
await conn.setpriv(
uid=0, gid=0, groups=[], pdeathsig=signal.SIGTERM, dumpable=True
)
# For now, bootstrap into a 1GB tmpfs.
await conn.mount_tmpfs("/mnt", {"size": "1024m"})
# This will become the container's root filesystem.
await conn.chdir("/mnt")
# Provide read-only bind mounts of the operating system.
for path in "bin", "lib", "lib64", "usr", "sbin":
if os.path.exists(f"/{path}"):
await conn.mkdir(path)
await conn.bind_mount(f"/{path}", path, readonly=True)
# Establish other locations such as special file systems.
for path in ("build", "dev", "etc", "proc", "sys", "tmp"):
await conn.mkdir(path)
await conn.populate_dev("dev")
await conn.populate_sys("sys", namespaces)
await conn.chmod("tmp", 0o1777)
# These are needed for running apt.
for fn in "resolv.conf", "passwd", "group":
await conn.write_text(
f"etc/{fn}",
pathlib.Path(f"/etc/{fn}").read_text(encoding="utf8"),
)
# All mounts created until this point will be unmodifiable to the
# payload.
childconn = await stack.enter_async_context(conn.fork())
await childconn.unshare(namespaces)
# Since user namespaces come with empty id maps by default, we must
# map the identity to have access to these ids.
identity = linuxnamespaces.IDMapping.identity(0, 65536)
await conn.newidmaps(childconn.pid, [identity], [identity])
# We now have the final namespaces and id mappings except for the PID
# namespace which needs another fork.
# Due to being a subreaper, childconn becomes our child.
await conn.exit()
await conn.waitself()
# In order to pivot_root, /mnt must be a mount point, but the earlier
# unshare made sealed it.
await childconn.bind_mount("/mnt", "/mnt")
await childconn.chdir("/mnt")
# Keep a copy of /proc somewhere. Otherwise we cannot mount a new proc
# instance.
await childconn.bind_mount("/proc", "/mnt/bin")
await childconn.pivot_root(".", ".", umount_old=True)
# Create PID 1 inside the new pid namespace.
targetconn = await stack.enter_async_context(childconn.fork())
# Again, targetconn becomes our child due to being a subreaper.
await childconn.exit()
await childconn.waitself()
await targetconn.mount_proc("/proc")
await targetconn.umount("/bin", detach=True)
# All intended reparenting is done. targetconn is our child.
linuxnamespaces.prctl_set_child_subreaper(False)
await targetconn.exec(
[
"mmdebstrap",
"--verbose",
"--mode=root",
"--variant=apt",
"--keyring=/usr/share/keyrings/debian-archive-keyring.pgp",
"unstable",
"/build",
],
environ=dict(os.environ),
)
sys.exit((await targetconn.waitself()).si_status)
if __name__ == "__main__":
asyncio.run(main())
|