An Analysis of CVE-2025-32463 : Sudo Chroot Bug

pwn8pwn8
12 min read

ปลายเดือนที่แล้ว sudo ได้ทำการ patch ช่องโหว่ที่สามารถทำ Privilege Escalation ได้ได้บนค่าเริ่มต้นของระบบเลย น่าสนใจตรงที่ไม่ต้องมีพื้นฐาน memory corruption ขั้นสูงก็เข้าใจและลองทำ POC ได้ เลยคิดว่าอยากเอามาลองทำ Vulnerability Analysis ดูครับ

ผมชอบ sudo ตรงที่เค้ามีทำ advisory ช่องโหว่เค้าตลอดให้เราตามอ่านได้ง่าย ช่องโหว่นี้เองก็เช่นกัน ถ้าใครอยากอ่านสามารถเข้าไปอ่านได้ที่นี่ Security Advisory

ถ้าเข้าไปอ่านเราจะพบกับข้อมูลว่าช่องโหว่นี้ impact กับ sudo version ไหนและมีรายละเอียดคร่าว ๆ เป็นยังไง

จะเห็นว่าจริง ๆ ช่องโหว่ไม่มีอะไรเลย ถ้าอ่านพารากราฟด้านบนเราก็พอจะเดาได้แล้วว่ามันเกิดอะไรขึ้น จะเห็นว่ามัน impact กับ -R ที่เป็นการ set chroot ก่อนจะใช้ command ผ่าน sudo (ทำไปทำไมวะ 55555) จาก detail เราพอจะ scope ได้ว่าช่องโหว่มันเกี่ยวกับ chroot แน่ ๆ งั้นเราไปลอง diff code ดูดีกว่า

เราลอง diff code ดูใน change ระหว่าง v1.9.17 และ v1.9.17p1 จะพบว่ามีการลบ code ส่วนที่เรียก pivot_root และ unpivot_root ออกหมดเลย

# git diff v1.9.17 v1.9.17p1 -- '*.c'
diff --git a/plugins/sudoers/sudoers.c b/plugins/sudoers/sudoers.c
index 70a0c1a52..1a8031740 100644
--- a/plugins/sudoers/sudoers.c
+++ b/plugins/sudoers/sudoers.c
... Snipped ...
 int
 set_cmnd_path(struct sudoers_context *ctx, const char *runchroot)
 {
... Snipped ...
-    /* Pivot root. */
-    if (runchroot != NULL) {
-    if (!pivot_root(runchroot, &pivot_state))
-        goto error;
-    }
-
-    ret = resolve_cmnd(ctx, cmnd_in, &cmnd_out, path);
+    ret = resolve_cmnd(ctx, cmnd_in, &cmnd_out, path, runchroot);
... Snipped ...
-    /* Restore root. */
-    if (runchroot != NULL)
-    (void)unpivot_root(&pivot_state);
-
... Snipped ...
 }

และลบไฟล์ plugins/sudoers/pivot.c ออกด้วย

diff --git a/plugins/sudoers/pivot.c b/plugins/sudoers/pivot.c
deleted file mode 100644
index 59423f917..000000000
--- a/plugins/sudoers/pivot.c
+++ /dev/null

ลองไปดู content ของไฟล์ดีกว่า

File: plugins/sudoers/pivot.c


/*
 * Pivot to a new root directory, storing the old root and old cwd
 * in state.  Changes current working directory to the new root.
 * Returns true on success, else false.
 */
bool
pivot_root(const char *new_root, struct sudoers_pivot *state)
{
    debug_decl(pivot_root, SUDOERS_DEBUG_UTIL);

    state->saved_root = open("/", O_RDONLY);
    state->saved_cwd = open(".", O_RDONLY);
    if (state->saved_root == -1 || state->saved_cwd == -1 || chroot(new_root) == -1) {
    if (state->saved_root != -1) {
        close(state->saved_root);
        state->saved_root = -1;
    }
    if (state->saved_cwd != -1) {
        close(state->saved_cwd);
        state->saved_cwd = -1;
    }
    debug_return_bool(false);
    }
    debug_return_bool(chdir("/") == 0);
}

/*
 * Pivot back to the stored root directory and restore the old cwd.
 * Returns true on success, else false.
 */
bool
unpivot_root(struct sudoers_pivot *state)
{
    bool ret = true;
    debug_decl(unpivot_root, SUDOERS_DEBUG_UTIL);

    /* Order is important: restore old root, *then* change cwd. */
    if (state->saved_root != -1) {
    if (fchdir(state->saved_root) == -1 || chroot(".") == -1) {
        sudo_warn("%s", U_("unable to restore root directory"));
        ret = false;
    }
    close(state->saved_root);
    state->saved_root = -1;
    }
    if (state->saved_cwd != -1) {
    if (fchdir(state->saved_cwd) == -1) {
        sudo_warn("%s", U_("unable to restore current working directory"));
        ret = false;
    }
    close(state->saved_cwd);
    state->saved_cwd = -1;
    }

    debug_return_bool(ret);
}

จะเห็นว่า function ใน pivot.c ใช้ในการ manage เรื่อง chroot จริงด้วยแปลว่า assumption ของเราน่าจะมาถูกทาง

จากนั้นเมื่อ chroot เสร็จ sudo น่าจะมีการเรียกใช้งานฟังก์ชั่นอะไรสักอย่างทำให้ไปอ่าน /etc/nsswitch.conf แต่เมื่อเรา chroot ไปแล้วทำให้ path ที่ไปอ่านทำให้อยู่ใน path ที่อยู่ภายใน chroot

แต่ยังมีบางอย่างที่ยังขาด detail บางอย่างที่ผมอยากรู้อยู่ที่วันนี้เราจะมาหาคำตอบไปด้วยกันครับ :)

  • sudo อ่าน /etc/nsswitch.conf ตอนไหน?

  • แล้วทำไมมันถึงอ่าน /etc/nsswitch.conf ใน container ด้วย(วะครับ)?

Environment Setup

ก่อนไปหาคำตอบเราต้องมาทำสิ่งที่น่ารำคาญที่สุดในการ analyze คือ setup environment ขอบคุณโลกใบนี้ที่มี LLM ให้น้อง generate Dockerfile ที่มี sudo version 1.9.17 เลย

# Use Ubuntu 24.04 as the base image
FROM ubuntu:24.04

# Avoid interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive

# Prepare an environment
RUN apt-get update && apt-get install -y \
    build-essential \
    wget \
    tar \
    gdb \
    sudo \
    libpam0g-dev \
    libaudit-dev \
    check \
    && rm -rf /var/lib/apt/lists/*

# Set the working directory for our project
WORKDIR /usr/src/sudo-dev

# Download the specified version of the sudo source code
RUN wget https://www.sudo.ws/dist/sudo-1.9.17.tar.gz

# Extract the downloaded tarball
RUN tar -xzvf sudo-1.9.17.tar.gz

# Change the working directory to the extracted source code folder
WORKDIR /usr/src/sudo-dev/sudo-1.9.17

# Configure the build. The --enable-debug flag is crucial as it
# compiles sudo with debugging symbols, which are necessary for gdb.
RUN ./configure --enable-debug

# Compile the sudo source code
RUN make

# Install the compiled sudo. This will place it in /usr/local/bin,
# effectively replacing the system's default sudo for command-line use.
RUN make install

# Create a non-root user for testing
RUN useradd -m -s /bin/bash noob

# Give the new user passwordless sudo access
RUN echo 'noob ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

# Switch to the new user
USER noob
WORKDIR /home/noob
RUN mkdir temp

# entrypoint
CMD ["/bin/bash"]

ถ้า build เสร็จแล้ว run เบยจร้า อย่าลืมไปเพิ่ม SYS_PTRACE ให้ docker ด้วยล่ะ เดี้ยวใช้ gdb บ่ได้

แอบเพิ่ม sudoers ไว้ใช้ debug

sudo อ่าน /etc/nsswitch.conf ตอนไหน?

จากข้อมูลที่เรามีตอนนี้ เราสามารถ scope code ที่ควรอ่าน ซึ่งจะอยู่ระหว่างการ chroot หรือคือ pivot_root และ unpivot_root

File: plugins/sudoers/sudoers.c

set_cmnd_path(struct sudoers_context *ctx, const char *runchroot)
{
    // ... Snipped ...
    /* Pivot root. */
    if (runchroot != NULL) {
    if (!pivot_root(runchroot, &pivot_state))
        goto error;
    }

    ret = resolve_cmnd(ctx, cmnd_in, &cmnd_out, path);
    if (ret == FOUND) {
    char *slash = strrchr(cmnd_out, '/');
    if (slash != NULL) {
        *slash = '\0';
        ctx->user.cmnd_dir = canon_path(cmnd_out);
        if (ctx->user.cmnd_dir == NULL && errno == ENOMEM)
        goto error;
        *slash = '/';
    }
    }

    if (ISSET(ctx->mode, MODE_CHECK))
    ctx->user.cmnd_list = cmnd_out;
    else
    ctx->user.cmnd = cmnd_out;

    /* Restore root. */
    if (runchroot != NULL)
    (void)unpivot_root(&pivot_state);

    // ... Snipped ...
}

คำถามคึอเราควรไล่ DFS ไปทีละ function ว่ามันโหลด /etc/nsswitch.conf ที่ไหนไหม? จริง ๆ ก็ได้แต่คนขี้เกียจแบบเราเลือก way อื่นที่ง่ายกว่า

ขอแนะนำให้ทุกคนรู้จัก tool เทพของเราในวันนี้ strace ซึ่งเอาไว้ใช้ trace ว่ามี syscall ไหนโดนเรียกบ้างขณะ execute command โดยเราจะ filter เอาเฉพาะ chroot และ files operation (ตอน analyze ตอนแรกผมไม่ได้ใส่ filter แต่เขียนบทความเลยมา filter ออกจะได้ scope ง่าย ๆ)

noob@e3ff6ee0ed3a:~$ sudo strace -e trace=file,chroot sudo -R temp temp
execve("/usr/local/bin/sudo", ["sudo", "-R", "temp", "temp"], 0x7ffc0031d258 /* 16 vars */) = 0
... Snipped ...
chroot("temp")                          = 0
chdir("/")                              = 0
openat(AT_FDCWD, "/proc/sys/kernel/ngroups_max", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/etc/nsswitch.conf", 0x7ffebd061e90, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/", {st_mode=S_IFDIR|0755, st_size=4096, ...}, 0) = 0
openat(AT_FDCWD, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/etc/nsswitch.conf", 0x7ffebd061e90, 0) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/group", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/usr/local/sbin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/usr/local/bin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/usr/sbin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/usr/bin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/sbin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/bin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/snap/bin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/usr/local/sbin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/usr/local/bin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/usr/sbin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/usr/bin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/sbin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/bin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/snap/bin/temp", 0x56b5b6f4f5b0, 0) = -1 ENOENT (No such file or directory)
chroot(".")                             = 0
... Snipped ...
chroot("temp")                          = 0
chdir("/")                              = 0
chroot(".")                             = 0
... Snipped ...

จาก strace ด้านบนจะเห็นว่ามี syscall openat(AT_FDCWD, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) หลัง chroot ด้วยเพิ่มเติมคือมันหา file ไม่เจอเพราะยังไม่ได้สร้างใน ./temp/etc/ strace ทำให้เรารู้ว่ามันเปิดไฟล์นี้จริงไหม แต่จะไม่เห็นว่ามันเปิดอ่านจากฟังก์ชันไหนอยู่ดี

แล้วถ้าอยากรู้ว่าตอนที่มันเปิดอ่านไฟล์ที่ไหนนี่เราต้องทำยังไง? ง่ายมากครับรอบนี้กลับมาที่ gdb ที่เรารักเบย

sudo gdb --args sudo -R temp temp

โดย gdb เองเราสามารถทำการ breakpoint ที่ syscall ได้โดยใช้คำสั่ง catch syscall [Syscall number] หรือของ openat ผมจำได้ syscall number คือ 257

💡
Trick สำหรับการ debug : รันโปรแกรมให้ทำงานรอบหนึ่งก่อน เพื่อให้โหลด symbol เรียบร้อย เวลาเซ็ต breakpoint จะได้ไม่สับสน จากนั้นตั้ง breakpoint ที่ pivot_root ก่อน แล้วค่อยใช้ catch syscall จะช่วยลดจำนวนครั้งที่ต้อง continue ลงเยอะ

หรือถ้าต้องการ ก็สามารถตั้งเงื่อนไข (conditional breakpoint) ได้ แต่ในเคสนี้ผมมองว่าจำนวน event ไม่เยอะ และเราทำแค่ครั้งเดียว ก็ใช้วิธี c ต่อได้เลย

(gdb) r
Starting program: /usr/local/bin/sudo -R temp temp
... Snipped ...
sudo: you are not permitted to use the -R option with temp
[Inferior 1 (process 264) exited with code 01]
# setup breakpoint
(gdb) b pivot_root

(gdb) r
Starting program: /usr/local/bin/sudo -R temp temp
... Snipped ...
Breakpoint 1.1, pivot_root (new_root=0x5a980000f50c "temp", state=0x7ffe9a3ace18) at ./pivot.c:39
39      {
# setup breakpoint for openat
(gdb) catch syscall 257
Catchpoint 2 (syscall 'openat' [257])
(gdb) c
Continuing.

Catchpoint 2 (call to syscall openat), 0x000072c80be8f175 in __libc_open64 (file=file@entry=0x72c80bcd8858 "/", oflag=oflag@entry=0) at ../sysdeps/unix/sysv/linux/open64.c:41
warning: 41     ../sysdeps/unix/sysv/linux/open64.c: No such file or directory
(gdb) c
Continuing.
... Snipped ...
# c until we found /etc/nsswitch.conf
Catchpoint 2 (call to syscall openat), __GI___open64_nocancel (file=0x72c80bf42d92 "/etc/nsswitch.conf", oflag=524288) at ../sysdeps/unix/sysv/linux/open64_nocancel.c:39
39      in ../sysdeps/unix/sysv/linux/open64_nocancel.c

เมื่อเราเจอ syscall เพื่อเปิด /etc/nsswitch.conf แล้วให้เรา backtrace เพื่อดู function ที่ call มานั้นเอง

(gdb) bt
#0  __GI___open64_nocancel (file=0x72c80bf42d92 "/etc/nsswitch.conf", oflag=524288) at ../sysdeps/unix/sysv/linux/open64_nocancel.c:39
#1  0x000072c80be05bb5 in __GI__IO_file_open (fp=fp@entry=0x5a9800016e80, filename=<optimized out>, posix_mode=<optimized out>, prot=prot@entry=438, read_write=8, is32not64=<optimized out>)
    at ./libio/fileops.c:185
#2  0x000072c80be05e52 in _IO_new_file_fopen (fp=fp@entry=0x5a9800016e80, filename=filename@entry=0x72c80bf42d92 "/etc/nsswitch.conf", mode=<optimized out>, mode@entry=0x72c80bf3edb4 "rce",
    is32not64=is32not64@entry=1) at ./libio/fileops.c:281
#3  0x000072c80bdf9ee2 in __fopen_internal (is32=1, mode=0x72c80bf3edb4 "rce", filename=0x72c80bf42d92 "/etc/nsswitch.conf") at ./libio/iofopen.c:75
#4  _IO_new_fopen (filename=filename@entry=0x72c80bf42d92 "/etc/nsswitch.conf", mode=mode@entry=0x72c80bf3edb4 "rce") at ./libio/iofopen.c:86
#5  0x000072c80bec647d in nss_database_reload (initial=0x7ffe9a3ac500, staging=0x7ffe9a3ac5c0) at ./nss/nss_database.c:306
#6  nss_database_check_reload_and_get (local=<optimized out>, result=0x7ffe9a3ac6f0, database_index=nss_database_initgroups) at ./nss/nss_database.c:457
#7  0x000072c80becaddc in internal_getgrouplist (user=user@entry=0x5a9800010c08 "root", group=group@entry=0, size=size@entry=0x7ffe9a3ac748, groupsp=groupsp@entry=0x7ffe9a3ac750, limit=limit@entry=-1)
    at ./nss/initgroups.c:75
#8  0x000072c80becb0dc in getgrouplist (user=user@entry=0x5a9800010c08 "root", group=group@entry=0, groups=groups@entry=0x72c80bbdf010, ngroups=ngroups@entry=0x7ffe9a3ac7b4) at ./nss/initgroups.c:156
#9  0x000072c80bf95079 in sudo_getgrouplist2_v1 (name=0x5a9800010c08 "root", basegid=0, groupsp=groupsp@entry=0x7ffe9a3ac810, ngroupsp=ngroupsp@entry=0x7ffe9a3ac81c) at ./getgrouplist.c:105
#10 0x000072c80bcb761e in sudo_make_gidlist_item (pw=0x5a9800010bd8, ngids=<optimized out>, gids=<optimized out>, gidstrs=0x0, type=1) at ./pwutil_impl.c:298
#11 0x000072c80bcb6175 in sudo_get_gidlist (pw=0x5a9800010bd8, type=type@entry=1) at ./pwutil.c:1033
#12 0x000072c80bcad96b in runas_getgroups (ctx=ctx@entry=0x72c80bd056c0 <sudoers_ctx>) at ./match.c:146
#13 0x000072c80bc99a5c in runas_setgroups (ctx=0x72c80bd056c0 <sudoers_ctx>) at ./set_perms.c:1634
#14 set_perms (ctx=ctx@entry=0x72c80bd056c0 <sudoers_ctx>, perm=perm@entry=5) at ./set_perms.c:285
#15 0x000072c80bcb8b58 in resolve_cmnd (ctx=ctx@entry=0x72c80bd056c0 <sudoers_ctx>, infile=infile@entry=0x7ffe9a3af753 "temp", outfile=outfile@entry=0x7ffe9a3ace20,
    path=path@entry=0x5a9800018070 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin") at ./resolve_cmnd.c:42
#16 0x000072c80bc9c9dc in set_cmnd_path (ctx=ctx@entry=0x72c80bd056c0 <sudoers_ctx>, runchroot=0x5a980000f50c "temp") at ./sudoers.c:1108
#17 0x000072c80bc9ce67 in set_cmnd (ctx=0x72c80bd056c0 <sudoers_ctx>) at ./sudoers.c:1177
#18 sudoers_check_common (pwflag=pwflag@entry=0, ctx=0x72c80bd056c0 <sudoers_ctx>) at ./sudoers.c:358
#19 0x000072c80bc9e4e8 in sudoers_check_cmnd (argc=argc@entry=1, argv=argv@entry=0x7ffe9a3ae4a0, env_add=env_add@entry=0x0, closure=closure@entry=0x7ffe9a3acfb0) at ./sudoers.c:689
#20 0x000072c80bc94493 in sudoers_policy_check (argc=1, argv=0x7ffe9a3ae4a0, env_add=0x0, command_infop=0x7ffe9a3ad070, argv_out=0x7ffe9a3ad078, user_env_out=0x7ffe9a3ad080, errstr=0x7ffe9a3ad098)
    at ./policy.c:1244
#21 0x00005a97d213bf93 in policy_check (run_envp=0x7ffe9a3ad080, run_argv=0x7ffe9a3ad078, command_info=0x7ffe9a3ad070, env_add=0x0, argv=0x7ffe9a3ae4a0, argc=1) at ./sudo.c:1266
#22 main (argc=<optimized out>, argv=<optimized out>, envp=<optimized out>) at ./sudo.c:261

แค่นี้เราก็ได้คำตอบแล้วว่าในฝั่งของ sudo นั้นได้ทำการเรียก getgrouplist() ในฟังก์ชั่น sudo_getgrouplist2_v1() ที่ path lib/util/getgrouplist.c นั่นเอง

จากนี้ใครอยากเข้าใจว่า sudo ทำอะไร ทำไมมันถึงมาเรียก getgrouplist() สามารถไปอ่านเพิ่มเติมใน path ที่ผมบอกเบย(แล้วกลับมาเล่าให้ฟังด้วย)

แล้วทำไมมันถึงอ่าน /etc/nsswitch.conf ใน container ด้วย(วะครับ)?

ตรงนี้แหละคือจุดที่ผมติดอยู่พักใหญ่ พยายามหาคำตอบอยู่นานว่ามันเกิดจากอะไร คำตอบ… เดี๋ยวเจอกันใน Bonus Part ครับ 😉

ก่อนอื่นมาทำความรู้จักกับ The Name Service Switch (NSS) กันก่อนจากนี้ผมจะเรียกชื่อว่า NSS นะครับ

NSS เป็นวิธีที่ Glibc และหลาย ๆ โปรแกรมบน Linux ใช้ในการเลือก Source ของข้อมูลต่าง ๆ ใน system เช่น group, hosts, passwd และอื่น ๆ โดยการ configure ต่าง ๆ จะอยู่ที่ไฟล์ /etc/nsswitch.conf นั่นเอง อยากรู้เพิ่มเติมไปอ่าน Manual กันเองได้เลยค้อฟ

จาก backtrace ด้านบนผมจะ scope ลงมาส่วนที่สนใจว่าทำไมมันถึง load ไฟล์ configure จะเห็นว่า nss_database_check_reload_and_get() ถูกเรียกที่ ./nss/nss_database.c:457 นี่ควรจะเป็นจุดแรก ๆ ที่เราไปดู

#4  _IO_new_fopen (filename=filename@entry=0x72c80bf42d92 "/etc/nsswitch.conf", mode=mode@entry=0x72c80bf3edb4 "rce") at ./libio/iofopen.c:86
#5  0x000072c80bec647d in nss_database_reload (initial=0x7ffe9a3ac500, staging=0x7ffe9a3ac5c0) at ./nss/nss_database.c:306
#6  nss_database_check_reload_and_get (local=<optimized out>, result=0x7ffe9a3ac6f0, database_index=nss_database_initgroups) at ./nss/nss_database.c:457
#7  0x000072c80becaddc in internal_getgrouplist (user=user@entry=0x5a9800010c08 "root", group=group@entry=0, size=size@entry=0x7ffe9a3ac748, groupsp=groupsp@entry=0x7ffe9a3ac750, limit=limit@entry=-1)
    at ./nss/initgroups.c:75

ขั้นแรก เราต้องตรวจสอบให้ชัดก่อนว่าเครื่องกำลังใช้ glibc เวอร์ชันไหน

bashCopyEdit
$ ldd --version
ldd (Ubuntu GLIBC 2.39-0ubuntu8.5) 2.39
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.

ซึ่งถ้าใครใช้ version อื่นก็ไปอ่านของ version ที่ตัวเองใช้ได้เลย ส่วนถ้าใครใช้ version เดียวกันกับผม สามารถอ่าน code เต็ม ๆ ได้ที่ https://elixir.bootlin.com/glibc/glibc-2.39/source/nss/nss_database.c#L457

จาก Backtrace เราพบว่ามีการเรียกฟังก์ชัน nss_database_reload() นั่นหมายความว่าเราจะโฟกัสเฉพาะโค้ดก่อนถึงบรรทัดนี้ก็เพียงพอ เนื่องจากโค้ดค่อนข้างยาว ผมจะย่อยเป็นส่วน ๆ เพื่ออธิบายให้เข้าใจง่ายขึ้น

static bool
nss_database_check_reload_and_get (struct nss_database_state *local,
                                   nss_action_list *result,
                                   enum nss_database database_index)
{
  // ... Focus on code before reload ... 
  bool ok = nss_database_reload (&staging, &initial);
  // Skip

ก่อนจะลงไปดูโค้ด แนะนำให้ใช้ gdb ตั้ง breakpoint ที่ฟังก์ชัน nss_database_check_reload_and_get() วิธีนี้จะช่วยให้เราเห็นค่าของตัวแปรต่าง ๆ ขณะรันจริง ทำให้เข้าใจ flow ได้ง่ายขึ้น และไม่ต้องมานั่งเดาเคสเอง

(gdb) b nss_database_check_reload_and_get

(gdb) c
Continuing.
Breakpoint 2, nss_database_check_reload_and_get (local=0x5723d2dd69b0, result=0x7ffc544b5f30, database_index=nss_database_initgroups) at ./nss/nss_database.c:396

(gdb) p *local
$3 = {data = {nsswitch_conf = {size = 494, ino = 1483121, mtime = {tv_sec = 1659454483, tv_nsec = 0}, ctime = {tv_sec = 1753689235, tv_nsec = 700200329}}, services = {0x5723d2dd63b0, 0x5723d2dd7130,
      0x5723d2dd63b0, 0x5723d2dd7180, 0x5723d2dd63b0, 0x5723d2dd6360, 0x0, 0x5723d2dd7180, 0x5723d2dd63b0, 0x5723d2dd63b0, 0x5723d2dd7180, 0x5723d2dd7130, 0x5723d2dd71c0, 0x5723d2dd7130,
      0x5723d2dd7130, 0x5723d2dd63b0, 0x5723d2dd7180}, reload_disabled = 0, initialized = true}, lock = 0, root_ino = 4098686, root_dev = 53}

เมื่อเข้าไปในฟังก์ชัน ขั้นแรกจะตรวจสอบค่าของตัวแปร data.reload_disabled

  • ถ้าเป็น 1 หมายความว่า ไม่ต้อง reload และจะดึงค่าจาก cache (local->data.services[database_index]) มาใช้ทันที

  • ในกรณีของเรา ค่านี้เป็น 0 จึงข้ามเงื่อนไขนี้ไปและทำงานต่อ

  struct __stat64_t64 str;

  /* Acquire MO is needed because the thread that sets reload_disabled
     may have loaded the configuration first, so synchronize with the
     Release MO store there.  */
  if (atomic_load_acquire (&local->data.reload_disabled))
    {
      *result = local->data.services[database_index];
      /* No reload, so there is no error.  */
      return true;
    }

จาก [1] โค้ดจะตรวจสอบว่าไฟล์ /etc/nsswitch.conf มีการเปลี่ยนแปลงหรือไม่

  • ถ้ามีการเปลี่ยนแปลง → โหลดค่าใหม่

  • ถ้าไม่มีการเปลี่ยนแปลง → ใช้ค่าจาก cache ([2]) ในเคสนี้ ไฟล์เป็นคนละไฟล์กันอยู่แล้ว ควรจะมีการเปลี่ยนแปลงแน่นอน ดังนั้นเราจะข้ามเงื่อนไข if นี้ไปเช่นกัน


  struct file_change_detection initial;
  if (!__file_change_detection_for_path (&initial, _PATH_NSSWITCH_CONF)) // [1] 
    return false;

  __libc_lock_lock (local->lock);
  if (__file_is_unchanged (&initial, &local->data.nsswitch_conf)) // [2]
    {
      /* Configuration is up-to-date.  Read it and return it to the
         caller.  */
      *result = local->data.services[database_index];
      __libc_lock_unlock (local->lock);
      return true;
    }

ทำการตรวจสอบว่า local->data.services[database_index] มีค่าเป็น NULL หรือเปล่า[3] (เอาไว้ตรวจสอบว่า service ที่ถูกเรียกมาผ่าน NSS นั้นมีใน cache หรือเปล่า)

ถ้ามีใน cache ก็จะทำการ check ว่า path ปัจจุบันนั้นถูก chroot มาป่าว [4] ถ้ามันถูก chroot มาแล้วก set flag local->data.reload_disabled เป็น 1 แล้ว set ค่าใน cache ไปผ่าน result

หากผ่านทั้งหมดแล้วจะไปเรียก nss_database_reload() ต่อไป

  int stat_rv = __stat64_time64 ("/", &str);

  if (local->data.services[database_index] != NULL) // [3] if cache is NULL => Skip
    {
      /* Before we reload, verify that "/" hasn't changed.  We assume that
        errors here are very unlikely, but the chance that we're entering
        a container is also very unlikely, so we err on the side of both
        very unlikely things not happening at the same time.  */
      if (stat_rv != 0
      || (local->root_ino != 0
          && (str.st_ino != local->root_ino
          ||  str.st_dev != local->root_dev))) // [4] chroot check
    {
        /* Change detected; disable reloading and return current state.  */
        atomic_store_release (&local->data.reload_disabled, 1);
        *result = local->data.services[database_index];
        __libc_lock_unlock (local->lock);
        return true; 
      }
    }
  if (stat_rv == 0)
    {
      local->root_ino = str.st_ino;
      local->root_dev = str.st_dev;
    }

  __libc_lock_unlock (local->lock);

  /* Avoid overwriting the global configuration until we have loaded
     everything successfully.  Otherwise, if the file change
     information changes back to what is in the global configuration,
     the lookups would use the partially-written  configuration.  */
  struct nss_database_data staging = { .initialized = true, };

  bool ok = nss_database_reload (&staging, &initial);

ถ้าอ่านผ่าน ๆ แล้วก็ดูปกติดี มันก็มีการ check ว่าอยู่ใน container แล้วหนิ ทำไมมันยัง call อีก(วะ)

คำตอบมันอยู่ในตัวแปร local->data.services ซึ่งถ้าเราย้อนกลับไปดูตอนที่มัน define nss_database จะเห็นว่า local->data.services[nss_database_initgroups] ของเรามีค่าเป็น NULL!!! ทำให้มันทำการ skip if ที่ [3] ไป ทำให้ไม่ได้มีการ check chroot ใน if ที่ [4] และไปเรียก nss_database_reload() ที่ไปอ่าน /etc/nsswitch.conf แม้จะ chroot แล้วนั่นเอง

services = {
    0x5723d2dd63b0, // DEFINE_DATABASE (aliases)
    0x5723d2dd7130, // DEFINE_DATABASE (ethers)
    0x5723d2dd63b0, // DEFINE_DATABASE (group)
    0x5723d2dd7180, // DEFINE_DATABASE (group_compat)
    0x5723d2dd63b0, // DEFINE_DATABASE (gshadow)
    0x5723d2dd6360, // DEFINE_DATABASE (hosts)
    0x0,            // DEFINE_DATABASE (initgroups) [5] local->data.services[database_index]
    // ... Snipped ...

แค่นี้เราก็ได้คำตอบแล้วว่าทำไมมันถึงอ่าน configure ภายหลังจากการทำ chroot แล้ว!

Exploit Development

เราจะไม่เล่าในวันนี้ครับเพราะ exploit เต็มเน็ตไปหมด ในส่วนของ advisory ของ Stratascale ก็มี poc ทิ้งไว้ให้เหมือนกัน (จริง ๆ แล้วขี้เกียจหน่ะ lol)

แต่พอลองหวดดูแล้ว ผมว่ามีหลายจุดที่น่าจะทำให้งงเวลาตามดีบัก เผื่อใครจะไปลองนั่งเขียน exploit ผมคิดว่า Information พวกนี้น่าจะช่วยได้

  1. Share Object File (.so) ของเราไม่ได้ถูก load ผ่าน __libc_dlopen_mode() ทันทีหลังจากที่ reload nss แต่จะถูกอ่านภายหลังทำให้ path ที่ต้อง resolve libnss จะอยู่นอก chroot environment

  2. การ load nss module จะเอา module name มาต่อกับ libnss_%s.so%s (ดู code เต็ม ๆ ตรงนี้)

  3. การ load Share Object File(.so) ถ้าไม่มี / จะถูก lookup ผ่าน LD_LIBRARY_PATH แต่ถ้ามีจะมองว่าเป็น absolute หรือ relative path และจะ lookup ที่ current cwd ด้วย RTFM

3 ข้อนี้น่าจะตรงกับคำถามที่หลายคนสงสัย และช่วยให้เข้าใจว่าทำไม exploit ถึงเขียนแบบนี้ครับ

Bonus : Born But with Me

อยาก note สิ่งที่ติดโง่กับตอน analyze และ debug ช่องโหว่นี้ไว้หน่อย จริง ๆ สิ่งนึงที่ไม่ได้มีคนเขียนถึงเท่าไรคือช่องโหว่จะได้บน Environment ที่ใช้ Glibc ตั้งแต่ version 2.36 เท่านั้น…

ช่องโหว่นี้ผมเสียเวลาไปเพราะตอนแรกผมลอง setup debug environment เป็น ubuntu 22.04 ซึ่งใช้ Glibc version 2.35 แต่ไปลอง ubuntu 24.04 (2.39) แม่ง exploit ได้ปกติเลย ผมเลยนั่งไล่ code ว่า 2.35 กับ 2.39 มันต่างกันตรงไหน โดยวิธีการ diff ผมทำแบบนี้ครับ

Diff Tools ❌

เปิดสอง browser แล้วไถหาดูว่ามันต่างกันตรงไหน ✅✅✅✅

แล้วประเด็นคือตอนแรกผมมั่นใจมากว่า 2 version นี้ code เหมือนกัน 555555555 (แต่จริง ๆ แล้วมันไม่เหมือนโว้ยยยย)

สิ่งนี้เรียกสังขารและอีโก้ใช่หรือไม่

สุดท้ายผมลอง backtrace เทียบกันสองเวอร์ชันถึงพบกว่า Glibc 2.35 มันติดที่ check chroot เลยมาลองนั่ง diff code ดูแบบคนปกติถึงพบว่ามันไม่มี if (local->data.services[database_index] != NULL) ทำให้ reload ใน chroot environment ไม่ได้

ตัดจบเพราะไม่รู้จะจบยังไง

จาก diff code และ release notes จะเห็นได้ว่าทาง sudo เลือกที่จะยกเลิก feature chroot และลบออกไป เพราะถ้าปล่อย feature นี้ไว้ ต้องคอยระวังการ interact กับไฟล์ระหว่าง chroot ซึ่งถ้าเกี่ยวข้องกับ library ยิ่งซับซ้อนและจัดการยากมาก จริง ๆ ช่องโหว่นี้มีลักษณะคล้ายกับช่องโหว่ของ Docker (CVE-2019-14271) ที่ตอนนั้นแก้ด้วยการ Initialize NSS ทั้งหมดตั้งแต่แรก เพื่อไม่ต้องไป resolve ภายใน container แนวทางนี้เองเคยมีการพูดคุยในฝั่ง glibc เช่นกัน แต่ maintainer ไม่ได้เลือกใช้ เพราะอาจเกิด impact แก้ไขได้ยากกว่า

ส่วนตัวผมเห็นด้วยกับการเอา featureที่ไม่ได้เป็นที่นิยมและสร้างความยุ่งยากในการ maintain ออก เพราะถ้าวันนี้ไม่ได้แตกที่ Glibc วันหน้าอาจจะแตกที่ feature อื่นก็ได้

อ่านจนจบแล้วเพื่อน ๆ มีไอเดียในการแก้ไข Issue นี้ยังไงบ้างค้อฟ มาแชร์กันหน่อย

ปล. ไม่มีใครจำ syscall number ของ openat ได้หรอก โม้ไปงั้น อยากรู้ว่า syscall number ของที่ละ arch คืออะไรดูได้ที่นี่เลยจร้า

1
Subscribe to my newsletter

Read articles from pwn8 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

pwn8
pwn8