QUESTION
같은 파일을 가리키는 두 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 안에서 막는다.
STRUCTURE
구조 그림
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에 따로 붙는다.
CALL PATH
호출 흐름
path 객체를 얻는 lookup 단계와 열린 file 객체를 fd table에 공개하는 단계를 구분한다. 실패하면 예약한 fd slot과 임시 path 참조가 모두 되돌려져야 한다.
함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.
SOURCE COORDINATES
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 |
COMPLETE PROGRAM
실행 예제 원본
아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.
cc -std=c17 -Wall -Wextra -O2 openat2_root.c -o openat2_root01#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
코드 조각별 설명
O_PATH | O_DIRECTORYdirectory를 읽기 위한 fd가 아니라 경로 해석의 안정된 기준 handle로 연다. O_DIRECTORY로 non-directory를 거부한다.
struct open_how how구조체 크기를 syscall에 함께 넘겨 future extension을 구분한다. 사용하지 않는 field는 0이어야 한다.
RESOLVE_BENEATH.., absolute symlink, mount 등으로 dirfd 아래에서 빠져나가는 lookup을 거부한다. 단순 문자열 prefix 검사보다 강하다.
SYS_openat2root fd와 relative path를 한 번의 kernel path walk에 넘긴다. 검사와 open 사이 pathname 교체 창을 없앤다.
close(root)target fd를 얻은 뒤에도 root fd는 독립 참조다. 소유권을 끝낼 때 각각 close한다.
DETAILS
세부 동작
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를 따로 가진다.
dirfd는 working directory race를 없앤다
chdir 기반 코드는 process 전체 current working directory를 바꾸므로 다른 thread의 상대 경로 해석에 영향을 준다. openat 계열은 operation마다 기준 directory를 인자로 전달한다.
directory를 rename해도 열린 dirfd는 해당 directory object를 계속 가리키므로 pathname 문자열보다 안정적이다.
검사와 사용을 같은 lookup에 넣는다
lstat로 symlink 여부를 확인한 뒤 open하면 두 호출 사이 attacker가 entry를 바꿀 수 있다. O_NOFOLLOW는 마지막 component만 제한하고 중간 symlink 정책은 해결하지 않는다.
openat2 resolve flag는 kernel namei가 component를 걷는 동안 정책을 적용한다.
OBJECTS
객체와 수명
| 대상 | 언제 생기고 없어지는가 | 확인할 값 |
|---|---|---|
files_struct | process/thread group이 공유할 수 있는 fd table이며 exit에서 해제된다 | fd array, close_on_exec |
struct file | open/accept에서 생기고 마지막 fd/reference가 닫힐 때 release된다 | f_pos, f_flags, f_path |
struct path | mount와 dentry 참조 쌍으로 lookup 동안 유지된다 | mnt, dentry, refcount |
FAILURE PATH
실패 조건과 오해하기 쉬운 부분
| 겉으로 보이는 현상 | 실제 원인 후보 | 확인 방법 |
|---|---|---|
| EXDEV | RESOLVE_BENEATH/IN_ROOT 정책 위반 | path component와 mount/symlink 확인 |
| EMFILE/ENFILE | process 또는 system open file limit | ulimit -n, file-nr |
| dup fd offset이 함께 움직임 | 같은 open file description 공유 | kcmp KCMP_FILE 또는 /proc fdinfo |
LAB
직접 확인
- 같은 fd를 dup한 뒤 번갈아 read하여 offset이 공유되는지 /proc/self/fdinfo로 확인한다.
- root 아래 symlink가 .. 밖을 가리키게 만들고 openat2가 EXDEV로 거부하는지 본다.
- open() 두 번과 dup() 두 번을 비교해 f_pos와 O_NONBLOCK flag 공유 여부를 확인한다.
./openat2_root . README.mdstrace -e trace=openat,openat2,read,close ./openat2_root . README.mdPRIMARY REFERENCES