
Reproduction and PoC scripts for CVE-2026-96512 (SudoTimeWarp), where the caller's TZ shifts sudoers NOTBEFORE/NOTAFTER windows, plus mitigation checks.
sudo: the caller's TZ decides NOTBEFORE/NOTAFTER
Reproduction material for SudoTimeWarp (CVE-2026-96512). A sudoers rule whose Date_Spec timestamp omits
the trailing Z is converted by mktime(), which re-reads getenv("TZ") on every call.
Because sudo is setuid-root and the caller's environ crosses execve() intact, the
unprivileged caller chooses the timezone in which their own validity window is evaluated.
The base issue — TZ influences NOTBEFORE/NOTAFTER — was reported privately by the
XlabAI Team of Tencent Xuanwu Lab, the Atuin Automated Vulnerability Discovery
Engine, and Guannan Wang, Zhanpeng Liu and Guancheng Li, and credited in commit
db669167c (2026-03-14).
SudoTimeWarp (CVE-2026-96512) is the finding that db669167c is incomplete. The protection it
installs covers glibc's timezone cache — and with it the log timestamps — but does not
reach the mktime() at gentime.c:156, where authorization is decided. On a tree that
already contains that commit, the window shift remains fully reproducible. The maintainer
records the point in the message of 1820a349:
the previous change "was not effective since the mktime() function re-reads the TZ environment variable each time it is called"
A sudoers rule that (1) grants a command to the caller, (2) carries NOTBEFORE= or
NOTAFTER=, and (3) writes the timestamp without a Z suffix and without an explicit
offset.
Item (3) is not exotic: the sudoers manual documents the suffix-less form as a supported
extension, prints 20151201235900 as one of its four example timestamps
(docs/sudoers.man.in:1820), and the form appears in the project's own regression corpus
(plugins/sudoers/regress/testsudoers/test13.sh).
Always in a disposable container or VM. Every script here rewrites /etc/sudoers.
They back it up and restore it, but a mistake there locks you out of a real machine — the
scripts refuse to run outside a container unless you pass --i-know.
docker build -t sudotimewarp .
docker run --rm -it sudotimewarp
Or without building an image:
docker run --rm -it -v "$PWD:/m" debian:trixie bash -c \
'apt-get update >/dev/null && apt-get install -y sudo >/dev/null && bash /m/poc.sh'
Expected output on an affected build:
=== probes ===
rule valid, no TZ : uid=0(root) gid=0(root) groups=0(root)
rule expired, no TZ : sudo: a password is required
rule expired, TZ=UTC : sudo: a password is required
rule expired, TZ=XXX24 : uid=0(root) gid=0(root) groups=0(root)
expired + 'Z', TZ=XXX24 : sudo: a password is required
XXX is an arbitrary three-letter timezone abbreviation and 24 is a POSIX offset. No
file is involved and none needs to exist — the exploitable channel is the inline POSIX
string only. The tzfile form (TZ=:/tmp/evil.tz and variants) is refused by glibc's
__libc_enable_secure guard under setuid and measures exactly 0 s of shift, which is what
separates this from CVE-2014-9680.
Lines 1–3 are controls, and they matter: a parse error would produce the same ALLOW as the bug.
poc.sh exits 0 when affected, 1 when not, 2 when the controls did not hold.
poc.sh uses NOPASSWD so it can run non-interactively. That is not a condition of
the bug. The split scripts show the privilege boundary explicitly — part 1 does only what
an administrator legitimately does, part 2 runs as the unprivileged user and uses no
privilege of any kind:
bash repro-admin.sh escalation # as root: writes the policy
su - poc -c 'bash /poc/repro-attacker.sh'
bash repro-admin.sh --cleanup
repro-admin.sh takes four scenarios:
With a password-requiring rule the caller still authenticates through PAM, and a wrong password still fails. This is not an authentication bypass — what moves is the authorization decision.
PR:L, not PR:N.TZ values deny.C:L/I:N/A:N). The 7.8 vector scores the case where the dated rule is
broader than the caller's permanent access.The ~25 h bound applies to the access window, not to the duration of the impact: one successful use inside it is enough to establish persistence that outlives the window.
Append Z to every NOTBEFORE/NOTAFTER timestamp — it forces the timegm() branch.
grep -rE 'NOT(BEFORE|AFTER)=' /etc/sudoers /etc/sudoers.d/
Do not audit with sudo -l. It formats every Date_Spec through gmtime() and always
appends a Z (plugins/sudoers/display.c:229), so a rule written
NOTAFTER=20260827221423 is displayed as NOTAFTER=20260827221423Z. The output normalises
away exactly the detail that decides it. cvtsudoers and fmtsudoers behave the same way.
Read /etc/sudoers directly.
1820a349: https://github.com/sudo-project/sudo/commit/1820a349687522f51023d1ae5925125f59679a8cReported to the maintainer on 2026-08-28, with no deadline. A candidate patch came back the same day; the public fix landed on 2026-08-29. CVE assigned by Red Hat, acting as CNA-LR, and published 2026-09-23. There is no embargo: everything here has been public since the fix commit.
SudoTimeWarp / CVE-2026-96512: Ermenson Junior, independent research, recorded by Red Hat as "Independent security research". The original report of the underlying issue belongs to the XlabAI Team of Tencent Xuanwu Lab, the Atuin Automated Vulnerability Discovery Engine, and Guannan Wang, Zhanpeng Liu and Guancheng Li.
Published for defensive use: verifying whether a host is affected, and validating the Z
mitigation.
| Name | SudoTimeWarp |
| CVE | CVE-2026-96512 |
| CVSS v3.1 | AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H — 7.8 High |
| CWE | CWE-863 (registered); mechanism is CWE-807 |
| Affected | sudo 1.8.20 through 1.9.17p2, and main before 1820a349 |
| Fixed in | 1820a349 (2026-08-29) |
| Shift | up to 24 h 59 m 59 s per direction; total interval 49 h 59 m 58 s |
| Line | Checks |
|---|
| 1 | The rule works at all inside its window |
| 2 | The denial in line 4's absence really comes from NOTAFTER |
| 3 | Setting TZ is not itself the cause — TZ=UTC decides as no TZ does |
| 4 | The bug: the expired rule executes as root |
| 5 | The boundary: with the documented Z, the timegm() branch is taken and it dies |
| Scenario | Policy |
|---|
expired (default) | one rule, NOTAFTER one hour in the past, zone-less |
valid | the same rule still inside its window — control |
expired-z | the same expired rule with the documented Z — control, unaffected form |
escalation | a narrow permanent grant plus an expired broad one — the shape a maintenance or break-glass grant actually takes |