Hệ điều hành · 03/10/2026

Linux openat2 thực chiến: Chặn path traversal và symlink race

Ứng dụng nhận đường dẫn invoices/2026/report.pdf từ người dùng và chỉ muốn mở file bên trong /srv/uploads. Kiểm tra không có ../, gọi realpath(), thấy path vẫn nằm trong thư mục cho phép — nhưng attacker đổi symlink giữa lúc kiểm tra và open(), khiến ứng dụng mở file ngoài sandbox.

Linux openat2 thực chiến: Chặn path traversal và symlink race

Ứng dụng nhận đường dẫn invoices/2026/report.pdf từ người dùng và chỉ muốn mở file bên trong /srv/uploads. Kiểm tra không có ../, gọi realpath(), thấy path vẫn nằm trong thư mục cho phép — nhưng attacker đổi symlink giữa lúc kiểm tra và open(), khiến ứng dụng mở file ngoài sandbox.

openat2() đưa các ràng buộc path resolution vào chính một system call của kernel, loại bỏ nhiều race mà sanitize chuỗi và check-then-open không xử lý được. Bài này hướng dẫn chọn RESOLVE_BENEATH, RESOLVE_IN_ROOT, RESOLVE_NO_SYMLINKS, RESOLVE_NO_MAGICLINKS, RESOLVE_NO_XDEV, xử lý lỗi đúng và thiết kế fallback an toàn. Nội dung được đối chiếu với openat2(2), path_resolution(7) và tài liệu pathname lookup của Linux kernel.

1. Path traversal không chỉ là chuỗi ../

Một bộ lọc lexical có thể chặn ../ nhưng path resolution còn chịu tác động của:

  • symlink ở bất kỳ component nào;
  • absolute symlink trỏ về filesystem root;
  • magic link trong /proc/<pid>/fd hoặc /proc/<pid>/exe;
  • bind mount/mount point nằm trong cây được phép;
  • directory bị rename đồng thời;
  • path tuyệt đối lọt qua logic nối chuỗi;
  • khác biệt giữa bước validate và bước open.

Mẫu sau không an toàn:

char *resolved = realpath(user_path, NULL);
if (starts_with(resolved, "/srv/uploads/")) {
    fd = open(resolved, O_RDONLY);
}

Giữa realpath() và open(), attacker có thể thay đổi hierarchy. Kiểm tra prefix chuỗi còn dễ sai ở boundary như /srv/uploads-evil, encoding hoặc path không tồn tại khi tạo file mới.

Không validate một pathname rồi mở lại bằng tên. Hãy yêu cầu kernel resolve và enforce constraint trong cùng thao tác, sau đó làm việc bằng file descriptor.

2. openat2 mở rộng openat như thế nào?

openat2() xuất hiện từ Linux 5.6. Giống openat(), path tương đối được resolve từ directory file descriptor; khác biệt quan trọng là cấu trúc open_how có field resolve để áp policy lên toàn bộ quá trình lookup.

#include <fcntl.h>
#include <linux/openat2.h>
#include <sys/syscall.h>
#include <unistd.h>

struct open_how how = {
    .flags = O_RDONLY | O_CLOEXEC,
    .mode = 0,
    .resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};

int fd = syscall(
    SYS_openat2,
    rootfd,
    user_path,
    &how,
    sizeof(how)
);

Man page hiện hành lưu ý glibc chưa cung cấp wrapper, nên code gọi qua syscall(). Zero-initialize toàn bộ open_how để tương thích khi headers tương lai thêm field. size phải phản ánh cấu trúc mà chương trình thực sự truyền.

3. Neo lookup bằng directory file descriptor

Mở root tin cậy lúc khởi động:

int rootfd = open(
    "/srv/uploads",
    O_PATH | O_DIRECTORY | O_CLOEXEC
);

if (rootfd == -1) {
    /* fail startup */
}

O_PATH tạo handle tới directory mà không mở nội dung để đọc/ghi. Ngay cả khi directory được rename, file descriptor vẫn tham chiếu object đó. Root path phải đến từ cấu hình tin cậy; kiểm tra bằng fstat(), ownership, mode và mount policy khi threat model yêu cầu.

