← System Programming DUJINLABS.COM

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

fd table, open file description, openat2

작은 정수 fd와 struct file을 구분하고, dirfd 기반 경로 해석과 openat2 resolve flag로 pathname race를 줄이는 방법을 봅니다.

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

같은 파일을 가리키는 두 fd는 offset도 항상 따로 가지는가?

fd는 process별 table index이고, open file description은 커널의 struct file이다. open()을 두 번 하면 보통 struct file도 둘이지만 dup()이나 fork()로 복제한 fd는 같은 struct file을 가리켜 offset과 file status flag를 공유한다.

pathname을 검사한 뒤 다시 open하는 코드는 그 사이 directory entry가 바뀔 수 있다. openat2는 dirfd와 resolve policy를 한 번의 lookup에 적용해 symlink, mount crossing, root escape 조건을 kernel path walk 안에서 막는다.

구조 그림

그림 1. fd 숫자에서 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

fd 3과 fd 7이 같은 struct file을 가리키면 file offset과 status flag를 공유한다. FD_CLOEXEC는 각 fd slot에 따로 붙는다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
dirfd 검색 기준 directory
open_how flags/mode/resolve
path walk component별 lookup
struct file open description 생성
fd slot 작은 정수로 설치

path 객체를 얻는 lookup 단계와 열린 file 객체를 fd table에 공개하는 단계를 구분한다. 실패하면 예약한 fd slot과 임시 path 참조가 모두 되돌려져야 한다.

그림 3. 커널 내부에서 지나가는 주요 지점
sys_openat2 open_how 복사
do_sys_openat2 fd reserve
do_filp_open nameidata walk
vfs_open file->f_op 설정
fd_install fd table publish

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

Linux 6.18.37 LTS 소스 위치

glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.

파일함수·구조체여기서 볼 것
fs/open.c SYSCALL_DEFINE4(openat2), do_sys_openat2() open_how validation과 fd 설치
fs/namei.c path_openat(), link_path_walk() dirfd 기준 component lookup과 resolve 제한
fs/file.c get_unused_fd_flags(), fd_install() fd slot 예약과 struct file publish

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -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}

코드 조각별 설명

실제 코드 12행O_PATH | O_DIRECTORY

directory를 읽기 위한 fd가 아니라 경로 해석의 안정된 기준 handle로 연다. O_DIRECTORY로 non-directory를 거부한다.

실제 코드 16행struct open_how how

구조체 크기를 syscall에 함께 넘겨 future extension을 구분한다. 사용하지 않는 field는 0이어야 한다.

실제 코드 18행RESOLVE_BENEATH

.., absolute symlink, mount 등으로 dirfd 아래에서 빠져나가는 lookup을 거부한다. 단순 문자열 prefix 검사보다 강하다.

실제 코드 20행SYS_openat2

root fd와 relative path를 한 번의 kernel path walk에 넘긴다. 검사와 open 사이 pathname 교체 창을 없앤다.

실제 코드 23행close(root)

target fd를 얻은 뒤에도 root fd는 독립 참조다. 소유권을 끝낼 때 각각 close한다.

세부 동작

01

fd와 open description의 flag를 구분한다

FD_CLOEXEC는 fd slot에 붙고 fcntl(F_GETFD/F_SETFD)로 다룬다. O_APPEND, O_NONBLOCK 같은 status flag는 struct file에 붙어 dup된 fd끼리 공유한다.

서로 다른 open() 결과는 같은 inode를 가리켜도 file offset과 status flag를 따로 가진다.

02

dirfd는 working directory race를 없앤다

chdir 기반 코드는 process 전체 current working directory를 바꾸므로 다른 thread의 상대 경로 해석에 영향을 준다. openat 계열은 operation마다 기준 directory를 인자로 전달한다.

directory를 rename해도 열린 dirfd는 해당 directory object를 계속 가리키므로 pathname 문자열보다 안정적이다.

03

검사와 사용을 같은 lookup에 넣는다

lstat로 symlink 여부를 확인한 뒤 open하면 두 호출 사이 attacker가 entry를 바꿀 수 있다. O_NOFOLLOW는 마지막 component만 제한하고 중간 symlink 정책은 해결하지 않는다.

openat2 resolve flag는 kernel namei가 component를 걷는 동안 정책을 적용한다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
files_structprocess/thread group이 공유할 수 있는 fd table이며 exit에서 해제된다fd array, close_on_exec
struct fileopen/accept에서 생기고 마지막 fd/reference가 닫힐 때 release된다f_pos, f_flags, f_path
struct pathmount와 dentry 참조 쌍으로 lookup 동안 유지된다mnt, dentry, refcount

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
EXDEVRESOLVE_BENEATH/IN_ROOT 정책 위반path component와 mount/symlink 확인
EMFILE/ENFILEprocess 또는 system open file limitulimit -n, file-nr
dup fd offset이 함께 움직임같은 open file description 공유kcmp KCMP_FILE 또는 /proc fdinfo

직접 확인

  1. 같은 fd를 dup한 뒤 번갈아 read하여 offset이 공유되는지 /proc/self/fdinfo로 확인한다.
  2. root 아래 symlink가 .. 밖을 가리키게 만들고 openat2가 EXDEV로 거부하는지 본다.
  3. open() 두 번과 dup() 두 번을 비교해 f_pos와 O_NONBLOCK flag 공유 여부를 확인한다.
실행./openat2_root . README.md
추적strace -e trace=openat,openat2,read,close ./openat2_root . README.md

원문