Open Bug 2029455 Opened 1 month ago Updated 19 days ago

Assertion failure: startEpochNs != endEpochNs in NudgeToCalendarUnit with 24h dateline gap (Pacific/Apia)

Categories

(Core :: JavaScript: Standard Library, defect, P3)

defect

Tracking

()

People

(Reporter: bugmon, Unassigned)

References

(Blocks 1 open bug)

Details

(Keywords: ai-involved, assertion, testcase)

Attachments

(3 files)

When Temporal duration rounding/totaling is performed relative to a ZonedDateTime in a zone that underwent a 24-hour forward dateline transition (e.g. Pacific/Apia, which skipped 2011-12-30 entirely when moving from UTC-11 to UTC+13), the nudge-window endpoints computed by NudgeToCalendarUnit can collapse to a single instant. DisambiguatePossibleEpochNanoseconds handles the gap by adding (offsetAfter - offsetBefore) = 86400000000000 ns and resolving the resulting later date-time, so the skipped day 2011-12-30T<t> and the real day 2011-12-31T<t> both resolve to the same epoch nanoseconds. NudgeToCalendarUnit then computes startEpochNs == endEpochNs and trips MOZ_ASSERT(startEpochNs != endEpochNs).

In release builds the assertion is compiled out; numerator and denominator both become 0, FractionToDouble short-circuits on numerator==0 and returns 0, and the API returns a silently incorrect Temporal result (e.g. Duration.from({days:-1}).total({unit:'days', relativeTo:zdt}) returns 0 instead of -1). No out-of-bounds access, division-by-zero trap, or other memory-safety issue occurs. This is a debug-only crash / release correctness bug in the Temporal implementation, reachable from content JS via Duration.prototype.total, Duration.prototype.round, and ZonedDateTime.prototype.until/since with calendar smallestUnit.

Build Info

Affected Code

File: js/src/builtin/temporal/TimeZone.cpp, line 1054-1116

  // Step 14.
  int64_t nanoseconds = offsetAfter - offsetBefore;

  // Step 15.
  MOZ_ASSERT(std::abs(nanoseconds) <= ToNanoseconds(TemporalUnit::Day));
  ...
  // Step 17.
  MOZ_ASSERT(disambiguation == TemporalDisambiguation::Compatible ||
             disambiguation == TemporalDisambiguation::Later);

  // Steps 18-19.
  auto laterTime = ::AddTime(isoDateTime.time, nanoseconds);
  MOZ_ASSERT(std::abs(laterTime.days) <= 1,
             "adding nanoseconds is at most one day");

  // Step 20.
  auto laterDate =
      BalanceISODate(isoDateTime.date, static_cast<int32_t>(laterTime.days));

  // Step 21.
  auto laterDateTime = ISODateTime{laterDate, laterTime.time};

  // Step 22.
  PossibleEpochNanoseconds laterEpochNs;
  if (!GetPossibleEpochNanoseconds(cx, timeZone, laterDateTime,
                                   &laterEpochNs)) {
    return false;
  }

  // Steps 23-24.
  MOZ_ASSERT(!laterEpochNs.empty());

  // Step 25.
  *result = laterEpochNs.back();
  return true;

File: js/src/builtin/temporal/Duration.cpp, line 2303-2317

  const auto& [r1, r2, startEpochNs, endEpochNs, startDuration, endDuration] =
      nudgeWindow;

  // Step 13.
  MOZ_ASSERT(startEpochNs != endEpochNs);
  MOZ_ASSERT_IF(sign > 0,
                startEpochNs <= destEpochNs && destEpochNs <= endEpochNs);
  MOZ_ASSERT_IF(sign < 0,
                endEpochNs <= destEpochNs && destEpochNs <= startEpochNs);

  // Step 14.
  auto numerator = (destEpochNs - startEpochNs).toNanoseconds();
  auto denominator = (endEpochNs - startEpochNs).toNanoseconds();
  MOZ_ASSERT(denominator != Int128{0});
  MOZ_ASSERT(numerator.abs() <= denominator.abs());

