β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3104

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3104 Β· 16 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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 past mon_lens) through stat() 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

fixed
baseline reproduced→ patch + rebuild →patched clean

With 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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 (same kernel; udf.ko module rebuilt from patched /usr/src/sys/vfs/udf and kldload'ed)

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 fix). No obstacles.

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'.