Unvalidated timestamp month indexes mon_lens out of bounds β kernel static .data past the 2x12 array observable via stat() mtime differences
Summary
udf_timetotimespec uses the raw uint8_t month from disk as the mon_lens[lpyear][i] loop bound with no month<=12 check; months 13..255 read up to ~1KB of kernel .data past the static array and the summed OOB words surface in st_mtime, where consecutive-file stat() differences isolate individual words. VERIFIED guest: 19/19 leap-row terms for months>=13 were out-of-range words (first at mon_lens[1][12] = flat index 24). FreeBSD fixed this exact bug. Crafted/corrupt UDF image -> unpriv stat() -> build-deterministic disclosure of kernel static .data adjacent to mon_lens (potential pointer-bearing tables): a KASLR-defeat / kernel-fingerprint oracle. Low on this no-KASLR guest; one-line fix (validate month) validated: months>=13 clamp to epoch 0.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3104 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_img.py | β | 10.9 KB | view raw | |
| df3102.udf | β | 1.0 MB | β download | |
| poc.c | β | 6.2 KB | view raw | |
| build.sh | β | 115 B | view raw | |
| run.sh | β | 89 B | view raw | |
| run.log | β | 1.5 KB | view raw | |
| leak_sample.txt | β | 1.5 KB | view raw | |
| fix.diff | β | 383 B | view raw | |
| df31xx_combined_fix.diff | β | 1.9 KB | view raw | |
| fix_validate.sh | β | 2.5 KB | view raw | |
| fix_run.log | β | 6.2 KB | view raw | |
| fix_build.log | β | 7.4 KB | view raw | |
| verdict.json | β | 3.0 KB | view raw | |
| README.md | β | 1.9 KB | β raw | |
| VERDICT.md | β | 3.1 KB | β raw | |
| env.txt | β | 236 B | view raw |
DF-3104 β unvalidated mtime month reads mon_lens out of bounds
What
udf_timetotimespec() (sys/vfs/udf/udf_vnops.c:243-294) uses the raw
ECMA-167 timestamp.month (a uint8_t straight from disk) as the loop bound
for mon_lens[lpyear][i] (:267-268) with no month <= 12 validation:
lpyear = udf_isaleapyear(time->year);
for (i = 1; i < time->month; i++)
t->tv_sec += mon_lens[lpyear][i] * 3600 * 24;
mon_lens is a static int[2][12] (:220-223). With a leap year (row 1),
month 13 already indexes flat word 24 β the first word past the array;
months up to 255 read up to ~1 KB of adjacent kernel .data. The summed
out-of-bounds words are fully observable from userspace: each crafted file
with month m contributes mon_lens[1][m-1] * 86400 to st_mtime, so
stat() differences between files isolate individual OOB words (25 of 32
bits recoverable per word since 86400 = 2^7Β·675). Static .data next to
the array can contain kernel pointers (e.g. vops tables) β a KASLR-defeat
oracle β plus wild st_mtime values.
FreeBSD fixed this exact bug ("Don't trust time->month as it will be used
for an array index" in their udf_timetotimespec).
Reproduce
./run.sh # mounts image (root); as uid 1001: stat T10..T31
Expected (stock kernel)
T11 ... 2592000 in-range (30 days) T12 ... 2678400 in-range (31 days) T13 ... -2059807616 OUT-OF-RANGE (mon_lens[1][12] = flat 24 = OOB) ... DF3104_OOB_CONFIRMED: 19 of the month terms for months>=13 read out-of-range words
Patched kernel: months β₯ 13 clamp to epoch 0 (T13..T31 ... 0), exactly
the FreeBSD behavior β the stale guest binary's diff heuristic still prints
"OUT-OF-RANGE" for the clamped rows; the meaningful signal is that every
st_mtime for month β₯ 13 is 0.
Fix validation
See DF-3102/fix_validate.sh + df31xx_combined_fix.diff (shared driver); fix_run.log in this directory.
DF-3104 β VERDICT
Status: REPRODUCED (leak, weak) β OOB read of kernel static .data past
mon_lens[2][12], with the read words observable via stat() timestamps
from an unprivileged user on a crafted UDF image.
Root cause
sys/vfs/udf/udf_vnops.c:220-223:
static int mon_lens[2][12] = { ... };
:267-268:
for (i = 1; i < time->month; i++)
t->tv_sec += mon_lens[lpyear][i] * 3600 * 24;
time->month is uint8_t from the file entry's timestamp on disk, never
validated. lpyear β {0,1} (udf_isaleapyear, :225-235, always returns 0
or 1), so mon_lens[1][i] for i β₯ 12 reads flat words β₯ 24 β beyond
the 24-word array, up to i = 254 (month 255) β ~1 KB of adjacent .data.
Reproduction (run.log)
Image files T10..T31: FE mtime year=2020 (leap), month = 10..31,
day=1, identical otherwise. As uid 1001 (su maxx):
T11 1604232000 2592000 in-range <- mon_lens[1][10] = Oct 31d (valid) T12 1606910400 2678400 in-range <- mon_lens[1][11] = Nov 30d (valid) T13 -452897216 -2059807616 OUT-OF-RANGE <- mon_lens[1][12] = flat 24 = OOB T14 -1367233344 -914336128 OUT-OF-RANGE ... (all 19 terms for months >= 13) T31 5892316608 1039478784 OUT-OF-RANGE DF3104_OOB_CONFIRMED: 19 of the month terms for months>=13 read out-of-range words (first at mon_lens[1][12], flat index 24)
Each consecutive-pair difference isolates exactly one OOB word scaled by 86400 (mod 2^32, signed); the values are stable per kernel build. 19 of 19 month-terms β₯ 13 are out of the {28,29,30,31}-day range β the array-bound violation is total, not statistical.
Impact
- Deterministic disclosure of kernel static
.data(up to ~1 KB pastmon_lens) throughstat()mtime arithmetic: 25 of 32 bits per word recoverable (86400 = 2^7 Γ 675). Whatever the linker places after the array β potentially function-pointer-bearing tables β leaks at build-deterministic offsets: a KASLR-defeat / build-fingerprint oracle. - On this guest (no KASLR) the practical ceiling is low β hence Low severity β but the read itself is a genuine array-index violation fed by disk data, and the fix is one line.
Fix validation (fix.diff)
if (time->year < 1970 || time->month < 1 || time->month > 12) guard
(FreeBSD-equivalent). Patched module, same image (fix_run.log):
T13 0 ... <- clamped to epoch (fix working) ...all months >= 13 -> st_mtime 0
Every st_mtime for months β₯ 13 becomes 0; months β€ 12 keep valid values.
(The "OUT-OF-RANGE" labels printed by the stale guest-side PoC binary refer
to the clamp-to-zero diffs, not to OOB reads β the binary's heuristic only
compares diffs; the mtimes themselves show the clamp. The updated poc.c in
this pack treats st_mtime == 0 as clamped.)
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedWith the month validation in the patched module, every file with month >= 13 reports st_mtime 0 (epoch clamp) instead of garbage sums; months <= 12 keep correct values (fix_run.log).
['fix_run.log', 'fix_build.log', 'fix.diff', 'df31xx_combined_fix.diff']
Confirmed kernel references
Detail
Exploit chain
crafted UDF image (or any image with corrupt timestamps) -> unprivileged stat() of files whose FE mtime month = 13..255 -> per-word recovery (25/32 bits each, 86400 = 2^7*675) of kernel .data adjacent to mon_lens at build-deterministic offsets -> KASLR-defeat / kernel-build fingerprint oracle. Read-only, low bandwidth, hence Low severity.
Evidence (decisive lines)
["run.log: T11/T12 diffs in-range (2592000/2678400), T13..T31 all OUT-OF-RANGE diffs ending in 'DF3104_OOB_CONFIRMED: 19 of the month terms ...'", 'leak_sample.txt: full st_mtime table', 'fix_run.log: patched module -> every month>=13 st_mtime clamped to 0', 'craft_img.py: T10..T31 FEs with year=2020 (leap), month=10..31']
PoC changes
Seed PoC authored fresh for pass 2; one iteration (shared
Verified recommended fix
Validate the timestamp in udf_timetotimespec: reject year < 1970 || month < 1 || month > 12 (clamp tv_sec to 0), matching FreeBSD.
Verdict
udf_timetotimespec() indexes mon_lens[lpyear][i] for i = 1..month-1 using the unvalidated uint8_t month from the on-disk timestamp; months 13..255 read up to ~1 KB of kernel static .data past the 2x12 int array and the summed words are observable via stat() st_mtime differences. Reproduced as uid 1001 on a crafted image: 19/19 month terms for months >= 13 (leap-year row) were out-of-range words, e.g. first OOB term mon_lens[1][12] (flat index 24). FreeBSD carries the identical fix with the comment 'Don't trust time->month as it will be used for an array index'.
No comments yet.