Sau đó mọi path từ người dùng phải được truyền dưới dạng path tương đối tới rootfd, trừ khi chủ động dùng semantics RESOLVE_IN_ROOT.

4. RESOLVE_BENEATH: không được thoát xuống dưới root

RESOLVE_BENEATH yêu cầu mọi component được resolve là descendant của directory dirfd. Nó từ chối:

  • path tuyệt đối;
  • .. hoặc symlink dẫn ra ngoài root;
  • absolute symlink;
  • các escape khác mà kernel phát hiện trong lookup.
.resolve = RESOLVE_BENEATH |
           RESOLVE_NO_MAGICLINKS

Hiện tại RESOLVE_BENEATH cũng chặn magic link, nhưng man page khuyến nghị khai báo RESOLVE_NO_MAGICLINKS rõ ràng vì hành vi ngầm có thể thay đổi trong tương lai.

BENEATH phù hợp khi API contract nhận path tương đối như tenant-a/report.pdf và bất kỳ path tuyệt đối nào đều là input lỗi.

5. RESOLVE_IN_ROOT: root tạm thời cho từng lần open

RESOLVE_IN_ROOT coi directory dirfd là filesystem root trong lần lookup:

  • path tuyệt đối được hiểu tương đối với dirfd;
  • absolute symlink cũng được resolve bên trong root tạm;
  • .. tại root hoạt động như /.., tức vẫn ở root.
.resolve = RESOLVE_IN_ROOT |
           RESOLVE_NO_MAGICLINKS

Nó giống một chroot chỉ áp cho một thao tác, không thay root của toàn process. IN_ROOT hữu ích khi xử lý path được tạo cho một filesystem image, container rootfs hoặc archive extraction mà path có thể bắt đầu bằng /.

Không kết hợp mù RESOLVE_BENEATH và RESOLVE_IN_ROOT. Chọn semantics theo contract: “chỉ path tương đối ở bên dưới” hoặc “diễn giải path như bên trong root riêng”.

6. O_NOFOLLOW khác RESOLVE_NO_SYMLINKS

O_NOFOLLOW chỉ ảnh hưởng symlink ở component cuối cùng. Nó không ngăn:

safe-dir/link-to-outside/secret.txt

nếu link-to-outside là symlink ở giữa. RESOLVE_NO_SYMLINKS cấm symlink ở mọi component và ngầm cấm magic link:

.resolve = RESOLVE_BENEATH |
           RESOLVE_NO_SYMLINKS

Đây là lựa chọn mạnh cho upload spool, extraction root hoặc directory do tenant kiểm soát. Nhưng symlink được dùng rộng rãi trong filesystem hệ thống; áp cờ này cho mọi path có thể làm ứng dụng hỏng sau update distribution. Hãy dùng theo phạm vi security cụ thể và cho phép cấu hình khi use case cần symlink hợp lệ.

7. Magic link nguy hiểm khác symlink thường

Magic link trong procfs có thể trỏ tới object kernel/file descriptor và không tuân theo mọi semantics của symlink thông thường. Ví dụ:

/proc/self/fd/7
/proc/1234/exe

Trong container hoặc process đặc quyền, theo magic link có thể mở object ngoài view dự kiến hoặc vô tình nhận controlling terminal. Dùng:

RESOLVE_NO_MAGICLINKS

ngay cả khi đang dựa vào BENEATH/IN_ROOT. Nếu cấm toàn bộ symlink bằng RESOLVE_NO_SYMLINKS, magic link cũng đã bị cấm.

8. RESOLVE_NO_XDEV chặn mount traversal

RESOLVE_NO_XDEV từ chối mọi lần đi qua mount point, kể cả bind mount:

.resolve = RESOLVE_BENEATH |
           RESOLVE_NO_MAGICLINKS |
           RESOLVE_NO_XDEV

Cờ này hữu ích khi tenant có thể ảnh hưởng mount namespace hoặc thư mục cho phép không được phép chứa mount khác. Tuy nhiên bind mount hợp lệ xuất hiện nhiều trong container và deployment. Bật NO_XDEV toàn cục có thể chặn layout bình thường.

Threat model phải trả lời: mount bên trong root có được tin cậy không? Nếu chỉ administrator kiểm soát mount namespace, BENEATH có thể đủ cho ranh giới path; nếu attacker có khả năng tạo/di chuyển mount, cần lớp namespace/capability policy và cân nhắc NO_XDEV.

