File Descriptor / I/O · Linux userspace / kernel ABI

fd table, open file description, openat2

Distinguishes a small integer fd from struct file, and shows how dirfd-relative resolution plus openat2 resolve flags reduce pathname races.

Series
11 / 38
Build
cc -std=c17 -Wall -Wextra -O2 openat2_root.c -o openat2_root
Run
./openat2_root . README.md
Kernel
Linux 6.18.37 LTS

Do two fds for the same file always have separate offsets?

An fd is an index in a per-process table, while an open file description is the kernel's struct file. Two calls to open() normally create two struct files, but fds duplicated by dup() or fork() refer to the same struct file and share its offset and file status flags.

Code that inspects a pathname and then opens it again allows a directory entry to change between the two operations. openat2 applies a dirfd and a resolution policy in a single lookup, blocking symlinks, mount crossings, and root escapes inside the kernel path walk.

Structure diagram

Figure 1. Reference structure from an fd number to an inode

files_struct

  • fd 0 → tty file
  • fd 3 → file A
  • fd 7 → file A

struct file A

  • f_pos=4096
  • f_flags=O_RDONLY
  • f_path

struct path

  • vfsmount
  • dentry: config
  • parent dentry

inode

  • mode/uid/size
  • address_space
  • file_operations

When fd 3 and fd 7 refer to the same struct file, they share the file offset and status flags. FD_CLOEXEC belongs separately to each fd slot.

Call path

Figure 2. From userspace code to observable results
dirfd search base directory
open_how flags/mode/resolve
path walk component-by-component lookup
struct file create open description
fd slot install as small integer

Distinguish the lookup stage that obtains a path object from the stage that publishes the open file object in the fd table. On failure, both the reserved fd slot and temporary path references must be rolled back.

Figure 3. Major points along the kernel-internal path
sys_openat2 copy open_how
do_sys_openat2 fd reserve
do_filp_open nameidata walk
vfs_open set file->f_op
fd_install fd table publish

This diagram is not for memorizing function names. Follow which return value, file descriptor, memory mapping, or wait queue is passed to the next stage.

Linux 6.18.37 LTS source locations

Go beyond the glibc function to the files where the syscall implementation meets kernel objects. Each link points to the original file at the same tag.

FileFunction / structureWhat to inspect
fs/open.c SYSCALL_DEFINE4(openat2), do_sys_openat2() Validate open_how and install the fd
fs/namei.c path_openat(), link_path_walk() Resolve components relative to dirfd with resolve restrictions
fs/file.c get_unused_fd_flags(), fd_install() Reserve an fd slot and publish struct file

Complete runnable example

The code below is not pseudocode with explanatory lines omitted. It is a minimal example that can be built and run as a file.

Buildcc -std=c17 -Wall -Wextra -O2 openat2_root.c -o openat2_root
01#define _GNU_SOURCE
02#include <fcntl.h>
03#include <linux/openat2.h>
04#include <stdio.h>
05#include <sys/syscall.h>
06#include <unistd.h>
07
08int main(int argc, char **argv)
09{
10    if (argc != 3)
11        return 2;
12    int root = open(argv[1], O_PATH | O_DIRECTORY | O_CLOEXEC);
13    if (root < 0)
14        return 1;
15
16    struct open_how how = {
17        .flags = O_RDONLY | O_CLOEXEC,
18        .resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS
19    };
20    int fd = (int)syscall(SYS_openat2, root, argv[2], &how, sizeof(how));
21    if (fd < 0) {
22        perror("openat2");
23        close(root);
24        return 1;
25    }
26
27    char byte;
28    ssize_t n = read(fd, &byte, 1);
29    if (n == 1)
30        printf("first byte: 0x%02x\n", (unsigned char)byte);
31    close(fd);
32    close(root);
33    return n < 0;
34}

Code notes

Source line 12O_PATH | O_DIRECTORY

Opens the directory not to read it, but as a stable base handle for pathname resolution. O_DIRECTORY rejects a non-directory.

Source line 16struct open_how how

Passes the structure size with the syscall to distinguish future extensions. Unused fields must be 0.

Source line 18RESOLVE_BENEATH

Rejects lookups that escape below the dirfd through .., an absolute symlink, a mount, or similar mechanisms. This is stronger than a simple string-prefix check.

Source line 20SYS_openat2

Passes the root fd and relative path through one kernel path walk, removing the pathname-replacement window between inspection and open.

Source line 23close(root)

After obtaining the target fd, the root fd is still an independent reference. Close each separately when its ownership ends.

Detailed behavior

01

Distinguish fd flags from open-description flags

FD_CLOEXEC belongs to an fd slot and is managed with fcntl(F_GETFD/F_SETFD). Status flags such as O_APPEND and O_NONBLOCK belong to struct file and are shared by duplicated fds.

Results from separate open() calls have independent file offsets and status flags even when they refer to the same inode.

02

A dirfd eliminates working-directory races

Code based on chdir changes the process-wide current working directory and therefore affects relative resolution in other threads. The openat family passes the base directory as an argument to each operation.

Even after the directory is renamed, an open dirfd continues to refer to that directory object and is therefore more stable than a pathname string.

03

Put validation and use in the same lookup

If code checks for a symlink with lstat and then calls open, an attacker can replace the entry between the calls. O_NOFOLLOW restricts only the final component and does not solve policy for intermediate symlinks.

openat2 resolve flags apply policy while kernel namei walks every component.

Objects and lifetimes

ObjectCreation and releaseValues to inspect
files_structAn fd table that can be shared by a process/thread group and is released at exitfd array, close_on_exec
struct fileCreated by open/accept and released when its last fd/reference closesf_pos, f_flags, f_path
struct pathA mount-and-dentry reference pair retained during lookupmnt, dentry, refcount

Failure conditions and common misconceptions

Observed symptomLikely causesHow to verify
EXDEVA RESOLVE_BENEATH/IN_ROOT policy violationInspect path components and mount/symlink traversal
EMFILE/ENFILEThe per-process or system-wide open-file limitulimit -n, file-nr
The offsets of duplicated fds move togetherThey share the same open file descriptionInspect kcmp KCMP_FILE or /proc fdinfo

Verify it yourself

  1. Duplicate the same fd, alternate reads through both numbers, and inspect /proc/self/fdinfo to confirm that the offset is shared.
  2. Create a symlink below the root that points outside through .. and verify that openat2 rejects it with EXDEV.
  3. Compare two separate open() calls with two dup() results and inspect sharing of f_pos and the O_NONBLOCK flag.
Run./openat2_root . README.md
Tracestrace -e trace=openat,openat2,read,close ./openat2_root . README.md

Primary sources