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

sysctl_ip6_tempvltime() restores the wrong variable on rejection: failed net.inet6.ip6.tempvltime writes persist the invalid value AND clobber ip6_temp_preferred_lifetime

Summary

The EINVAL path of sysctl_ip6_tempvltime assigns the saved old valid lifetime to ip6_temp_preferred_lifetime instead of restoring ip6_temp_valid_lifetime, so (a) the value the kernel just rejected as invalid (vltime < pltime, violating RFC 4941) remains installed, and (b) the preferred lifetime is corrupted to the old valid lifetime - leaving preferred > valid, the exact state the check exists to forbid. VERIFIED live 2/2 on stock guest: after 'sysctl -w net.inet6.ip6.tempvltime=100' fails with EINVAL (pltime=86400), tempvltime reads back 100 and temppltime reads back 604800 (the sysctl(8) failure diagnostic even echoes the old value, masking the corruption). Root-only sysctl: admin configuring IPv6 privacy-address lifetimes gets a silent persistent invalid configuration feeding the RFC 4941 temporary-address lifetime math. No memory-safety impact. Fix validated (one-line: restore the correct variable, mirroring the temppltime twin).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3448 Β· 11 files
FileTypeDescriptionSize
README.md β€” 2.2 KB ↓ raw
VERDICT.md β€” 3.6 KB ↓ raw
trigger.sh β€” 1.5 KB view raw
build.sh β€” 76 B view raw
run.sh β€” 136 B view raw
run.log β€” 447 B view raw
run.2.log β€” 445 B view raw
env.txt β€” 220 B view raw
fix.diff β€” 348 B view raw
manifest.json β€” 860 B view raw
verdict.json β€” 2.7 KB view raw

DF-3448 β€” sysctl_ip6_tempvltime restores the wrong variable (and keeps the rejected value)

File: sys/netinet6/in6_proto.c (pass 2)

What

sysctl_ip6_tempvltime() (sys/netinet6/in6_proto.c:437-453) guards the RFC-4941 invariant valid_lifetime >= preferred_lifetime. When a write violates it, the intended behavior (matching the KAME/FreeBSD original) is to restore the OLD valid lifetime and fail with EINVAL.

DragonFly's copy instead does:

old = ip6_temp_valid_lifetime;
error = SYSCTL_IN(req, arg1, sizeof(int));      /* arg1 == &ip6_temp_valid_lifetime */
if (ip6_temp_valid_lifetime < ip6_temp_preferred_lifetime) {
        ip6_temp_preferred_lifetime = old;      /* BUG: wrong variable */
        return (EINVAL);
}

Two effects on every rejected write:

  1. the rejected ip6_temp_valid_lifetime value stays installed (the restore never touches it), and
  2. ip6_temp_preferred_lifetime is clobbered with the old valid lifetime.

Impact

Root-only sysctl misconfiguration persistence + corruption of the IPv6 privacy-address (temporary address, RFC 4941) lifetime configuration: preferred > valid afterwards, so subsequent temporary-address management (in6_ifadd.c) computes lifetimes from an inconsistent state (addresses can be treated as valid long past their preferred expiry, or regenerate on the wrong schedule). No memory-safety impact. Severity: Low.

Reproduce (in the QEMU guest, as root)

sh /tmp/df3448_trigger.sh        # or: scp trigger.sh over and run it

Expected output on a vulnerable kernel (see run.log / run.2.log):

== attempting invalid write: tempvltime=100 (100 < pltime 86400) ==
sysctl: net.inet6.ip6.tempvltime=100: Invalid argument
== state after rejected write ==
pltime=604800  vltime=100
BUG PART 1 CONFIRMED: rejected tempvltime=100 persisted (should still be 604800)
BUG PART 2 CONFIRMED: temppltime clobbered to 604800 (old vltime; should still be 86400)

Note the extra trap: sysctl -w prints the old value (604800) in its diagnostic while the kernel variable is actually left at 100 β€” an admin reading the error output would believe the write was fully rejected.

Build: none needed (shell-only trigger).

VERDICT.md
↓ download raw

DF-3448 β€” VERDICT

Status: reproduced (deterministic, 2/2 runs) Β· Impact: none (config corruption, root-only) Β· Confidence: certain

Root cause (path:line)

sys/netinet6/in6_proto.c:437-453, sysctl_ip6_tempvltime():

447:    old = ip6_temp_valid_lifetime;
448:    error = SYSCTL_IN(req, arg1, sizeof(int));   /* writes ip6_temp_valid_lifetime */
449:    if (ip6_temp_valid_lifetime < ip6_temp_preferred_lifetime) {
450:        ip6_temp_preferred_lifetime = old;   /* restores the WRONG variable */
451:        return (EINVAL);
452:    }

