-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fs,sched: add chroot() filesystem jail #19900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Abhishekmishra2808
wants to merge
4
commits into
apache:master
Choose a base branch
from
Abhishekmishra2808:feature/fs-chroot
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
dc7c54d
sched: add per-group filesystem jail root
Abhishekmishra2808 fe5eddd
fs: add chroot() syscall
Abhishekmishra2808 dff5d5c
fs: start absolute lookups at the jail root
Abhishekmishra2808 d3ea539
docs: document chroot jail root
Abhishekmishra2808 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| .. _chroot: | ||
|
|
||
| ====== | ||
| chroot | ||
|
xiaoxiang781216 marked this conversation as resolved.
|
||
| ====== | ||
|
|
||
| ``chroot()`` is a kernel-enforced filesystem jail. When | ||
| ``CONFIG_FS_CHROOT`` is enabled, each task group may pin a directory as | ||
| its root. Absolute path lookup starts there, so the group cannot see | ||
| files outside that tree. | ||
|
|
||
| Limitations: this is **not** a container. The current implementation | ||
| only changes where pathname lookup begins; it does not provide PID, | ||
| mount, or network namespaces, and it does not populate the new root | ||
| with ``/dev`` or ``/proc``. See `TODO`_ for what each of these would | ||
| require. | ||
|
|
||
| Design and implementation | ||
| ========================== | ||
|
|
||
| The Kconfig option and the syscall are the easy part; ``chroot()`` on | ||
| NuttX has no MMU-backed process isolation to lean on, so the whole | ||
| feature has to be built on top of the single, global pseudo-filesystem | ||
| inode tree that every task already shares. This section walks through | ||
| why that made the implementation harder than it looks, in the order | ||
| the pieces had to be worked out. | ||
|
|
||
| Where does the jail live? | ||
| -------------------------- | ||
|
|
||
| The first question is what a "jail" even is in a system with one | ||
| shared filesystem tree: it cannot be a separate tree, so it has to be | ||
| a *starting point* that path lookups are not allowed to walk above. | ||
| That starting point needs to be remembered somewhere per-caller, and | ||
| it needs to survive ``fork()``-style child creation the same way an | ||
| open file table or a working directory does. | ||
|
|
||
| NuttX already keeps exactly that kind of shared, inheritable state on | ||
| the task group (``struct task_group_s``), not on the individual task, | ||
| because every thread in a task group is supposed to see the same | ||
| filesystem view. The jail is stored as a single absolute path:: | ||
|
|
||
| struct task_group_s | ||
| { | ||
| ... | ||
| #ifdef CONFIG_FS_CHROOT | ||
| FAR char *tg_root; /* Absolute jail path, or NULL */ | ||
| #endif | ||
| }; | ||
|
|
||
| A path is used instead of a cached inode so a later unmount/remount | ||
| at that location is picked up on the next lookup. ``tg_root`` is | ||
| ``NULL`` when the group has not called ``chroot()``. | ||
|
|
||
| ``group_inherit_chroot()`` (``sched/group/group_create.c``) copies the | ||
| string to child task groups (kernel threads are skipped): | ||
|
|
||
| .. code-block:: c | ||
|
|
||
| if (rgroup->tg_root == NULL) | ||
| { | ||
| return OK; | ||
| } | ||
|
|
||
| group->tg_root = strdup(rgroup->tg_root); | ||
| if (group->tg_root == NULL) | ||
| { | ||
| return -ENOMEM; | ||
| } | ||
|
|
||
| That is what makes a jail apply to a whole subtree of children, not | ||
| just the one task that called ``chroot()``. | ||
|
|
||
| How lookups stay inside the jail | ||
| --------------------------------- | ||
|
|
||
| Every absolute path goes through ``_inode_search()``. When a jail is | ||
| installed the search does not start from a private inode; it rewrites | ||
| the path and then uses the original walk from ``g_root_inode``: | ||
|
|
||
| 1. Prepend ``tg_root`` to the incoming path (``/tmp/jail`` + ``/foo`` | ||
| becomes ``/tmp/jail/foo``). | ||
| 2. Normalize the combined string with ``inode_normalize_abs()``: | ||
| drop empty and ``.`` segments, collapse ``..``, and write each | ||
| kept segment directly into the output buffer while advancing the | ||
| input pointer once. | ||
| 3. Ensure the result is still under ``tg_root``. ``..`` is clamped so | ||
| it cannot pop above the jail prefix, so ``/../etc`` inside the jail | ||
| becomes ``/tmp/jail/etc``, not host ``/etc``. | ||
| 4. Continue the original inode-tree walk on that host path. | ||
|
|
||
| ``inode_normalize_abs()`` is also used when no jail is installed, so | ||
| ``chroot(".")`` under a mount (``$PWD/.``) does not pass a leftover | ||
| ``.`` to the filesystem as ``relpath``. | ||
|
|
||
| There is no separate jailed walk, and no extra ``..`` handling inside | ||
| the tree traversal. | ||
|
|
||
| Why ``chroot()`` does not touch ``PWD`` | ||
| ----------------------------------------- | ||
|
|
||
| Relative lookups go through ``inode_search()``, which prepends | ||
| ``$PWD`` to the path and then calls the exact same absolute-path | ||
| logic described above. That raises an obvious question: what happens | ||
| to a task's current directory when its whole notion of "root" just | ||
| moved? | ||
|
|
||
| An earlier version of this change rewrote ``PWD`` inside ``chroot()`` | ||
| itself to keep it consistent with the new jail. That turned out to be | ||
| both the wrong layer and unnecessary: | ||
|
|
||
| * ``chroot()`` is a filesystem primitive; ``PWD`` is environ state. | ||
| POSIX ``chroot()`` does not touch the current directory either -- | ||
| the well-known Unix idiom is that the *caller* must ``chdir()`` | ||
| immediately after ``chroot()``, precisely so that no stale | ||
| reference to the old tree is left lying around. | ||
| * It is not needed for containment. A stale ``PWD`` used in a | ||
| relative lookup after ``chroot()`` is still rewritten by | ||
| ``_inode_search()`` (prepend the jail path, normalize, clamp | ||
| ``..``) before the tree walk. The lookup can fail or land on a | ||
| path inside the jail that was not intended, but it cannot resolve | ||
| to a node outside the jail. | ||
|
|
||
| So ``chroot()`` leaves ``PWD`` alone, and the caller is responsible | ||
| for calling ``chdir()`` afterward if a sane current directory inside | ||
| the jail is needed -- exactly as the NSH ``chroot`` command already | ||
| does with its trailing ``chdir("/")`` (see `NSH`_ below), which sets | ||
| ``PWD`` correctly via the ordinary ``chdir()`` path, with no special | ||
| jail-aware logic required. | ||
|
|
||
| Why the privilege gate lives in ``chroot()`` itself | ||
| ----------------------------------------------------- | ||
|
|
||
| The last design question was who is allowed to call ``chroot()`` at | ||
| all. When ``CONFIG_SCHED_USER_IDENTITY`` is enabled, the syscall | ||
| checks ``tg_euid`` directly and returns ``EPERM`` for anything but | ||
| effective UID 0:: | ||
|
|
||
| #ifdef CONFIG_SCHED_USER_IDENTITY | ||
| if (group->tg_euid != 0) | ||
| { | ||
| set_errno(EPERM); | ||
| return ERROR; | ||
| } | ||
| #endif | ||
|
|
||
| This is intentionally the same *class* of check as the credential DAC | ||
| checks described in :ref:`user-identity`, and it inherits the same | ||
| caveat: on ``CONFIG_BUILD_FLAT``, kernel and application code share | ||
| one address space, so this is a userspace-visible gate rather than a | ||
| hardware-enforced boundary -- other code in that address space can | ||
| write ``tg_euid`` or ``tg_root`` directly. Protected and kernel builds | ||
| close that gap by enforcing the check at the syscall boundary, which | ||
| untrusted code cannot bypass. Without ``CONFIG_SCHED_USER_IDENTITY`` | ||
| at all, every task is effectively root, so ``chroot()`` stays | ||
| available to everyone and is a pure path-containment mechanism with no | ||
| privilege check gating it. | ||
|
|
||
| Configuration | ||
| ============= | ||
|
|
||
| Enable ``CONFIG_FS_CHROOT`` in the filesystem configuration. The | ||
| syscall is then available from ``unistd.h``. | ||
|
|
||
| Semantics | ||
| ========= | ||
|
|
||
| * ``chroot(path)`` resolves ``path`` relative to the caller's current | ||
| root (so a nested ``chroot()`` cannot walk back to the host tree). | ||
| * ``path`` must name a directory (``ENOTDIR`` otherwise). | ||
| * ``chroot("/")`` from the global root is a no-op. From inside a jail, | ||
| ``/`` is the jail root, so it cannot be used to escape. | ||
| * The jail is stored on the task group as the absolute path ``tg_root``. | ||
| Child tasks inherit it. Kernel threads do not. | ||
| * ``chroot()`` does not modify ``PWD`` or any other environ state; the | ||
| caller is responsible for calling ``chdir()`` afterward if a | ||
| specific current directory inside the jail is needed. See | ||
| `Why chroot() does not touch PWD`_. | ||
|
|
||
| NSH | ||
| === | ||
|
|
||
| The NSH ``chroot`` command performs the usual Unix dance:: | ||
|
|
||
| chdir(newroot); | ||
| chroot("."); | ||
| chdir("/"); | ||
|
|
||
| With no extra arguments the current NSH session stays jailed (``pwd`` | ||
| shows ``/``, ``ls /`` lists the jail tree) because of the trailing | ||
| ``chdir("/")`` in the sequence above, not because ``chroot()`` itself | ||
| touches ``PWD``. An optional command is executed with ``execvp()`` | ||
| after the jail is in place; NSH closes non-stdio, non-``O_CLOEXEC`` | ||
| descriptors first (see below). | ||
|
|
||
| When ``CONFIG_SCHED_USER_IDENTITY`` is enabled, drop extra privilege | ||
| after the jail is in place (for example ``setuid()`` to a non-root | ||
| user) so a later ``chroot()`` cannot be used to escape. | ||
|
|
||
| Open file descriptors | ||
| ===================== | ||
|
|
||
| File descriptors opened before ``chroot()`` are not retroactively | ||
| contained. POSIX allows this; NuttX does not close them. A jailed | ||
| task that inherits a host descriptor can read and write that file | ||
| without going through pathname lookup, so the jail does not apply. | ||
| This is the most common way ``chroot()`` is misused as a security | ||
| tool. Do not treat it as a sandbox against a process that already | ||
| holds host file descriptors. | ||
|
|
||
| The NSH ``chroot <newroot> <command>`` form closes every open | ||
| descriptor above stderr that is not already marked ``O_CLOEXEC`` | ||
| before ``execvp()``. Stdio (fds 0--2) is left intact. The | ||
| no-command form leaves the current NSH session jailed with its | ||
| existing descriptors, including any that point outside the tree. | ||
|
|
||
| TODO | ||
| ==== | ||
|
|
||
| The following are deliberately out of scope for this initial | ||
| implementation, and are listed with what each would require, since | ||
| that scoping was itself a large part of the design work: | ||
|
|
||
| * **Populating ``/dev``, ``/proc``, etc. inside the jail.** Nothing | ||
| bind-mounts or otherwise recreates these pseudo-filesystems under | ||
| the new root, so a jailed task cannot open devices or read process | ||
| info unless the jail directory tree already contains them. Adding | ||
| this needs either a bind-mount primitive (mount an existing inode | ||
| subtree at a second path) or a per-jail selective mount step run at | ||
| ``chroot()`` time; neither existed in the VFS before this change, | ||
| and both are a materially larger change than pathname jailing. | ||
| This is the specific gap raised for using ``chroot()`` to sandbox | ||
| remote logins (telnet/ssh): without a minimal ``/dev``, a jailed | ||
| shell cannot even do much I/O. | ||
| * **PID namespaces.** NuttX has one flat, global task/PID table. | ||
| Isolating it per jail would mean making scheduler and IPC lookups | ||
| (``kill()``, ``/proc``-style listings, signal delivery) aware of a | ||
| namespace boundary, which touches the scheduler core, not just the | ||
| VFS. This implementation does not attempt that. | ||
| * **Mount namespaces.** The mount table (``g_root_inode`` and its | ||
| mounted filesystems) is process-global. A jailed task group can be | ||
| confined to a subtree of the existing mount table, but it cannot | ||
| have a private view where mounts made outside the jail are hidden, | ||
| or where the jailed task can mount/unmount without affecting the | ||
| rest of the system. That requires per-task-group mount tables. | ||
| * **Network namespaces.** Sockets and network interfaces are global | ||
| to the OS instance; nothing in this change touches the network | ||
| stack. | ||
| * **``pivot_root()``.** Swapping the process root while keeping the | ||
| old root reachable is not implemented; ``chroot()`` only changes | ||
| where lookups begin. | ||
|
|
||
| None of these are ruled out architecturally -- they are simply not | ||
| part of this change, which is scoped to pathname-lookup containment. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.