9. Mẫu wrapper mở file đọc an toàn

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <linux/openat2.h>
#include <stdint.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>

static int secure_open_beneath(int rootfd, const char *path)
{
    struct open_how how = {
        .flags = O_RDONLY | O_CLOEXEC,
        .mode = 0,
        .resolve = RESOLVE_BENEATH |
                   RESOLVE_NO_SYMLINKS |
                   RESOLVE_NO_XDEV,
    };

    int fd = syscall(
        SYS_openat2,
        rootfd,
        path,
        &how,
        sizeof(how)
    );

    if (fd == -1)
        return -1;

    struct stat st;
    if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode)) {
        int saved = errno ? errno : EINVAL;
        close(fd);
        errno = saved;
        return -1;
    }

    return fd;
}

Constraint path không tự chứng minh final object là regular file. Sau open, dùng fstat() trên descriptor để từ chối directory, device, FIFO hoặc socket. Sau đó đọc/ghi bằng fd; không mở lại pathname.

10. Tạo file mới an toàn

openat2() hỗ trợ O_CREAT. Đặt mode đúng và chỉ mở trong parent hierarchy đã constraint:

struct open_how how = {
    .flags = O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC,
    .mode = 0600,
    .resolve = RESOLVE_BENEATH |
               RESOLVE_NO_SYMLINKS |
               RESOLVE_NO_XDEV,
};

O_EXCL tránh ghi đè target có sẵn trong workflow cần unique name. Umask vẫn ảnh hưởng mode. Nếu cần publish atomically, cân nhắc file tạm trong cùng directory/filesystem rồi fsync và renameat2() theo semantics phù hợp.

Không dùng access() để check rồi mới open(). Hãy thử open với credential thật và xử lý lỗi; check-then-open tạo thêm race.

11. openat2 không bảo vệ mọi thao tác pathname

openat2() trả một file descriptor an toàn cho object được mở. Nó không tự biến các thao tác pathname khác thành race-free:

  • unlink(), rename(), chmod() theo path vẫn có contract riêng;
  • mở parent an toàn rồi gọi unlinkat() bằng basename giảm scope nhưng vẫn cần threat model về concurrent rename;
  • archive extraction gồm create directory, symlink, hard link và metadata — không chỉ open file;
  • hard link có thể làm object nằm trong tree nhưng refer inode nhạy cảm nếu attacker có quyền tạo link; DAC và fs.protected_hardlinks vẫn quan trọng.

Thiết kế ưu tiên fd-based APIs, directory ownership chặt, mount namespace riêng và worker ít quyền. openat2 là primitive mạnh, không phải sandbox hoàn chỉnh.

12. Xử lý errno theo nguyên nhân

errnoÝ nghĩa điển hìnhHành động
EXDEVEscape BENEATH/IN_ROOT hoặc vượt mount với NO_XDEVTừ chối input; log loại vi phạm, không trả path nhạy cảm.
ELOOPGặp symlink/magic link bị policy cấmTừ chối hoặc dùng policy khác đã được review.
EAGAINKernel không chứng minh được an toàn do rename race; hoặc cache miss với CACHEDRetry có giới hạn, giữ nguyên security flags.
ENOSYSKernel không có openat2Fail closed hoặc dùng fallback đã đánh giá.
EINVALFlag/mode/size không hợp lệ hoặc kernel không biết flagFeature-detect; không âm thầm bỏ constraint.
E2BIGStruct extension không được kernel hỗ trợTuân theo extensibility contract, không cắt bừa dữ liệu.
ENOENTComponent/target không tồn tạiXử lý nghiệp vụ; không suy ra đó là traversal.

Với EAGAIN do BENEATH/IN_ROOT và concurrent rename, retry toàn bộ openat2() một số lần giới hạn; nếu vẫn lỗi, fail closed. Không retry bằng open() không constraint.

13. RESOLVE_CACHED là tối ưu hiệu năng, không phải policy bảo mật

RESOLVE_CACHED yêu cầu lookup hoàn tất chỉ từ dentry cache; nếu cần I/O/revalidation, syscall trả EAGAIN. Event loop có thể thử fast path rồi offload slow path:

