|
| 1 | +.. _chroot: |
| 2 | + |
| 3 | +====== |
| 4 | +chroot |
| 5 | +====== |
| 6 | + |
| 7 | +``chroot()`` is a kernel-enforced filesystem jail. When |
| 8 | +``CONFIG_FS_CHROOT`` is enabled, each task group may pin a directory as |
| 9 | +its root. Absolute path lookup starts there, so the group cannot see |
| 10 | +files outside that tree. |
| 11 | + |
| 12 | +Limitations: this is **not** a container. The current implementation |
| 13 | +only changes where pathname lookup begins; it does not provide PID, |
| 14 | +mount, or network namespaces, and it does not populate the new root |
| 15 | +with ``/dev`` or ``/proc``. See `TODO`_ for what each of these would |
| 16 | +require. |
| 17 | + |
| 18 | +Design and implementation |
| 19 | +========================== |
| 20 | + |
| 21 | +The Kconfig option and the syscall are the easy part; ``chroot()`` on |
| 22 | +NuttX has no MMU-backed process isolation to lean on, so the whole |
| 23 | +feature has to be built on top of the single, global pseudo-filesystem |
| 24 | +inode tree that every task already shares. This section walks through |
| 25 | +why that made the implementation harder than it looks, in the order |
| 26 | +the pieces had to be worked out. |
| 27 | + |
| 28 | +Where does the jail live? |
| 29 | +-------------------------- |
| 30 | + |
| 31 | +The first question is what a "jail" even is in a system with one |
| 32 | +shared filesystem tree: it cannot be a separate tree, so it has to be |
| 33 | +a *starting point* that path lookups are not allowed to walk above. |
| 34 | +That starting point needs to be remembered somewhere per-caller, and |
| 35 | +it needs to survive ``fork()``-style child creation the same way an |
| 36 | +open file table or a working directory does. |
| 37 | + |
| 38 | +NuttX already keeps exactly that kind of shared, inheritable state on |
| 39 | +the task group (``struct task_group_s``), not on the individual task, |
| 40 | +because every thread in a task group is supposed to see the same |
| 41 | +filesystem view. The jail is stored as a single absolute path:: |
| 42 | + |
| 43 | + struct task_group_s |
| 44 | + { |
| 45 | + ... |
| 46 | + #ifdef CONFIG_FS_CHROOT |
| 47 | + FAR char *tg_root; /* Absolute jail path, or NULL */ |
| 48 | + #endif |
| 49 | + }; |
| 50 | + |
| 51 | +A path is used instead of a cached inode so a later unmount/remount |
| 52 | +at that location is picked up on the next lookup. ``tg_root`` is |
| 53 | +``NULL`` when the group has not called ``chroot()``. |
| 54 | + |
| 55 | +``group_inherit_chroot()`` (``sched/group/group_create.c``) copies the |
| 56 | +string to child task groups (kernel threads are skipped): |
| 57 | + |
| 58 | +.. code-block:: c |
| 59 | +
|
| 60 | + if (rgroup->tg_root == NULL) |
| 61 | + { |
| 62 | + return OK; |
| 63 | + } |
| 64 | +
|
| 65 | + group->tg_root = strdup(rgroup->tg_root); |
| 66 | + if (group->tg_root == NULL) |
| 67 | + { |
| 68 | + return -ENOMEM; |
| 69 | + } |
| 70 | +
|
| 71 | +That is what makes a jail apply to a whole subtree of children, not |
| 72 | +just the one task that called ``chroot()``. |
| 73 | + |
| 74 | +How lookups stay inside the jail |
| 75 | +--------------------------------- |
| 76 | + |
| 77 | +Every absolute path goes through ``inode_search_setup()`` and then |
| 78 | +the original walk from ``g_root_inode``: |
| 79 | + |
| 80 | +1. Prepend ``tg_root`` to the incoming path (``/tmp/jail`` + ``/foo`` |
| 81 | + becomes ``/tmp/jail/foo``). |
| 82 | +2. Canonicalize the combined string with ``_inode_canonicalize()``: |
| 83 | + drop empty and ``.`` segments and collapse ``..``. The jail prefix |
| 84 | + is the floor for that walk, so ``..`` cannot pop above ``tg_root``. |
| 85 | +3. ``/../etc`` inside the jail therefore becomes ``/tmp/jail/etc``, |
| 86 | + not host ``/etc``. |
| 87 | +4. Continue the original inode-tree walk on that host path. |
| 88 | + |
| 89 | +Without a jail the same canonicalize step still runs, so |
| 90 | +``chroot(".")`` under a mount (``$PWD/.``) does not pass a leftover |
| 91 | +``.`` to the filesystem as ``relpath``. |
| 92 | + |
| 93 | +There is no separate jailed walk, and no extra ``..`` handling inside |
| 94 | +the tree traversal. |
| 95 | + |
| 96 | +Why ``chroot()`` does not touch ``PWD`` |
| 97 | +----------------------------------------- |
| 98 | + |
| 99 | +Relative lookups go through ``inode_search()``, which prepends |
| 100 | +``$PWD`` to the path and then calls the exact same absolute-path |
| 101 | +logic described above. That raises an obvious question: what happens |
| 102 | +to a task's current directory when its whole notion of "root" just |
| 103 | +moved? |
| 104 | + |
| 105 | +An earlier version of this change rewrote ``PWD`` inside ``chroot()`` |
| 106 | +itself to keep it consistent with the new jail. That turned out to be |
| 107 | +both the wrong layer and unnecessary: |
| 108 | + |
| 109 | +* ``chroot()`` is a filesystem primitive; ``PWD`` is environ state. |
| 110 | + POSIX ``chroot()`` does not touch the current directory either -- |
| 111 | + the well-known Unix idiom is that the *caller* must ``chdir()`` |
| 112 | + immediately after ``chroot()``, precisely so that no stale |
| 113 | + reference to the old tree is left lying around. |
| 114 | +* It is not needed for containment. A stale ``PWD`` used in a |
| 115 | + relative lookup after ``chroot()`` is still rewritten by |
| 116 | + ``inode_search_setup()`` (prepend the jail path, canonicalize, clamp |
| 117 | + ``..``) before the tree walk. The lookup can fail or land on a |
| 118 | + path inside the jail that was not intended, but it cannot resolve |
| 119 | + to a node outside the jail. |
| 120 | + |
| 121 | +So ``chroot()`` leaves ``PWD`` alone, and the caller is responsible |
| 122 | +for calling ``chdir()`` afterward if a sane current directory inside |
| 123 | +the jail is needed -- exactly as the NSH ``chroot`` command already |
| 124 | +does with its trailing ``chdir("/")`` (see `NSH`_ below), which sets |
| 125 | +``PWD`` correctly via the ordinary ``chdir()`` path, with no special |
| 126 | +jail-aware logic required. |
| 127 | + |
| 128 | +Why the privilege gate lives in ``chroot()`` itself |
| 129 | +----------------------------------------------------- |
| 130 | + |
| 131 | +The last design question was who is allowed to call ``chroot()`` at |
| 132 | +all. When ``CONFIG_SCHED_USER_IDENTITY`` is enabled, the syscall |
| 133 | +checks ``tg_euid`` directly and returns ``EPERM`` for anything but |
| 134 | +effective UID 0:: |
| 135 | + |
| 136 | + #ifdef CONFIG_SCHED_USER_IDENTITY |
| 137 | + if (group->tg_euid != 0) |
| 138 | + { |
| 139 | + set_errno(EPERM); |
| 140 | + return ERROR; |
| 141 | + } |
| 142 | + #endif |
| 143 | + |
| 144 | +This is intentionally the same *class* of check as the credential DAC |
| 145 | +checks described in :ref:`user-identity`, and it inherits the same |
| 146 | +caveat: on ``CONFIG_BUILD_FLAT``, kernel and application code share |
| 147 | +one address space, so this is a userspace-visible gate rather than a |
| 148 | +hardware-enforced boundary -- other code in that address space can |
| 149 | +write ``tg_euid`` or ``tg_root`` directly. Protected and kernel builds |
| 150 | +close that gap by enforcing the check at the syscall boundary, which |
| 151 | +untrusted code cannot bypass. Without ``CONFIG_SCHED_USER_IDENTITY`` |
| 152 | +at all, every task is effectively root, so ``chroot()`` stays |
| 153 | +available to everyone and is a pure path-containment mechanism with no |
| 154 | +privilege check gating it. |
| 155 | + |
| 156 | +Configuration |
| 157 | +============= |
| 158 | + |
| 159 | +Enable ``CONFIG_FS_CHROOT`` in the filesystem configuration. The |
| 160 | +syscall is then available from ``unistd.h``. |
| 161 | + |
| 162 | +Semantics |
| 163 | +========= |
| 164 | + |
| 165 | +* ``chroot(path)`` resolves ``path`` relative to the caller's current |
| 166 | + root (so a nested ``chroot()`` cannot walk back to the host tree). |
| 167 | +* ``path`` must name a directory (``ENOTDIR`` otherwise). |
| 168 | +* ``chroot("/")`` resolves to the host root and clears the jail |
| 169 | + (``tg_root = NULL``). From inside a jail, ``/`` is the jail root, so |
| 170 | + it cannot be used to escape. |
| 171 | +* The jail is stored on the task group as the absolute path ``tg_root``. |
| 172 | + Child tasks inherit it. Kernel threads do not. |
| 173 | +* ``chroot()`` does not modify ``PWD`` or any other environ state; the |
| 174 | + caller is responsible for calling ``chdir()`` afterward if a |
| 175 | + specific current directory inside the jail is needed. See |
| 176 | + `Why chroot() does not touch PWD`_. |
| 177 | + |
| 178 | +NSH |
| 179 | +=== |
| 180 | + |
| 181 | +The NSH ``chroot`` command performs the usual Unix dance:: |
| 182 | + |
| 183 | + chdir(newroot); |
| 184 | + chroot("."); |
| 185 | + chdir("/"); |
| 186 | + |
| 187 | +With no extra arguments the current NSH session stays jailed (``pwd`` |
| 188 | +shows ``/``, ``ls /`` lists the jail tree) because of the trailing |
| 189 | +``chdir("/")`` in the sequence above, not because ``chroot()`` itself |
| 190 | +touches ``PWD``. An optional command is executed with ``execvp()`` |
| 191 | +after the jail is in place; NSH closes non-stdio, non-``O_CLOEXEC`` |
| 192 | +descriptors first (see below). |
| 193 | + |
| 194 | +When ``CONFIG_SCHED_USER_IDENTITY`` is enabled, drop extra privilege |
| 195 | +after the jail is in place (for example ``setuid()`` to a non-root |
| 196 | +user) so a later ``chroot()`` cannot be used to escape. |
| 197 | + |
| 198 | +Open file descriptors |
| 199 | +===================== |
| 200 | + |
| 201 | +File descriptors opened before ``chroot()`` are not retroactively |
| 202 | +contained. POSIX allows this; NuttX does not close them. A jailed |
| 203 | +task that inherits a host descriptor can read and write that file |
| 204 | +without going through pathname lookup, so the jail does not apply. |
| 205 | +This is the most common way ``chroot()`` is misused as a security |
| 206 | +tool. Do not treat it as a sandbox against a process that already |
| 207 | +holds host file descriptors. |
| 208 | + |
| 209 | +The NSH ``chroot <newroot> <command>`` form closes every open |
| 210 | +descriptor above stderr that is not already marked ``O_CLOEXEC`` |
| 211 | +before ``execvp()``. Stdio (fds 0--2) is left intact. The |
| 212 | +no-command form leaves the current NSH session jailed with its |
| 213 | +existing descriptors, including any that point outside the tree. |
| 214 | + |
| 215 | +TODO |
| 216 | +==== |
| 217 | + |
| 218 | +The following are deliberately out of scope for this initial |
| 219 | +implementation, and are listed with what each would require, since |
| 220 | +that scoping was itself a large part of the design work: |
| 221 | + |
| 222 | +* **Populating ``/dev``, ``/proc``, etc. inside the jail.** Nothing |
| 223 | + bind-mounts or otherwise recreates these pseudo-filesystems under |
| 224 | + the new root, so a jailed task cannot open devices or read process |
| 225 | + info unless the jail directory tree already contains them. Adding |
| 226 | + this needs either a bind-mount primitive (mount an existing inode |
| 227 | + subtree at a second path) or a per-jail selective mount step run at |
| 228 | + ``chroot()`` time; neither existed in the VFS before this change, |
| 229 | + and both are a materially larger change than pathname jailing. |
| 230 | + This is the specific gap raised for using ``chroot()`` to sandbox |
| 231 | + remote logins (telnet/ssh): without a minimal ``/dev``, a jailed |
| 232 | + shell cannot even do much I/O. |
| 233 | +* **PID namespaces.** NuttX has one flat, global task/PID table. |
| 234 | + Isolating it per jail would mean making scheduler and IPC lookups |
| 235 | + (``kill()``, ``/proc``-style listings, signal delivery) aware of a |
| 236 | + namespace boundary, which touches the scheduler core, not just the |
| 237 | + VFS. This implementation does not attempt that. |
| 238 | +* **Mount namespaces.** The mount table (``g_root_inode`` and its |
| 239 | + mounted filesystems) is process-global. A jailed task group can be |
| 240 | + confined to a subtree of the existing mount table, but it cannot |
| 241 | + have a private view where mounts made outside the jail are hidden, |
| 242 | + or where the jailed task can mount/unmount without affecting the |
| 243 | + rest of the system. That requires per-task-group mount tables. |
| 244 | +* **Network namespaces.** Sockets and network interfaces are global |
| 245 | + to the OS instance; nothing in this change touches the network |
| 246 | + stack. |
| 247 | +* **``pivot_root()``.** Swapping the process root while keeping the |
| 248 | + old root reachable is not implemented; ``chroot()`` only changes |
| 249 | + where lookups begin. |
| 250 | + |
| 251 | +None of these are ruled out architecturally -- they are simply not |
| 252 | +part of this change, which is scoped to pathname-lookup containment. |
0 commit comments