DisambiguatePossibleEpochNanoseconds resolves a wall-clock time that falls in a gap by shifting forward by (offsetAfter - offsetBefore). For the Pacific/Apia 2011-12-29/31 dateline change this shift is exactly 24 hours, so 2011-12-30T12:00 (gap) and 2011-12-31T12:00 (real) both resolve to the same epoch instant. ComputeNudgeWindow in NudgeToCalendarUnit picks r1 = -1 day (2011-12-31T12:00) and r2 = -2 days (2011-12-30T12:00) relative to 2012-01-01T12:00[Pacific/Apia]; both map to identical epoch nanoseconds, so startEpochNs == endEpochNs and the MOZ_ASSERT fires. In release builds denominator becomes 0 but numerator is also forced to 0 by the |numerator.abs() <= denominator.abs()| structural invariant, so FractionToDouble returns 0 without dividing by zero and the API returns a wrong-but-valid number.

Exploit Chain

  1. Attacker-controlled script constructs a ZonedDateTime shortly after a 24-hour forward dateline transition, e.g. Temporal.ZonedDateTime.from("2012-01-01T12:00:00[Pacific/Apia]").
  2. Script calls Temporal.Duration.from({days:-1}).total({unit:'days', relativeTo:zdt}) (or .round / ZonedDateTime.until with a calendar smallestUnit).
  3. Duration_total -> DifferenceZonedDateTimeWithTotal -> TotalRelativeDuration -> NudgeToCalendarUnit calls ComputeNudgeWindow, which adds r1=-1 and r2=-2 days to the relative date and resolves each via GetEpochNanosecondsFor(..., Compatible).
  4. For r2 the target 2011-12-30T12:00 falls in the 24h gap; DisambiguatePossibleEpochNanoseconds (TimeZone.cpp) shifts it forward 24h to 2011-12-31T12:00, the same instant as r1.
  5. startEpochNs == endEpochNs; MOZ_ASSERT(startEpochNs != endEpochNs) fires in debug builds (controlled crash).
  6. In release builds the assert is absent; numerator = denominator = 0, FractionToDouble returns 0, and the user receives an incorrect total/rounded duration. No memory corruption.

Steps to Reproduce

  1. Build SpiderMonkey debug shell (--enable-debug).
  2. Save testcase: let zdt = Temporal.ZonedDateTime.from("2012-01-01T12:00:00[Pacific/Apia]"); Temporal.Duration.from({days:-1}).total({unit:'days', relativeTo:zdt});
  3. Run: obj-js-debug/dist/bin/js --fuzzing-safe testcase.js
  4. Observe: Assertion failure: startEpochNs != endEpochNs, at js/src/builtin/temporal/Duration.cpp:2307.

Security Impact

  • Severity: Low
  • Attacker capability: Content JavaScript can deterministically crash a debug/assertion-enabled SpiderMonkey build (DoS for developers/fuzzers). In release builds the same input causes Temporal Duration.total/round and ZonedDateTime.until/since to return numerically incorrect results around 24-hour dateline transitions (e.g. total of -1 day reported as 0; 24h difference rounded to -P2D), but does not cause memory corruption, undefined behaviour, or information disclosure.
  • Preconditions: None beyond the ability to run JavaScript that uses the Temporal API with an IANA time zone containing a 24-hour forward transition (Pacific/Apia 2011-12-30, Pacific/Kiritimati 1994-12-31, etc.). Crash requires a debug (MOZ_ASSERT-enabled) build; release builds only miscompute.

ASAN Report

