[Research] CVE-2025-32463 Into the 'sudo -R'(KR)
Intro
안녕하세요 poosic 입니다. 첫 연구글과 함께 이렇게 Hackyboiz에 복귀하였습니다!
이번 연구글에서는 CVE-2025-32463: sudo에서 chroot를 이용한 LPE에 대해 정리하다가 생긴 궁금증에 대해 직접 찾아보고 정리해보고자 합니다. 해당 취약점의 개략적인 내용은 하루한줄을 참고해주세요!
# sudoers.c
pivot_root()
-> resolve_cmnd() # load changed root's nsswitch.conf
-> unpivot_root() # not load original root's nsswitch.conf
그래도 간단히 취약점이 생긴 sudoers.c
의 flow를 한번 간단히 살펴보자면, pivot_root
실행이후 resolve_cmnd
함수에서 NSS API가 호출되며 pivot_root
를 통해 루트 권한으로 로드된 공격자의 임의 파일(/etc/nsswitch.conf
)을 읽고 nss_database 관련 정보가 갱신되는데 unpivot_root
이후 원래 root의 nss_database로 갱신되지 않아 취약점이 발생했습니다.
취약점이 생긴 pivot.c
, sudoers.c
를 보다보니 의문점이 들었는데요. 의문점은 다음과 같았습니다.
- 해당 코드에서 nsswitch.conf는 어디에서 load 되는건지
- 왜 nsswitch.conf는 chroot로 바뀐 루트 디렉토리의 파일을 인식하고 있는지
실제로 취약점이 발생한 sudo v1.9.17 pivot.c 코드를 확인해보면 unpivot_chroot()
에서 충실히 nsswitch.conf를 다시 읽어오도록 해놓았는대요. 그래서 문제는 더 미궁으로 빠져들었습니다ㅠㅠ.
이를 알기 위해서는 당연하게도 git에 sudo repo를 참조하거나 gdb로 분석해 봐야겠죠.
Environment setting
처음 환경설정을 할 당시에는 기존 Ubuntu에 디버깅용 sudo와 glibc를 컴파일하여 진행하려고 했습니다. 다만, WSL에 설치한 Ubuntu의 경우 -R 옵션을 사용하지 않던 버전이라 sudo.conf 등 다른 파일에 설정해줘야 할 것들이 너무나도 많아 약간(?) 편한 방법을 찾게 되었습니다.
바로 현명한 분들이 구축해놓은 poc와 docker를 이용하는 것이죠. 이 글을 따라 분석을 해보고 싶으신 분들은 아래 Reference의 [1]를 참조해 설치를 진행하시면 좋을 것 같습니다. 설치 과정은 간단하니 생략하고 환경세팅을 시작해보도록 하겠습니다.
해당 환경은 gdb가 깔려있지 않습니다. gdb를 깔기 위해서는 당연히 sudo 권한이 있어야하지만 run.sh을 실행시키면 pwn이라는 일반 유저로 들어가기 때문에 gdb를 설치할 수 없습니다.
저는 멍청하게도 여기서 다시 직접 환경설정을 하다가 생각해보니 “하핳 난 sudo 권한을 가질수 있지!”하면서 docker에서 세팅해준 poc 스크립트를 이용해 root 권한을 얻어 gdb를 설치하였습니다.
해당 사실이 알려지고 L0ch님에게 경악이라는 감정을 일깨워드렸습니다.
심신미약으로 이게 local에 제가 올린 docker container라는 사실을 까맣게 잊고요…그냥 편하게 docker exec -it --user root <container-id> bash
로 root로 접속해 세팅하실 수 있습니다.
root@a593a22215ca:/# echo 'pwn ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
root@a593a22215ca:/# su - pwn
pwn@a593a22215ca:/tmp/sudowoot.stage.Ex5pZ2$ sudo gdb --args sudo -R woot woot
sudo를 디버깅하기 위해서는 sudo 권한이 필요합니다. root 에서 진행하셔도 좋지만 혹시라도 flow상 다른점이 있을까봐 일반 권한을 가진 pwn에 sudo 권한을 부여해 진행했습니다. 이후에 sudo 권한으로 gdb를 실행하면 디버깅 준비가 완료되었습니다 이제 본격적으로 디버깅을 진행해보죠!
After pivot_root
(gdb) b pivot_root
(gdb) r
Breakpoint 1.1, pivot_root (new_root=0x55aae9e866dc "woot", state=0x7ffd8c3bb4a8) at ./pivot.c:39
39 {
(gdb) b fopen
Breakpoint 2 at 0x7ff5aaf55e60: file ./libio/iofopen.c, line 85.
nsswitch.conf가 load되는 위치를 찾기위해 우선적으로 pivot_root
이후 fopen
되는 지점을 break point를 잡아주었습니다.
Breakpoint 2, _IO_new_fopen (filename=filename@entry=0x7ff5ab09ed92 "/etc/nsswitch.conf", mode=mode@entry=0x7ff5ab09adb4 "rce")
at ./libio/iofopen.c:85
(gdb) bt
#0 _IO_new_fopen (filename=filename@entry=0x7ff5ab09ed92 "/etc/nsswitch.conf", mode=mode@entry=0x7ff5ab09adb4 "rce")
at ./libio/iofopen.c:85
#1 0x00007ff5ab02247d in nss_database_reload (initial=0x7ffd8c3bab90, staging=0x7ffd8c3bac50) at ./nss/nss_database.c:306
#2 nss_database_check_reload_and_get (local=<optimized out>, result=0x7ffd8c3bad80, database_index=nss_database_initgroups)
at ./nss/nss_database.c:457
#3 0x00007ff5ab026ddc in internal_getgrouplist (user=user@entry=0x55aae9e87d98 "root", group=group@entry=0,
size=size@entry=0x7ffd8c3badd8, groupsp=groupsp@entry=0x7ffd8c3bade0, limit=limit@entry=-1) at ./nss/initgroups.c:75
#4 0x00007ff5ab0270dc in getgrouplist (user=user@entry=0x55aae9e87d98 "root", group=group@entry=0,
groups=groups@entry=0x7ff5aa7c9010, ngroups=ngroups@entry=0x7ffd8c3bae44) at ./nss/initgroups.c:156
#5 0x00007ff5ab0f1149 in sudo_getgrouplist2_v1 (name=0x55aae9e87d98 "root", basegid=0, groupsp=groupsp@entry=0x7ffd8c3baea0,
ngroupsp=ngroupsp@entry=0x7ffd8c3baeac) at ./getgrouplist.c:105
#6 0x00007ff5aa967b5e in sudo_make_gidlist_item (pw=0x55aae9e87d68, ngids=<optimized out>, gids=<optimized out>, gidstrs=0x0,
type=1) at ./pwutil_impl.c:306
#7 0x00007ff5aa9666b5 in sudo_get_gidlist (pw=0x55aae9e87d68, type=type@entry=1) at ./pwutil.c:1033
#8 0x00007ff5aa95de8b in runas_getgroups (ctx=ctx@entry=0x7ff5aa9b76c0 <sudoers_ctx>) at ./match.c:146
#9 0x00007ff5aa949f9c in runas_setgroups (ctx=0x7ff5aa9b76c0 <sudoers_ctx>) at ./set_perms.c:1634
#10 set_perms (ctx=ctx@entry=0x7ff5aa9b76c0 <sudoers_ctx>, perm=perm@entry=5) at ./set_perms.c:285
#11 0x00007ff5aa9690a8 in resolve_cmnd (ctx=ctx@entry=0x7ff5aa9b76c0 <sudoers_ctx>, infile=infile@entry=0x7ffd8c3bd7a2 "woot",
outfile=outfile@entry=0x7ffd8c3bb4b0,
path=path@entry=0x55aae9e8dbc0 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") at ./resolve_cmnd.c:42
#12 0x00007ff5aa94cf1c in set_cmnd_path (ctx=ctx@entry=0x7ff5aa9b76c0 <sudoers_ctx>, runchroot=0x55aae9e866dc "woot")
at ./sudoers.c:1108
#13 0x00007ff5aa94d3a7 in set_cmnd (ctx=0x7ff5aa9b76c0 <sudoers_ctx>) at ./sudoers.c:1177
#14 sudoers_check_common (pwflag=pwflag@entry=0, ctx=0x7ff5aa9b76c0 <sudoers_ctx>) at ./sudoers.c:358
#15 0x00007ff5aa94ea28 in sudoers_check_cmnd (argc=argc@entry=1, argv=argv@entry=0x7ffd8c3bcb30, env_add=env_add@entry=0x0,
closure=closure@entry=0x7ffd8c3bb640) at ./sudoers.c:689
#16 0x00007ff5aa9449d3 in sudoers_policy_check (argc=1, argv=0x7ffd8c3bcb30, env_add=0x0, command_infop=0x7ffd8c3bb700,
argv_out=0x7ffd8c3bb708, user_env_out=0x7ffd8c3bb710, errstr=0x7ffd8c3bb728) at ./policy.c:1244
#17 0x000055aae921efb3 in policy_check (run_envp=0x7ffd8c3bb710, run_argv=0x7ffd8c3bb708, command_info=0x7ffd8c3bb700, env_add=0x0,
argv=0x7ffd8c3bcb30, argc=1) at ./sudo.c:1266
#18 main (argc=<optimized out>, argv=<optimized out>, envp=<optimized out>) at ./sudo.c:261
fopen
에서 인자로 /etc/nsswitch.conf
를 로드하는 것을 확인했으니 이제 backtrace 명령어를 통해 콜스택을 확인해보겠습니다. 가장 먼저 nss와 관련된 nss_databse_reload
, nss_database_check_reload_and_get
이 눈에 들어옵니다. 현재 flow에서는 해당 함수는 아래와 같은 역할을 합니다.
- nss_database_check_reload_and_get: nsswitch.conf가 바뀌었다면 reload.
- nss_database_reload:
/etc/nsswitch.conf
파일을 열고 파싱한 뒤, NSS 서비스 정보를staging
에 채워 넣고, 필요한 경우 기본 값을 채워넣음.
(gdb) c
Breakpoint 3, unpivot_root()
(gdb) c
Breakpoint 4, nss_database_check_reload_and_get()
(gdb) c
Continuing.
Downloading separate debug info for libnss_/woot1337.so.2
이후에 unpivot_root()
를 그냥 지나칠줄 알았으나 break point에 잘 잡힙니다. nss_database_check_reload_and_get()
이 로드되지만 pivot 이후와 다르게 nss_database_reload()
는 호출하지 않습니다. 그렇다면 문제는 unpivot_root
시 nss_database_reload
가 이루어지지 않아 chroot에서 인식한 nsswitch.conf를 계속 가지고 있어 발생하게 되겠네요.
이제 코드를 집중적으로 한번 보도록 하겠습니다!
After unpivot_root
static bool
nss_database_check_reload_and_get(struct nss_database_state *local,
nss_action_list *result,
enum nss_database database_index)
{
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;
}
struct file_change_detection initial;
if (!__file_change_detection_for_path(&initial, _PATH_NSSWITCH_CONF))
return false;
__libc_lock_lock(local->lock);
if (__file_is_unchanged(&initial, &local->data.nsswitch_conf))
{
/* 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;
}
int stat_rv = __stat64_time64("/", &str);
if (local->data.services[database_index] != NULL)
{
/* 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)))
{
/* 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);
if (ok)
{
__libc_lock_lock(local->lock);
/* See above for memory order. */
if (!atomic_load_acquire(&local->data.reload_disabled))
/* This may go back in time if another thread beats this
thread with the update, but in this case, a reload happens
on the next NSS call. */
local->data = staging;
*result = local->data.services[database_index];
__libc_lock_unlock(local->lock);
}
return ok;
}
처음 if 구문에서는 reload_disabled
를 통해 reload 수행여부를 확인하고 해당 값이 설정되어 있다면 reload없이 지금 값 그대로 result에 저장합니다.
이후엔 파일이 변경되었는지, 중간에 다른 스레드에서 갱신하고 있는지 확인한 후 nss_databse_reload()
를 호출해 reload를 진행합니다. ok에 return 값을 받고 처음 if 구문과 같이 atomic_load_acquire(&local->data.reload_disabled)
을 호출해 확인합니다.
그렇다면 local->data.reload_disabled
가 1로 설정되거나 file 검사와 관련된 if 구문에서 종료가 일어나서 reload 되지 못한다고 예상할 수 있겠네요.
pivot_root
이후 nss_database는 reload됐으니, 정확한 원인 파악을 위해 gdb로 unpivot_root
이후를 살펴보겠습니다.
Breakpoint 1.1, pivot_root (new_root=0x562c1e75943c "woot", state=0x7fff9f7e6638) at ./pivot.c:39
39 {
(gdb) c
Continuing.
Download failed: Invalid argument. Continuing without source file ./nss/./nss/nss_database.c.
Breakpoint 2, nss_database_check_reload_and_get (local=0x562c1e753990, result=0x7fff9f7e5f10, database_index=nss_database_initgroups) at ./nss/nss_database.c:396
warning: 396 ./nss/nss_database.c: No such file or directory
(gdb) c
Continuing.
...
Breakpoint 1, unpivot_root (state=state@entry=0x7fff7b4bbd38) at ./pivot.c:64
64 {
Breakpoint 2, nss_database_check_reload_and_get (local=0x5631f772d990, result=0x7fb3a825fb30 <__nss_passwd_database>, database_index=nss_database_passwd) at ./nss/nss_database.c:396
warning: 396 ./nss/nss_database.c: No such file or directory
(gdb) n
...
(gdb) info args
local = <optimized out>
result = 0x7fb3a825fb30 <__nss_passwd_database>
database_index = nss_database_passwd
(gdb) n
if (local->data.services[database_index] != NULL)
425 in ./nss/nss_database.c
(gdb) n
if (stat_rv != 0 || (local->root_ino != 0 && (str.st_ino != local->root_ino || str.st_dev != local->root_dev)))
431 in ./nss/nss_database.c
pivot_root
직후에는 database_index = nss_database_initgroup 이었지만 unpivot_root
이후에는 nss_database_passwd가 전달되기 때문에 local->data.reload_disabled
가 1로 설정되는 구문으로 들어가며 reload를 하지 않게됩니다.
(gdb) p *local->data.services[6]->module #nss_databse_initgroups
Cannot access memory at address 0x0
(gdb) p *local->data.services[9] #nss_databse_passwd
$25 = {module = 0x5590f5e38a70, action_bits = 320}
(gdb) set $m = (struct nss_module *) local->data.services[9]->module
(gdb) x/s $m->name
0x558e8c22bfb8: "/woot1337"
gdb를 통해 확인해보면pivot_root
에서는 database_initgroup이 default 값인 0으로 들어가며 해당 구문은 실행되지 않고 reload가 가능했고 unpivot_root
에서는 database_passwd가 인자로 사용되어 해당 구문이 실행되어 원래 root의 nsswitch.conf를 reload하지 않습니다.
분석을 진행하다보니 이제 하나 더 궁금증이 하나 더 생겨버립니다. ‘passwd에 /woot1337(악성 lib 경로)이 들어갔다고 LPE가 가능한 lib을 load하는지’ 입니다.
how to load my_lib?
악성 lib을 load하는 시점에 break point를 걸고 backtrace를 살펴보겠습니다.
Catchpoint 1
Inferior loaded libnss_/woot1337.so.2
dl_open_worker_begin (a=a@entry=0x7ffcfd5bc3b0) at ./elf/dl-open.c:775
warning: 775 ./elf/dl-open.c: No such file or directory
(gdb) bt
#0 dl_open_worker_begin (a=a@entry=0x7ffcfd5bc3b0) at ./elf/dl-open.c:775
...
#4 0x00007f6a81a5d164 in _dl_open (file=0x55d54daf0e00 "libnss_/woot1337.so.2", mode=<optimized out>, caller_dlopen=0x7f6a81968a0f <module_load+175>, nsid=<optimized out>, argc=4, argv=0x7ffcfd5be468, env=0x7ffcfd5be490) at ./elf/dl-open.c:905
...
#10 0x00007f6a81968a0f in module_load (module=0x55d54daebda0) at ./nss/nss_module.c:187
#11 0x00007f6a81968ee5 in __nss_module_load (module=0x55d54daebda0) at ./nss/nss_module.c:302
#12 __nss_module_get_function (module=0x55d54daebda0, name=name@entry=0x7f6a819e4128 "getpwnam_r") at ./nss/nss_module.c:328
#13 0x00007f6a8196960b in __GI___nss_lookup_function (fct_name=0x7f6a819e4128 "getpwnam_r", ni=<optimized out>) at ./nss/nsswitch.c:137
#14 __GI___nss_lookup (ni=ni@entry=0x7ffcfd5bc9a8, fct_name=fct_name@entry=0x7f6a819e4128 "getpwnam_r", fct2_name=fct2_name@entry=0x0, fctp=fctp@entry=0x7ffcfd5bc9b0) at ./nss/nsswitch.c:67
#15 0x00007f6a81965928 in __GI___nss_passwd_lookup2 (ni=ni@entry=0x7ffcfd5bc9a8, fct_name=fct_name@entry=0x7f6a819e4128 "getpwnam_r", fct2_name=fct2_name@entry=0x0, fctp=fctp@entry=0x7ffcfd5bc9b0) at ./nss/XXX-lookup.c:62
#16 0x00007f6a81977628 in __getpwnam_r (name=0x55d54daec830 "root", resbuf=0x55d54daf0e20,
buffer=0x55d54daf0e50 "r is\n#used. If you really want nothing to happen then use pam_permit.so or\n#pam_deny.so as appropriate.\n\n# We fall back to the system default in /etc/pam.d/common-*\n# \n\n@include common-auth\n@include "..., buflen=1024, result=0x7ffcfd5bca28)
at ../nss/getXXbyYY_r.c:264
...
#27 0x00007f6a812229d3 in sudoers_policy_check (argc=1, argv=0x7ffcfd5be480, env_add=0x0, command_infop=0x7ffcfd5bd050, argv_out=0x7ffcfd5bd058, user_env_out=0x7ffcfd5bd060, errstr=0x7ffcfd5bd078) at ./policy.c:1244
#28 0x000055d54cf16fb3 in policy_check (run_envp=0x7ffcfd5bd060, run_argv=0x7ffcfd5bd058, command_info=0x7ffcfd5bd050, env_add=0x0, argv=0x7ffcfd5be480, argc=1) at ./sudo.c:1266
#29 main (argc=<optimized out>, argv=<optimized out>, envp=<optimized out>) at ./sudo.c:261
nss_passwd_lookup
을 진행하다가 module load에 관한 함수들을 실행시켜줍니다. module_load()
를 살펴보겠습니다.
module_load (struct nss_module *module)
{
if (strcmp (module->name, "files") == 0)
return module_load_nss_files (module);
if (strcmp (module->name, "dns") == 0)
return module_load_nss_dns (module);
void *handle;
{
char *shlib_name;
if (__asprintf (&shlib_name, "libnss_%s.so%s",
module->name, __nss_shlib_revision) < 0)
/* This is definitely a temporary failure. Do not update
module->state. This will trigger another attempt at the next
call. */
return false;
handle = __libc_dlopen (shlib_name);
free (shlib_name);
}
...
nsswitch.conf 에서 기본적으로 passwd: files systemd
같이 정의를 하는데요. 이는 /etc/passwd 파일에서 passwd 정보를 찾고 없다면, libnss_systemd.so.2
를 로드하여 db에서 조회해보겠다는 뜻입니다. 다시 module_load로 돌아와보면 우리는 passwd에 단순히 path 값을 주었기 때문에 asprintf
이후 shlib_name에 libnss_woot1337.so.2
가 들어가게 되며 악성코드가 실행됩니다.
Outro
기존 2개였던 궁금증이 3개로 늘어나긴했지만 마지막으로 궁금증과 이에 대해 찾은 해답을 정리하며 마무리하겠습니다!
해당 코드에서 nsswitch.conf는 어디에서 load 되는건지
A. nss_database_check_reload_and_get에서 nss_database를 확인 및 갱신한다.
왜 nsswitch.conf는 chroot로 바뀐 루트 디렉토리의 파일을 인식하고 있는지
A.
unpivot_root
이후 nss_database를 확인하는 과정에서local->data.reload_disabled
가 1로 설정되며 reload 되지 않기 때문이다.passwd에 /woot1337(악성 lib 경로)이 들어갔다고 LPE가 가능한 lib을 load하는지
A.
module_load
과정에서 path는 files도 dns도 아니기 때문에 lib으로 인식하고 module load를 시도하기 때문이다.
하루한줄을 작성하다가 이렇게 파고 들줄은 몰랐습니다. sudo라는게 워낙 친숙하지만 이렇게 살펴볼 기회가 없었는데 덕분에 sudo와 조금 친해진 것 같아 뿌듯함이 차오르네요!
긴 글 읽어 주셔서 감사하고 다음에 더 좋은 글로 찾아 뵙겠습니다!
Reference
[1] https://github.com/pr0v3rbs/CVE-2025-32463_chwoot/tree/main
[2] https://codebrowser.dev/glibc/glibc/nss/nsswitch.c.html#132
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.