This is an automated email from the ASF dual-hosted git repository. xiaoxiang781216 pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/nuttx.git
commit 4b86c1dd230d10da79b716152827502dd285ca40 Author: Abhishek Mishra <[email protected]> AuthorDate: Wed Aug 19 08:07:17 2026 +0000 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 <[email protected]> --- Documentation/applications/nsh/commands.rst | 46 +++++ Documentation/applications/nsh/config.rst | 1 + Documentation/implementation/chroot.rst | 252 +++++++++++++++++++++++++ Documentation/implementation/index.rst | 1 + Documentation/implementation/user_identity.rst | 22 ++- Documentation/reference/user/10_filesystem.rst | 3 + Documentation/standards/posix.rst | 5 + fs/mount/fs_automount.c | 158 ++++++++-------- 8 files changed, 408 insertions(+), 80 deletions(-) diff --git a/Documentation/applications/nsh/commands.rst b/Documentation/applications/nsh/commands.rst index d1477e958df..c1e79508a89 100644 --- a/Documentation/applications/nsh/commands.rst +++ b/Documentation/applications/nsh/commands.rst @@ -215,6 +215,52 @@ Also sets the previous working directory environment variable ``cd ..`` sets the current working directory to the parent directory. ================== ===================================== +.. _cmdchroot: + +``chroot`` Change Root Directory +================================ + +**Command Syntax**:: + + chroot <newroot> [<command> [args...]] + +**Synopsis**. Change the filesystem root of the current task group so +absolute path lookups start at ``<newroot>``. Requires +``CONFIG_FS_CHROOT``. This is a filesystem jail, not a container. + +The command performs ``chdir(newroot)``, ``chroot(".")``, then +``chdir("/")``. With no extra arguments the current NSH session stays +jailed (``pwd`` shows ``/``). An optional command is executed with +``execvp()`` after the jail is in place. + +When ``CONFIG_SCHED_USER_IDENTITY`` is enabled, ``chroot()`` requires +effective UID 0. Drop extra privilege after jailing so a later +``chroot()`` cannot be used to escape. + +File descriptors opened before ``chroot()`` are not retroactively +contained. The ``chroot <newroot> <command>`` form closes non-stdio +descriptors that are not already ``O_CLOEXEC`` before ``execvp()``. +The no-command form leaves the current session's existing descriptors +usable, including any that point outside the jail. + +**Example**:: + + nsh> mkdir /tmp/jail + nsh> echo hello > /tmp/jail/marker + nsh> chroot /tmp/jail + nsh> pwd + / + nsh> ls / + /: + marker + nsh> cat /marker + hello + +Note that ``ls /`` only lists ``marker``: ``/dev`` and ``/proc`` are not +visible inside the jail because they were never created under +``/tmp/jail``. ``chroot()`` does not bind-mount or otherwise populate +these pseudo-filesystems into the new root; see :ref:`chroot`. + .. _cmdchmod: ``chmod`` Change File Permissions diff --git a/Documentation/applications/nsh/config.rst b/Documentation/applications/nsh/config.rst index 19b92ebfe03..31816b97981 100644 --- a/Documentation/applications/nsh/config.rst +++ b/Documentation/applications/nsh/config.rst @@ -40,6 +40,7 @@ Command Depends on Configuration Can Be Disabl ! ``CONFIG_NSH_DISABLE_LOOPS`` :ref:`cmdcat` ``CONFIG_NSH_DISABLE_CAT`` . :ref:`cmdcd` ! ``CONFIG_DISABLE_ENVIRON`` ``CONFIG_NSH_DISABLE_CD`` +:ref:`cmdchroot` ``CONFIG_FS_CHROOT`` ``CONFIG_NSH_DISABLE_CHROOT`` :ref:`cmdcmp` ``CONFIG_NSH_DISABLE_CMP`` . :ref:`cmdcp` ``CONFIG_NSH_DISABLE_CP`` . :ref:`cmddate` ``CONFIG_NSH_DISABLE_DATE`` . diff --git a/Documentation/implementation/chroot.rst b/Documentation/implementation/chroot.rst new file mode 100644 index 00000000000..8a06eeb1013 --- /dev/null +++ b/Documentation/implementation/chroot.rst @@ -0,0 +1,252 @@ +.. _chroot: + +====== +chroot +====== + +``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_setup()`` and then +the original walk from ``g_root_inode``: + +1. Prepend ``tg_root`` to the incoming path (``/tmp/jail`` + ``/foo`` + becomes ``/tmp/jail/foo``). +2. Canonicalize the combined string with ``_inode_canonicalize()``: + drop empty and ``.`` segments and collapse ``..``. The jail prefix + is the floor for that walk, so ``..`` cannot pop above ``tg_root``. +3. ``/../etc`` inside the jail therefore becomes ``/tmp/jail/etc``, + not host ``/etc``. +4. Continue the original inode-tree walk on that host path. + +Without a jail the same canonicalize step still runs, 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_setup()`` (prepend the jail path, canonicalize, 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("/")`` resolves to the host root and clears the jail + (``tg_root = NULL``). 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. diff --git a/Documentation/implementation/index.rst b/Documentation/implementation/index.rst index 4d53b205c6d..a0d71ddb152 100644 --- a/Documentation/implementation/index.rst +++ b/Documentation/implementation/index.rst @@ -9,6 +9,7 @@ Implementation Details bottomhalf_interrupt.rst cancellation_points.rst chip_h.rst + chroot.rst context_switches.rst crc.rst critical_sections.rst diff --git a/Documentation/implementation/user_identity.rst b/Documentation/implementation/user_identity.rst index ffb08aee3b5..9f621c80e8a 100644 --- a/Documentation/implementation/user_identity.rst +++ b/Documentation/implementation/user_identity.rst @@ -40,7 +40,10 @@ When ``CONFIG_SCHED_NGROUPS`` is greater than zero: with ``setgroups()``. * ``NGROUPS_MAX`` equals ``CONFIG_SCHED_NGROUPS``. -Filesystem DAC (``fs_checkmode()``) grants the group-class mode bits when the +Filesystem DAC (Discretionary Access Control -- ownership- and +mode-bit-based permission checks, as opposed to a mandatory policy +enforced independently of the file owner) is implemented by +``fs_checkmode()``, which grants the group-class mode bits when the file's group matches ``tg_egid`` **or** any entry in ``tg_groups``. Inheritance @@ -152,6 +155,23 @@ Configuration See :ref:`file-permission` for the VFS helpers, mount-crossing traverse rules, and testing notes. +Flat Build Trust Boundary +========================= + +This credential model is a DAC (Discretionary Access Control) layer for +cooperating tasks, not a process-isolation boundary. DAC here means +permission checks based on ownership and mode bits that the owner can +change (``chmod()``/``chown()``), rather than a mandatory policy +enforced independently of the object owner. On ``CONFIG_BUILD_FLAT``, +kernel and +application share one address space, so other code can write +``tg_euid`` / ``tg_egid`` (and other fields in ``task_group_s``) +directly and bypass the syscall checks. Protected and kernel builds +enforce the boundary via the syscall interface. + +The same caveat applies to ``chroot()``'s ``euid == 0`` gate and +``tg_root``; see :ref:`chroot`. + Pseudo-Filesystem Ownership =========================== diff --git a/Documentation/reference/user/10_filesystem.rst b/Documentation/reference/user/10_filesystem.rst index 695c1e0db30..4e1aef5419e 100644 --- a/Documentation/reference/user/10_filesystem.rst +++ b/Documentation/reference/user/10_filesystem.rst @@ -219,6 +219,9 @@ UNIX Standard Operations (``unistd.h``) /* Working directory operations */ int chdir(FAR const char *path); + #ifdef CONFIG_FS_CHROOT + int chroot(FAR const char *path); + #endif FAR char *getcwd(FAR char *buf, size_t size); /* File path operations */ diff --git a/Documentation/standards/posix.rst b/Documentation/standards/posix.rst index 5333f6f11e7..e435248d746 100644 --- a/Documentation/standards/posix.rst +++ b/Documentation/standards/posix.rst @@ -1325,6 +1325,9 @@ POSIX_FILE_SYSTEM File System: +``chroot()`` is supported when ``CONFIG_FS_CHROOT`` is enabled. See +:ref:`chroot`. + +--------------------------------+---------+ | API | Support | +================================+=========+ @@ -1332,6 +1335,8 @@ File System: +--------------------------------+---------+ | :c:func:`chdir` | Yes | +--------------------------------+---------+ +| :c:func:`chroot` | Yes | ++--------------------------------+---------+ | :c:func:`closedir` | Yes | +--------------------------------+---------+ | :c:func:`creat` | Yes | diff --git a/fs/mount/fs_automount.c b/fs/mount/fs_automount.c index 1afc7ac46ab..a4ecef5e8f6 100644 --- a/fs/mount/fs_automount.c +++ b/fs/mount/fs_automount.c @@ -476,56 +476,56 @@ static void automount_mount(FAR struct automounter_state_s *priv) ret = automount_findinode(lower->mountpoint); switch (ret) { - case OK_EXIST: + case OK_EXIST: - /* REVISIT: What should we do in this case? I think that this would - * happen only if a previous unmount failed? I suppose that we should - * try to unmount again because the mount might be stale. - */ + /* REVISIT: What should we do in this case? I think that this would + * happen only if a previous unmount failed? I suppose that we + * should try to unmount again because the mount might be stale. + */ - fwarn("WARNING: Mountpoint %s already exists\n", lower->mountpoint); - ret = automount_unmount(priv); - if (ret < 0) - { - /* We failed to unmount (again?). Complain and abort. */ + fwarn("WARNING: Mountpoint %s already exists\n", lower->mountpoint); + ret = automount_unmount(priv); + if (ret < 0) + { + /* We failed to unmount (again?). Complain and abort. */ - ferr("ERROR: automount_unmount failed: %d\n", ret); - return; - } + ferr("ERROR: automount_unmount failed: %d\n", ret); + return; + } - /* We successfully unmounted the file system. Fall through to - * mount it again. - */ + /* We successfully unmounted the file system. Fall through to + * mount it again. + */ - case OK_NOENT: + case OK_NOENT: - /* If we get here, then the volume must not be mounted */ + /* If we get here, then the volume must not be mounted */ - DEBUGASSERT(!priv->mounted); + DEBUGASSERT(!priv->mounted); - /* Mount the file system */ + /* Mount the file system */ - ret = nx_mount(lower->blockdev, lower->mountpoint, lower->fstype, - 0, NULL); - if (ret < 0) - { - ferr("ERROR: Mount failed: %d\n", ret); - return; - } + ret = nx_mount(lower->blockdev, lower->mountpoint, lower->fstype, + 0, NULL); + if (ret < 0) + { + ferr("ERROR: Mount failed: %d\n", ret); + return; + } - /* Indicate that the volume is mounted */ + /* Indicate that the volume is mounted */ - priv->mounted = true; + priv->mounted = true; #ifdef CONFIG_FS_AUTOMOUNTER_DRIVER - automount_notify(priv); + automount_notify(priv); #endif /* CONFIG_FS_AUTOMOUNTER_DRIVER */ - break; + break; - default: - ferr("ERROR: automount_findinode failed: %d\n", ret); - break; + default: + ferr("ERROR: automount_findinode failed: %d\n", ret); + break; } } @@ -556,69 +556,69 @@ static int automount_unmount(FAR struct automounter_state_s *priv) ret = automount_findinode(lower->mountpoint); switch (ret) { - case OK_EXIST: + case OK_EXIST: - /* If we get here, then the volume must be mounted */ + /* If we get here, then the volume must be mounted */ - DEBUGASSERT(priv->mounted); + DEBUGASSERT(priv->mounted); - /* Un-mount the volume */ + /* Un-mount the volume */ - ret = nx_umount2(lower->mountpoint, MNT_FORCE); - if (ret < 0) - { - /* We expect the error to be EBUSY meaning that the volume could - * not be unmounted because there are currently reference via open - * files or directories. - */ + ret = nx_umount2(lower->mountpoint, MNT_FORCE); + if (ret < 0) + { + /* We expect the error to be EBUSY meaning that the volume could + * not be unmounted because there are currently reference via + * open files or directories. + */ - if (ret == -EBUSY) - { - finfo("WARNING: Volume is busy, try again later\n"); + if (ret == -EBUSY) + { + finfo("WARNING: Volume is busy, try again later\n"); - /* Start a timer to retry the umount2 after a delay */ + /* Start a timer to retry the umount2 after a delay */ - ret = wd_start(&priv->wdog, lower->udelay, - automount_timeout, (wdparm_t)priv); - if (ret < 0) - { - ferr("ERROR: wd_start failed: %d\n", ret); - return ret; - } - } + ret = wd_start(&priv->wdog, lower->udelay, + automount_timeout, (wdparm_t)priv); + if (ret < 0) + { + ferr("ERROR: wd_start failed: %d\n", ret); + return ret; + } + } - /* Other errors are fatal */ + /* Other errors are fatal */ - else - { - ferr("ERROR: umount2 failed: %d\n", ret); - return ret; - } - } + else + { + ferr("ERROR: umount2 failed: %d\n", ret); + return ret; + } + } - /* Fall through */ + /* Fall through */ - case OK_NOENT: + case OK_NOENT: - /* The mountpoint is not present. This is normal behavior in the - * case where the user manually un-mounted the volume before removing - * media. Nice job, Mr. user. - */ + /* The mountpoint is not present. This is normal behavior in the + * case where the user manually un-mounted the volume before removing + * media. Nice job, Mr. user. + */ - if (priv->mounted) - { - priv->mounted = false; + if (priv->mounted) + { + priv->mounted = false; #ifdef CONFIG_FS_AUTOMOUNTER_DRIVER - automount_notify(priv); + automount_notify(priv); #endif /* CONFIG_FS_AUTOMOUNTER_DRIVER */ - } + } - return OK; + return OK; - default: - ferr("ERROR: automount_findinode failed: %d\n", ret); - return ret; + default: + ferr("ERROR: automount_findinode failed: %d\n", ret); + return ret; } }