[1085] Assertion failure: startEpochNs != endEpochNs, at /firefox/js/src/builtin/temporal/Duration.cpp:2307
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==1085==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000000 (pc 0x591124951f96 bp 0x7ffcea56e3a0 sp 0x7ffcea56e2b0 T1085)
==1085==The signal is caused by a WRITE memory access.
==1085==Hint: address points to the zero page.
    #0 0x591124951f96 in MOZ_CrashSequence(void*, long) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:235:3
    #1 0x591124951f96 in NudgeToCalendarUnit(JSContext*, js::temporal::InternalDuration const&, js::temporal::EpochNanoseconds const&, js::temporal::EpochNanoseconds const&, js::temporal::ISODateTime const&, JS::Handle<js::temporal::TimeZoneValue>, JS::Handle<js::temporal::CalendarValue>, js::temporal::Increment, js::temporal::TemporalUnit, js::temporal::TemporalRoundingMode, DurationNudge*) /firefox/js/src/builtin/temporal/Duration.cpp:2307:3
    #2 0x591124952339 in js::temporal::TotalRelativeDuration(JSContext*, js::temporal::InternalDuration const&, js::temporal::EpochNanoseconds const&, js::temporal::EpochNanoseconds const&, js::temporal::ISODateTime const&, JS::Handle<js::temporal::TimeZoneValue>, JS::Handle<js::temporal::CalendarValue>, js::temporal::TemporalUnit, double*) /firefox/js/src/builtin/temporal/Duration.cpp:2853:8
    #3 0x5911249ae3a3 in js::temporal::DifferenceZonedDateTimeWithTotal(JSContext*, JS::Handle<js::temporal::ZonedDateTime>, js::temporal::EpochNanoseconds const&, js::temporal::TemporalUnit, double*) /firefox/js/src/builtin/temporal/ZonedDateTime.cpp:862:10
    #4 0x59112495f7c7 in Duration_total(JSContext*, JS::CallArgs const&) /firefox/js/src/builtin/temporal/Duration.cpp:3919:10
    #5 0x59112495cd5c in bool JS::CallNonGenericMethod<&IsDuration(JS::Handle<JS::Value>), &Duration_total(JSContext*, JS::CallArgs const&)>(JSContext*, JS::CallArgs const&) /firefox/obj-js-debug/dist/include/js/CallNonGenericMethod.h:103:12
    #6 0x59112495cd5c in Duration_total(JSContext*, unsigned int, JS::Value*) /firefox/js/src/builtin/temporal/Duration.cpp:3988:10
    #7 0x5911240d5094 in CallJSNative(JSContext*, bool (*)(JSContext*, unsigned int, JS::Value*), js::CallReason, JS::CallArgs const&) /firefox/js/src/vm/Interpreter.cpp:490:13
    #8 0x5911240b0445 in js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::MaybeConstruct, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:586:12
    #9 0x5911240c01ec in js::CallFromStack(JSContext*, JS::CallArgs const&, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:658:10
    #10 0x5911240c01ec in js::Interpret(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:3272:16
    #11 0x5911240afaba in js::RunScript(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:460:13
    #12 0x5911240b2920 in js::ExecuteKernel(JSContext*, JS::Handle<JSScript*>, JS::Handle<JSObject*>, js::AbstractFramePtr, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/Interpreter.cpp:850:10
    #13 0x5911240b2d7e in js::Execute(JSContext*, JS::Handle<JSScript*>, JS::Handle<JSObject*>, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/Interpreter.cpp:880:10
    #14 0x59112436c0b1 in ExecuteScript(JSContext*, JS::Handle<JSObject*>, JS::Handle<JSScript*>, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/CompilationAndEvaluation.cpp:548:10
    #15 0x59112436c293 in JS_ExecuteScript(JSContext*, JS::Handle<JSScript*>) /firefox/js/src/vm/CompilationAndEvaluation.cpp:572:10
    #16 0x591122cb48ad in RunFile(JSContext*, char const*, _IO_FILE*, CompileUtf8, bool, bool) /firefox/js/src/shell/js.cpp:1388:10
    #17 0x591122cb3d99 in Process(JSContext*, char const*, bool, FileKind) /firefox/js/src/shell/js.cpp
    #18 0x591122c6f8bc in ProcessArgs(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12130:10
    #19 0x591122c6f8bc in Shell(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12383:12
    #20 0x591122c66780 in main /firefox/js/src/shell/js.cpp:12789:12
    #21 0x7e74112f91c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9)
    #22 0x7e74112f928a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a)
    #23 0x591122c2df58 in _start (/firefox/obj-js-debug/dist/bin/js+0x2685f58)

SUMMARY: UndefinedBehaviorSanitizer: SEGV /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:235:3 in MOZ_CrashSequence(void*, long)
==1085==ABORTING
Group: core-security → javascript-core-security
Attached patch fix.patchSplinter Review
Attached file crash_stack.txt
Keywords: sec-lowsec-other
Severity: -- → S3
Priority: -- → P3

This is another bug in the Temporal spec.

Group: javascript-core-security
Status: UNCONFIRMED → NEW
Ever confirmed: true
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: