Before you start#
Read A workstation, shell, and safe habits. Use a Linux shell as an ordinary user; the exercise changes only files in a fresh temporary directory. The source walkthrough is for upstream v6.12, while your running host may differ. Commands below are observations of your host, not proof it uses that source.
Learning goals#
Distinguish pathname, directory entry, inode, open file description and descriptor. Interpret basic mode bits, ownership and umask without treating them as the complete access policy. Identify a process and its credentials, and reason about access checks that can fail even when a name exists.
Names, objects and open handles#
A pathname is a recipe for locating an object, not the object itself. Absolute paths begin at the process's root; relative paths begin at its current working directory or a supplied directory descriptor. Each directory component must be searched. Symbolic links can introduce further lookup. Mounts and namespaces can change the resulting view, so identical path text need not denote the same object in two processes.
At the VFS layer a dentry represents a name lookup relationship; an inode represents filesystem object metadata and operations. Neither is simply a filename string. Multiple hard links can name the same inode within a filesystem; removing one name does not immediately destroy an object still named or held open. Filesystems differ, and special or virtual files can have unusual behavior. A filesystem instance and mount are also involved: an inode number alone is not a globally unique persistent identity. See the VFS documentation.
A successful open creates or obtains an open file description, represented in the kernel by struct file, with a position and operation table where applicable. A process's file descriptor is a small integer referring to that open object. Two descriptors can refer to the same open description, for example after duplication, and then may share its file offset. Two independent opens of the same pathname usually have separate open descriptions and offsets. Closing one descriptor removes that reference; other references can keep the object alive. A descriptor number can be reused, so a number recorded yesterday does not identify the same resource today.
Who may do what?#
A process has credentials, including real and effective user and group IDs and supplementary groups. An effective identity normally participates in ordinary access checks, but specific operations such as access() intentionally use different credential rules. A UID is not a PID: the former describes an identity involved in authority, the latter identifies a task or process in a PID namespace. Capabilities, user namespaces, mount options, Linux security modules and filesystem rules further constrain behavior. Root is not a universal bypass for every policy.
Mode bits select read, write and execute/search permissions for owner, group and others. For directories, execute means permission to search or traverse; read controls listing names, and write controls modifying directory entries, subject to further rules. File write permission and permission to unlink its directory entry are different questions. The umask is a creation mask: when no default ACL applies, the created mode is the requested mode with masked bits removed. With a default ACL, the ACL is inherited and the requested mode limits its permissions; the umask is not simply subtracted. For a requested 0666 and umask 0022, the familiar no-default-ACL result is 0644. Creation modes and actual modes should be observed rather than assumed. POSIX ACLs can express additional named users and groups; their mask limits effective permissions for the affected entries. See acl(5) and v6.12 POSIX ACL implementation.
The umask is applied in the creation-mode branch of posix_acl_create in v6.12 fs/posix_acl.c (GPL-2.0-only; the lines before and after this excerpt are omitted):
p = get_inode_acl(dir, ACL_TYPE_DEFAULT);
if (!p || p == ERR_PTR(-EOPNOTSUPP)) {
*mode &= ~current_umask();
return 0;
}
The subtraction happens only on this branch: no default ACL was found, or the filesystem reported EOPNOTSUPP. When a default ACL exists, the rest of the function computes the mode from the ACL instead, which is why the umask is not simply subtracted in that case. The excerpt does not show how get_inode_acl decides which case applies, so it establishes only the no-ACL result. The following synthetic example applies this branch to one requested mode:
Synthetic teaching data, not a device capture.
- No default ACL
- Requested 0666 with umask 0027 gives 0640: owner read and write, group read, others none.
- Directory has a default ACL
- The inherited ACL and requested mode decide the result; the umask is not simply subtracted.
- No ACL support (EOPNOTSUPP)
- Treated as no default ACL, so the umask still applies.
Source walkthrough#
- Pathname and directory
user_path_at resolves the requested name in a mount view.
- Path and inode
A successful lookup holds a path reference and finds the backing inode.
- Permission
inode_permission considers the requested mode and mount identity mapping.
- Cleanup
path_put releases the successful lookup reference; failures propagate.
Consider an ordinary process asking whether note is readable. In v6.12 fs/open.c, faccessat2 delegates to do_faccessat. This excerpt is from that function (GPL-2.0-only; surrounding lines omitted):
res = user_path_at(dfd, filename, lookup_flags, &path);
if (res)
goto out;
inode = d_backing_inode(path.dentry);
The directory descriptor and filename specify the starting point and name. user_path_at attempts lookup and fills a struct path on success; a nonzero result goes to cleanup without using an uninitialized path. The dentry then supplies the backing inode for the following checks. The path is a held kernel reference, not an ownership transfer to the calling process.
A later excerpt shows the permission check and release (same file and license; intervening lines omitted):
res = inode_permission(mnt_idmap(path.mnt), inode, mode | MAY_ACCESS);
out_path_release:
path_put(&path);
The first line asks the VFS permission machinery about the inode, mount identity mapping and requested mode. The second drops the acquired path reference on this cleanup route. Other code checks noexec, read-only mounts and retry conditions; the function also restores overridden credentials where required. A successful lookup is therefore not itself a grant of access. A successful access check is only a point-in-time result: another task may change a name, permissions or mount state before a later open. Do not use a separate check as a substitute for handling the result of the actual operation.
The following teaching model lists the do_faccessat steps in order, leaving out retry and credential handling:
- user_path_at
Resolve the directory descriptor and filename into a held path; failure exits early.
- d_backing_inode
Get the inode behind the dentry for the permission call.
- noexec check
Only for MAY_EXEC on a regular file; path_noexec can deny.
- inode_permission
The VFS permission check, using the mount id map and the requested mode.
- Read-only check
Only for a request with other-write on a non-special file; can return -EROFS.
- path_put
Release the path reference on the route taken after lookup.
For a concrete example, suppose /home/learner/lab/note exists and is readable by its owner, but lab has mode 000. A lookup by that unprivileged owner cannot traverse lab, so a read can fail with permission denied even though the file object exists. Conversely, granting search on lab and read on note can permit the read without granting permission to list the directory. ACLs or other policy can modify the result. The exact return depends on the credentials and environment; this is a model, not a claim about your host.
Processes, jobs and proc#
A shell job is a shell-level grouping used for foreground/background control, not a permanent global identifier. A process has a parent, descriptors, credentials and an address space; threads share selected resources. ps reports a snapshot, so a process can exit between listing it and acting on it. /proc is a virtual filesystem exposing selected kernel and process information, subject to configuration, namespaces and access restrictions. /proc/self refers to the process performing that particular lookup: in cat /proc/self/status, it is normally cat, not the shell. Use /proc/$$/status to inspect the shell's process if your shell expands $$ as expected. Consult proc documentation.
A permission decision over time#
Imagine a shell creates draft with requested mode 0666 while its umask is 0027 and there is no default ACL. The resulting mode is ordinarily 0640: the owner can read and write, the group can read, and others receive no mode-bit access. A second process in the owning group can read if every directory in its path permits search and other policies allow it. After the file is opened, renaming or unlinking the original name does not by itself invalidate the open descriptor; the held open-file reference can continue to identify the object. A later lookup of the old name can fail. Thus a pathname, an access check and a held descriptor each describe a different point in time. Access-check-then-open code must still handle the open's result and must not assume the name continued to identify the same inode.
Identity in an access decision#
A process can have real, effective, saved and filesystem user IDs, plus corresponding group identities and supplementary groups. The real UID identifies the account associated with the process for certain operations; the effective UID is used by many authority checks. Linux filesystem access checks normally use fsuid and fsgid, which usually track the effective IDs but can differ. Supplementary groups can also match a file's group or an ACL group entry. A numeric UID or GID must be interpreted in the relevant user-namespace and mount-idmapping context; the number displayed in one view is not necessarily the same representation in another. id is a useful snapshot, not a complete report of every capability, namespace mapping or LSM decision.
The access family deliberately answers a different question from an ordinary open: by default it checks using real IDs, with specified capability handling, while faccessat2 can alter relevant behavior through flags such as AT_EACCESS. The v6.12 do_faccessat code in the source walkthrough can temporarily override credentials for its check and later restores them. This makes it useful for some questions about the invoking user's access, but it does not make an access-check-then-open sequence safe against changes. A pathname, mount, ACL or identity can change between the check and the operation. The operation's own return is authoritative for that attempt.
Directory permissions and ACL masks#
For a regular file, read and write bits govern particular access requests, while execute permits a suitable executable to be run subject to other checks. On a directory, read permits listing names, write permits changing directory entries subject to additional rules, and execute means search/traversal. A user who knows a filename may traverse a searchable directory and read a permitted file without being able to list the directory. Conversely, permission to list names without search does not generally permit accessing each entry. Removing or renaming a name primarily involves the containing directories and their rules; the file's own write bit is not a universal unlink permission.
The sticky bit on a shared writable directory restricts removal or rename of entries to the file owner, directory owner or appropriately privileged process under the applicable rules. A setgid directory can affect the group inherited by newly created entries and can propagate setgid to new subdirectories, subject to filesystem and policy rules. Neither bit replaces directory search permission or all ACL and LSM checks. These are common Linux behaviors, not a claim that every filesystem or idmapped mount makes identical choices.
A POSIX ACL extends owner/group/other mode-style permissions with named user and group entries. The ACL mask limits the effective rights of named users and the group class; it does not simply limit the owner entry. A default ACL on a directory can be inherited by a newly created child, with requested creation mode constraining the result. The usual requested mode & ~umask explanation applies when no default ACL changes that calculation. Observe the actual result with stat and, if installed and supported, getfacl; lack of that tool or filesystem support is a limitation, not proof that no access policy exists.
In v6.12 fs/posix_acl.c, posix_acl_permission applies the mask on its named-user/group branch (SPDX GPL-2.0-only; loop and error return omitted):
if ((pa->e_perm & mask_obj->e_perm & want) == want)
return 0;
pa->e_perm is the candidate ACL entry's permissions, mask_obj->e_perm is the ACL mask and want is the requested read/write/execute subset. The intersection must contain every requested bit for that branch to return success. Otherwise the source returns -EACCES. The earlier branch maps IDs through the mount's idmap and compares filesystem credentials and groups; owner and other entries have different paths. ACL evaluation is one part of the broader VFS decision, not a shortcut around capabilities, mount restrictions or LSM hooks.
A path with two directories#
Assume an ordinary unprivileged user U wants to read /home/u/lab/report, with no ACL or capability override. The process must be able to search /, /home, /home/u and lab; then the file's applicable access rule must allow reading. Suppose lab is owned by U with mode 0600: it has read and write but no directory search bit. Even if report itself is 0644, lookup fails at lab, before the final file's read bit can grant the request. Changing lab to 0700 permits owner traversal under these assumptions; it does not grant any access to another user. This is the state exercised below, not a claim about all mount policies.
In the source's do_faccessat path, user_path_at can return an error during that traversal. The code goes to its outer cleanup without treating an uninitialized struct path as valid. On successful resolution, it obtains the backing inode, checks the requested access and mount conditions, then uses path_put on the path-release branch. If a filesystem or LSM denies access, or a rename changes the path between a prior check and an open, the resulting error can differ. The path reference's lifetime and the process's access authority are separate issues.
Names, inherited context, and process groups#
A hard link adds a directory entry for the same inode within the filesystem's rules; deleting one name reduces the link count but leaves the object accessible through other links or existing open references. A symbolic link instead contains a target path to be resolved; its target can be relative to the link's directory and can become dangling. Renaming a pathname changes directory entries, while an already-open file descriptor continues to refer to its open file description. Whether a rename is atomic and permitted depends on the operation, filesystem and mount boundaries; do not use a pathname string as a durable object identity.
A child inherits an environment and a working directory from its parent at process creation, subject to later changes and exec rules. Environment variables such as PATH are strings, not kernel authorization; an attacker-controlled search path can select an unintended executable. Shell redirections set up descriptors for a command, and a pipe connects endpoints, but neither changes the caller's UID. A process group is a set of processes used for job control and signal delivery; it is not a permission group and is distinct from a GID. A shell's background job number is local shell bookkeeping, not a kernel-wide PID.
/proc visibility can be limited by namespaces, mount options and ptrace/credential checks. Some entries for another user or another PID namespace may be absent or redacted; owner-only access is a useful expectation in some configurations, not a universal promise that all process metadata is private. Read only your own process for the exercise, avoid publishing command lines or environment values, and do not use elevated privileges to overcome a visibility restriction. DAC mode and ACL checks coexist with capabilities, LSMs and mount rules; passing one layer does not guarantee success through all others.
Practice#
As an ordinary Linux user, create a private directory; do not run as root or change a shared directory. If the filesystem has default ACLs, record them with getfacl if installed; results can differ. Commands use POSIX shell and common GNU utilities.
lab_dir=$(mktemp -d) || exit 1
cd "$lab_dir" || exit 1
umask 077
printf 'evidence\n' > note
stat -c 'mode=%a uid=%u gid=%g inode=%i links=%h' note
id
printf 'shell PID=%s\n' "$$"
ps -p "$$" -o pid,ppid,stat,comm
sed -n '1,12p' "/proc/$$/status"
mkdir gate
printf 'inside\n' > gate/item
chmod 000 gate
cat gate/item
The newly created note will commonly be mode 600 with no default ACL, and cat should report permission denied for the inaccessible directory. Do not expect exact IDs, inode numbers or process state. If the read succeeds, check whether the shell has elevated capabilities, is root, or the filesystem/policy behaves differently; do not claim the expected result occurred. ls gate and cat gate/item are different operations and do not prove the same permission. Restore access before cleanup:
chmod 700 gate
cat gate/item
rm gate/item
rmdir gate
rm note
cd /tmp
rmdir "$lab_dir"
Keep a note with the observed mode, UID/GID, shell PID, failure and the directory traversal explanation. Do not recursively remove a directory if cleanup reports unexpected contents; inspect it first.
Knowledge check#
-
Why can two names have one inode while two opens have separate positions?
-
Why can a file exist but a pathname-based read fail before checking the file's mode?
-
What resource does
path_putrelease in the source example, and when is it reached? -
What does umask
077tell you, and what other mechanisms can affect access? -
How can a named ACL entry grant read permission but still fail the access check?
Answers#
-
Names are directory links to an inode; independent opens create separate open file descriptions with their own positions. Duplicated descriptors can instead share one open description.
-
Every directory component must be searchable. If traversal fails, pathname resolution cannot reach the target; credentials, ACLs and other policy also matter.
-
It drops the
struct pathreference obtained by successful lookup, on the path-release route. A failed initial lookup jumps to the other cleanup path instead. -
It normally removes group and other permission bits from requested creation modes. Default ACLs, mount rules, capabilities and security modules make a simple mode calculation an incomplete access decision.
-
The ACL mask can remove the requested read bit from the named entry’s effective rights. The v6.12 mask branch checks the entry, mask and requested bits together; other permission layers still apply.
Checkpoint#
The artifact is the observation note. It passes when it separates PID from UID, names all four pathname-to-descriptor layers, explains the observed traversal failure and identifies the source cleanup. If the result differs, record credentials and filesystem behavior, restore directory access and repeat in a new private directory. Progress is not certification.
Sources#
- v6.12 fs/open.c:
do_faccessatand cleanup. - v6.12 include/linux/fs.h: VFS structures.
- VFS documentation.
- proc documentation.
- v6.12 POSIX ACL implementation.