Skip to content

Commit 83df4a9

Browse files
docs: document chroot jail root
Describe the jail, leftover pre-opened fds, the NSH command-form scrub, and the flat-build trust boundary shared with credentials. Signed-off-by: Abhishek Mishra <mishra.abhishek2808@gmail.com>
1 parent eed5860 commit 83df4a9

8 files changed

Lines changed: 408 additions & 80 deletions

File tree

Documentation/applications/nsh/commands.rst

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,52 @@ Also sets the previous working directory environment variable
215215
``cd ..`` sets the current working directory to the parent directory.
216216
================== =====================================
217217

218+
.. _cmdchroot:
219+
220+
``chroot`` Change Root Directory
221+
================================
222+
223+
**Command Syntax**::
224+
225+
chroot <newroot> [<command> [args...]]
226+
227+
**Synopsis**. Change the filesystem root of the current task group so
228+
absolute path lookups start at ``<newroot>``. Requires
229+
``CONFIG_FS_CHROOT``. This is a filesystem jail, not a container.
230+
231+
The command performs ``chdir(newroot)``, ``chroot(".")``, then
232+
``chdir("/")``. With no extra arguments the current NSH session stays
233+
jailed (``pwd`` shows ``/``). An optional command is executed with
234+
``execvp()`` after the jail is in place.
235+
236+
When ``CONFIG_SCHED_USER_IDENTITY`` is enabled, ``chroot()`` requires
237+
effective UID 0. Drop extra privilege after jailing so a later
238+
``chroot()`` cannot be used to escape.
239+
240+
File descriptors opened before ``chroot()`` are not retroactively
241+
contained. The ``chroot <newroot> <command>`` form closes non-stdio
242+
descriptors that are not already ``O_CLOEXEC`` before ``execvp()``.
243+
The no-command form leaves the current session's existing descriptors
244+
usable, including any that point outside the jail.
245+
246+
**Example**::
247+
248+
nsh> mkdir /tmp/jail
249+
nsh> echo hello > /tmp/jail/marker
250+
nsh> chroot /tmp/jail
251+
nsh> pwd
252+
/
253+
nsh> ls /
254+
/:
255+
marker
256+
nsh> cat /marker
257+
hello
258+
259+
Note that ``ls /`` only lists ``marker``: ``/dev`` and ``/proc`` are not
260+
visible inside the jail because they were never created under
261+
``/tmp/jail``. ``chroot()`` does not bind-mount or otherwise populate
262+
these pseudo-filesystems into the new root; see :ref:`chroot`.
263+
218264
.. _cmdchmod:
219265

220266
``chmod`` Change File Permissions

Documentation/applications/nsh/config.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Command Depends on Configuration Can Be Disabl
4040
! ``CONFIG_NSH_DISABLE_LOOPS``  
4141
:ref:`cmdcat` ``CONFIG_NSH_DISABLE_CAT`` .
4242
:ref:`cmdcd` ! ``CONFIG_DISABLE_ENVIRON`` ``CONFIG_NSH_DISABLE_CD``
43+
:ref:`cmdchroot` ``CONFIG_FS_CHROOT`` ``CONFIG_NSH_DISABLE_CHROOT``
4344
:ref:`cmdcmp` ``CONFIG_NSH_DISABLE_CMP`` .
4445
:ref:`cmdcp` ``CONFIG_NSH_DISABLE_CP`` .
4546
:ref:`cmddate` ``CONFIG_NSH_DISABLE_DATE`` .
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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.

Documentation/implementation/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Implementation Details
99
bottomhalf_interrupt.rst
1010
cancellation_points.rst
1111
chip_h.rst
12+
chroot.rst
1213
context_switches.rst
1314
crc.rst
1415
critical_sections.rst

Documentation/implementation/user_identity.rst

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ When ``CONFIG_SCHED_NGROUPS`` is greater than zero:
4040
with ``setgroups()``.
4141
* ``NGROUPS_MAX`` equals ``CONFIG_SCHED_NGROUPS``.
4242

43-
Filesystem DAC (``fs_checkmode()``) grants the group-class mode bits when the
43+
Filesystem DAC (Discretionary Access Control -- ownership- and
44+
mode-bit-based permission checks, as opposed to a mandatory policy
45+
enforced independently of the file owner) is implemented by
46+
``fs_checkmode()``, which grants the group-class mode bits when the
4447
file's group matches ``tg_egid`` **or** any entry in ``tg_groups``.
4548

4649
Inheritance
@@ -152,6 +155,23 @@ Configuration
152155
See :ref:`file-permission` for the VFS helpers, mount-crossing
153156
traverse rules, and testing notes.
154157

158+
Flat Build Trust Boundary
159+
=========================
160+
161+
This credential model is a DAC (Discretionary Access Control) layer for
162+
cooperating tasks, not a process-isolation boundary. DAC here means
163+
permission checks based on ownership and mode bits that the owner can
164+
change (``chmod()``/``chown()``), rather than a mandatory policy
165+
enforced independently of the object owner. On ``CONFIG_BUILD_FLAT``,
166+
kernel and
167+
application share one address space, so other code can write
168+
``tg_euid`` / ``tg_egid`` (and other fields in ``task_group_s``)
169+
directly and bypass the syscall checks. Protected and kernel builds
170+
enforce the boundary via the syscall interface.
171+
172+
The same caveat applies to ``chroot()``'s ``euid == 0`` gate and
173+
``tg_root``; see :ref:`chroot`.
174+
155175
Pseudo-Filesystem Ownership
156176
===========================
157177

Documentation/reference/user/10_filesystem.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ UNIX Standard Operations (``unistd.h``)
219219
/* Working directory operations */
220220
221221
int chdir(FAR const char *path);
222+
#ifdef CONFIG_FS_CHROOT
223+
int chroot(FAR const char *path);
224+
#endif
222225
FAR char *getcwd(FAR char *buf, size_t size);
223226
224227
/* File path operations */

Documentation/standards/posix.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,13 +1325,18 @@ POSIX_FILE_SYSTEM
13251325

13261326
File System:
13271327

1328+
``chroot()`` is supported when ``CONFIG_FS_CHROOT`` is enabled. See
1329+
:ref:`chroot`.
1330+
13281331
+--------------------------------+---------+
13291332
| API | Support |
13301333
+================================+=========+
13311334
| :c:func:`access` | Yes |
13321335
+--------------------------------+---------+
13331336
| :c:func:`chdir` | Yes |
13341337
+--------------------------------+---------+
1338+
| :c:func:`chroot` | Yes |
1339+
+--------------------------------+---------+
13351340
| :c:func:`closedir` | Yes |
13361341
+--------------------------------+---------+
13371342
| :c:func:`creat` | Yes |

0 commit comments

Comments
 (0)