resolve = security_flags | RESOLVE_CACHED;

if (errno == EAGAIN) {
    resolve = security_flags; /* bỏ CACHED, giữ constraint */
    /* retry ở worker phù hợp */
}

Đừng nhầm cache hit với path tin cậy. Security flags vẫn phải có ở cả fast path và retry.

14. Fallback cho kernel cũ

Không có fallback đơn giản nào tương đương toàn bộ openat2. Cách walk từng component bằng openat(), O_PATH, O_NOFOLLOW và directory fd có thể giảm rủi ro, nhưng phải xử lý symlink, mount, rename, final component và error rất cẩn thận.

Ưu tiên theo thứ tự:

  1. Đặt Linux 5.6+ làm minimum cho component xử lý path không tin cậy.
  2. Chạy chức năng trong helper/container/mount namespace ít quyền.
  3. Dùng thư viện đã audit có semantics rõ ràng.
  4. Nếu buộc tự implement component walk, document guarantee yếu hơn và fuzz/race-test.
  5. Fail closed cho upload/extraction đặc quyền nếu không có primitive cần thiết.

Không fallback sang realpath() + prefix check + open() rồi gọi đó là cùng mức bảo vệ.

15. Thiết kế API phía trên openat2

API ứng dụng nên làm contract đơn giản:

  • nhận path tương đối đã decode đúng một lần;
  • giới hạn độ dài và từ chối byte NUL ở layer ngôn ngữ;
  • không tự nối root string với user path;
  • map mỗi tenant tới rootfd riêng, không chỉ prefix tên;
  • trả lỗi chung cho client, log errno/policy nội bộ;
  • không cache pathname đã “validate”; cache rootfd/config có lifecycle rõ;
  • giữ file descriptor xuyên suốt xử lý.

Nếu business cho phép symlink nội bộ, dùng BENEATH + NO_MAGICLINKS và test absolute/relative symlink. Nếu không cần symlink, thêm NO_SYMLINKS để contract dễ audit hơn.

16. Kiểm thử đối kháng

Bộ test nên gồm:

  • ../secret, nhiều lớp .. và path tuyệt đối;
  • symlink cuối và symlink ở component giữa;
  • absolute symlink và relative symlink thoát root;
  • magic link qua /proc/self/fd trong namespace test;
  • bind mount/mount point khi NO_XDEV bật/tắt;
  • thread liên tục đổi symlink/rename directory trong lúc nhiều thread open;
  • FIFO, socket, device và directory thay cho regular file;
  • file bị rename sau open — fd vẫn phải trỏ object đã mở;
  • kernel thiếu openat2 hoặc thiếu flag;
  • EAGAIN retry budget và log/metric.

Chạy race test trong mount/user namespace hoặc VM cô lập. Không tạo mount/symlink storm trên production.

17. Checklist production

  • Mở root tin cậy bằng O_PATH|O_DIRECTORY|O_CLOEXEC.
  • Chọn rõ BENEATH hoặc IN_ROOT theo contract.
  • Khai báo NO_MAGICLINKS rõ ràng.
  • Dùng NO_SYMLINKS/NO_XDEV khi threat model yêu cầu.
  • Zero-initialize open_how và truyền đúng size.
  • Sau open, xác minh type/metadata bằng fstat().
  • Xử lý bằng fd, không mở lại pathname.
  • Retry EAGAIN có giới hạn và không bỏ security flags.
  • Định nghĩa fail-closed/fallback cho ENOSYS, EINVAL, E2BIG.
  • Fuzz path và race-test symlink/rename/mount.

Kết luận

Bảo vệ path không tin cậy không phải bài toán xóa ../ khỏi chuỗi. Kernel mới resolve pathname qua nhiều component có thể thay đổi đồng thời, symlink, magic link và mount. openat2() cho phép đặt ranh giới ngay trong lookup atomically rồi trả về file descriptor ổn định. Khi neo bằng rootfd tin cậy, chọn đúng resolve flags, xác minh object sau open và fail closed trên kernel cũ, ứng dụng có thể loại bỏ một lớp path traversal/TOCTOU mà cách “realpath rồi open” không thể bảo đảm.

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.