[Research] CVE-2025-32463 Into the 'sudo -R'(EN)

Intro

Hi, I’m poosic. I’m back on Hackyboiz with my first research article! >_-

In this post, I’ll try to answer some of the questions I had while researching CVE-2025-32463: LPE with chroot in sudo. For a high-level overview of the vulnerability, see 1Day1Line!

# sudoers.c
pivot_root()
-> resolve_cmnd() # load changed root's nsswitch.conf
-> unpivot_root() # not load original root's nsswitch.conf

However, if we briefly look at the flow of sudoers.c, the vulnerability occurred because the NSS API is called in the resolve_cmnd function after pivot_root is executed, and the attacker’s arbitrary file (/etc/nsswitch.conf) loaded with root privileges through pivot_root is read and the nss_database related information is updated, but it is not updated to the original root’s nss_database after unpivot_root.

When I looked at pivot.c and sudoers.c where the vulnerability occurred, I had questions. The questions were as follows.

  1. where is nsswitch.conf loaded in that code?
  2. why is nsswitch.conf recognizing files in the root directory that have been replaced with chroot?

When I checked the sudo v1.9.17 pivot.c code where the vulnerability actually occurred, I found that unpivot_chroot() dutifully reads nsswitch.conf back in, so the problem got even more muddled.

The obvious way to find out would be to see the sudo repo in git or analyze it with gdb.

Environment setting

When I first set up the environment, I was going to compile sudo and glibc for debugging on my existing Ubuntu, but since the Ubuntu I installed on WSL didn’t use the -R option, there were a lot of things to set up in sudo.conf and other files, so I found a slightly(?) easier way.

If you want to follow along with this article and analyze it, you can refer to [1] in the reference below to proceed with the installation. The installation process is simple, so let’s skip it and start setting up the environment.

The environment doesn’t have gdb installed, obviously you need to have sudo privileges to install gdb, but when I run run.sh, I can’t install gdb because it puts me as a regular user named pwn.

I stupidly went back to doing my own configuration here and thought, “Hahaha I can have sudo privileges!” and used the POC script that docker set up for me to get root privileges and install gdb.

L0ch finds out, and I remind him of the emotion of shock.

In my delirium, I forgot that this is a docker container that I put up locally… You can easily set it up as root with docker exec -it --user root <container-id> bash.

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

In order to debug sudo, you need sudo privileges. You can do it as root, but I gave sudo privileges to a pwn with normal privileges just in case it makes a difference in the flow. Afterwards, run gdb with sudo privileges and you’re ready to debug. Now let’s get started!

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.

To find where nsswitch.conf is loaded, we first breakpointed the point where it opens after pivot_root.

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

Now that we’ve verified that fopen loads /etc/nsswitch.conf as an argument, let’s check the callstack with the backtrace command. The first thing we see is nss_databse_reload and nss_database_check_reload_and_get, which are related to nss. In our current flow, these functions do the following

  • nss_database_check_reload_and_get: reload if nsswitch.conf has been changed.
  • nss_database_reload: Open and parse the /etc/nsswitch.conf file, populate staging with NSS service information, and fill in default values if necessary.
(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

I thought it would just skip unpivot_root() afterward, but it’s caught at the break point. It loads nss_database_check_reload_and_get() but doesn’t call nss_database_reload(), unlike after pivot. So the problem is that nss_database_reload doesn’t happen at unpivot_root, so we still have the nsswitch.conf recognized by chroot.

Now let’s take a hard look at the code!

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;
}

In the first if statement, we check whether to perform a reload via reload_disabled and if that value is set, we store the current value in result without reloading.

After that, we check to see if the file has changed or if another thread is updating it in the meantime, and then proceed with the reload by calling nss_databse_reload(). We get a return value of ok and check by calling atomic_load_acquire(&local->data.reload_disabled) as in the first if statement.

If so, we can expect that local->data.reload_disabled is set to 1, or that the if statement associated with the file check terminates, preventing it from reloading.

Since nss_database was reloaded after pivot_root, let’s look at it after unpivot_root with gdb to determine the exact cause.

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

Immediately after pivot_root it was database_index = nss_database_initgroup, but after unpivot_root it is passed nss_database_passwd, so it enters the syntax where local->data.reload_disabled is set to 1, and it will not 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"

Checking via gdb, we see that in pivot_root, database_initgroup enters the default value of 0 and the statement is not executed, allowing us to reload, and in unpivot_root, database_passwd is used as an argument and the statement is executed, not reloading the nsswitch.conf on the original root.

While analyzing, I now have one more question. ‘How does it load a malicious lib with /woot1337 (the path to the malicious lib) in passwd’.

how to load my_lib?

Let’s break point at the point where we load the malicious lib and look at the 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

After the nss_passwd_lookup, we run the functions for the module load. Let’s take a look at 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);
  }
  ...

In nsswitch.conf, we basically define something like passwd: files systemd, which means that if we’re not looking for passwd information in the /etc/passwd file, we’ll load libnss_systemd.so.2 and look it up in the db. If we come back to module_load again, because we simply gave a path value for passwd, after asprintf, the shlib_name will have libnss_woot1337.so.2 in it and the malware will execute.

Outro

I know that’s three questions instead of two, but let’s wrap things up with a final roundup of questions and the answers I found to them!

  1. where is nsswitch.conf loaded in that code?

    A. nss_database_check_reload_and_get to check and update the nss_database.

  2. why does nsswitch.conf recognize files in the root directory that have been replaced with chroot?

    A. Because local->data.reload_disabled is set to 1 and is not reloaded while checking nss_database after unpivot_root.

  3. passwd contains /woot1337 (malicious lib path), does LPE load possible libs?

    A. During the module_load process, path is neither files nor dns, so it is recognized as a lib and tries to load the module.

I didn’t expect to dig this deep after a day of writing lines, I’m very familiar with sudo but never had a chance to look at it like this, so I feel like I’ve gotten a little bit more familiar with sudo!

Thanks for reading this long post and I’ll see you next time with something better!

Reference

[1] https://github.com/pr0v3rbs/CVE-2025-32463_chwoot/tree/main

[2] https://codebrowser.dev/glibc/glibc/nss/nsswitch.c.html#132