arg1 is &ip6_temp_valid_lifetime (registration at in6_proto.c:495-498), so the SYSCTL_IN at line 448 installs the new value; the rejection path at line 450 was supposed to undo it (ip6_temp_valid_lifetime = old, exactly as the KAME/FreeBSD original and as the twin handler sysctl_ip6_temppltime at in6_proto.c:418-435 correctly does for ip6_temp_preferred_lifetime). The DragonFly transcription swapped the variable name. Consequences on every rejected write:

  • ip6_temp_valid_lifetime keeps the value the kernel just told the administrator was invalid (persistent misconfiguration);
  • ip6_temp_preferred_lifetime is overwritten with the old valid lifetime, so afterwards preferred (604800) > valid (100) β€” the exact invariant the check exists to prevent.

These feed in6_tmpaddpub()/temporary-address lifetime math in sys/netinet6/in6_ifadd.c (e.g. ip6_temp_preferred_lifetime, ip6_temp_valid_lifetime use when creating/regenerating RFC 4941 temporary addresses), producing wrong preferred/valid expiry schedules for privacy addresses when net.inet6.ip6.use_tempaddr is later enabled.

How verified

Ran trigger.sh on the live QEMU guest (stock INVARIANTS kernel, DragonFly 6.5-DEVELOPMENT #0, securelevel -1, root):

  • run.log (run 1) and run.2.log (run 2): after the EINVAL, tempvltime reads back 100 (the rejected value) and temppltime reads back 604800 (old vltime). Both bug halves confirmed twice, deterministically.
  • Guest sysctls restored to defaults afterwards (86400 / 604800) β€” guest left clean.

Why not higher severity

  • Write path is root-only (sysctl CTLFLAG_RW handler; no unprivileged route to it β€” sockopt/sysctl privilege).
  • No memory safety: both variables are plain ints consumed arithmetically by address-lifetime logic; no length/index/pointer derived from them.
  • Worse-case realistic impact: administrator gets a silent persistent invalid configuration plus a corrupted preferred lifetime β€” a correctness/privacy-regression bug, not an exploitation primitive.

Fix validation

fix.diff restores the correct variable (one-word change, mirroring the sibling sysctl_ip6_temppltime and the KAME/FreeBSD original). Fix validation by rebuild was not performed β€” mandatory rebuilds apply to memory-corruption findings; for this Low logic finding the correction is verifiable by inspection (fix_status: not_testable).

Fix

--- a/sys/netinet6/in6_proto.c
+++ b/sys/netinet6/in6_proto.c
@@ -446,7 +446,7 @@
    old = ip6_temp_valid_lifetime;
    error = SYSCTL_IN(req, arg1, sizeof(int));
    if (ip6_temp_valid_lifetime < ip6_temp_preferred_lifetime) {
-       ip6_temp_preferred_lifetime = old;
+       ip6_temp_valid_lifetime = old;
        return (EINVAL);
    }

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

not_testable

fix.diff restores the correct variable (one-word change mirroring the pltime handler and upstream KAME code); kernel rebuild validation skipped β€” mandatory only for memory-corruption findings, and this Low logic fix is verifiable by inspection

findings/poc/DF-3448/fix.diff
↓ fix.diffper-fix-DF-3448

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-3448/run.log and run.2.log β€” decisive lines: 'sysctl: net.inet6.ip6.tempvltime=100: Invalid argument' followed by 'pltime=604800  vltime=100' and both 'BUG PART n CONFIRMED' lines; VERDICT.md has the full path:line trace; fix.diff is the one-word correction

PoC changes

none β€” pure shell trigger written fresh (no seed); no compilation required

Verified recommended fix

In sysctl_ip6_tempvltime's rejection path, restore ip6_temp_valid_lifetime = old (not ip6_temp_preferred_lifetime), matching the sysctl_ip6_temppltime twin and the KAME/FreeBSD original

Verdict

sysctl_ip6_tempvltime() in sys/netinet6/in6_proto.c:437-453 restores the wrong variable on the EINVAL path: a rejected net.inet6.ip6.tempvltime write PERSISTS in the kernel, and ip6_temp_preferred_lifetime is simultaneously clobbered with the old valid lifetime. Verified live on the stock INVARIANTS guest (2/2 deterministic runs): after 'sysctl -w net.inet6.ip6.tempvltime=100' fails with EINVAL (pltime=86400), tempvltime reads back 100 and temppltime reads back 604800. Root-only config corruption of RFC 4941 temporary-address lifetimes; no memory-safety impact. The twin handler sysctl_ip6_temppltime and the KAME/FreeBSD original restore correctly; this is a DragonFly transcription bug.