# Assertion failure: std::abs(months) < CalendarMonthsPerYear(calendarId) in DifferenceNonISODateWithLeapMonth with Chinese calendar M12L leap month
Categories
(Core :: JavaScript Engine, defect, P1)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | wontfix |
| firefox149 | --- | wontfix |
| firefox150 | --- | wontfix |
| firefox151 | --- | fixed |
People
(Reporter: bugmon, Assigned: anba)
References
(Blocks 1 open bug)
Details
(4 keywords, Whiteboard: [adv-main151-])
Attachments
(4 files, 1 external file)
Assertion failure: std::abs(months) < CalendarMonthsPerYear(calendarId) in DifferenceNonISODateWithLeapMonth with Chinese calendar M12L leap month
A JS-reachable assertion failure occurs in DifferenceNonISODateWithLeapMonth() when computing Temporal.PlainDate.prototype.until() between two Chinese calendar dates with largestUnit: "year". The bug requires the source date to fall in a leap month (specifically M12L) that does not exist in an intermediate year reached by the year-counting heuristic, while the target year is itself a leap year with 13 months.
The algorithm at Calendar.cpp:3828-3871 first estimates years and applies up to two years -= sign corrections when intermediate dates surpass the target. However, when M12L is constrained to M12 in a non-leap intermediate year (4723), the anchor lands on the last ordinal month (12 of 12). The subsequent month-counting loop must then traverse the entire following year (4724), which has 13 months due to its own leap month (M09L). When the day values match exactly (29 == 29), the loop reaches months = 13 with intermediateDate == twoDate; since CompareSurpasses uses strict inequality (> 0), equality does not break the loop, and the assertion |months| < 13 fires.
In release builds (no assertion), the function returns DateDuration{years: 2, months: 13, days: 0} which serializes as P2Y13M. This violates the Temporal API contract that until() with largestUnit: "year" balances excess months into the years component. The months value is stored as int64_t and is not used for any array indexing or memory addressing, so this is a correctness bug rather than memory corruption.
Build Info
- Branch: main
- Revision: fb4975f0b42423c8e47d3d72c7a1e5d2f17b5129
- Timestamp: 2026-04-01T12:45:07+00:00
Affected Code
File: js/src/builtin/temporal/Calendar.cpp, line 3828-3872
int32_t years = twoDate.year - oneDate.year;
// If |oneDate + years| surpasses |twoDate|, reduce |years| by one. The next
// step will balance the intermediate result.
auto unconstrainedDate =
CalendarDate{oneDate.year + years, oneDate.monthCode, oneDate.day};
if (CompareSurpasses(sign, unconstrainedDate, twoDate)) {
years -= sign; // [BUG] Reduces years from 3 to 2 because {4724, M12L, 29} > {4724, M12, 29}
}
auto constrainedStartOfMonth =
CreateDateFromCodes(cx, calendarId, cal.get(), oneDate.year + years,
oneDate.monthCode, 1, TemporalOverflow::Constrain);
// [BUG] Year 4723 has no M12L; constrains to M12 = ordinal 12 of 12 (last month)
...
auto constrainedDate = CalendarDate{
.year = constrainedDateStartOfMonth.year,
.monthCode = constrainedDateStartOfMonth.monthCode,
.day = oneDate.day,
};
if (CompareSurpasses(sign, constrainedDate, twoDate)) {
years -= sign; // {4723, M12, 29} does NOT surpass {4724, M12, 29}; no correction
}
// [BUG] No mechanism exists to *increase* years when constrain moves anchor backward
// Add as many months as possible without surpassing |twoDate|.
int32_t months = 0;
while (true) {
CalendarDate intermediateDate;
if (!AddYearMonthDuration(cx, calendarId, cal.get(), oneDate,
{years, months + sign},
TemporalOverflow::Constrain, &intermediateDate)) {
return false;
}
if (CompareSurpasses(sign, intermediateDate, twoDate)) {
break; // [BUG] At months+1=13: {4724, M12, 29} EQUALS twoDate; equality returns false
}
months += sign; // months reaches 13
constrainedDate = intermediateDate;
}
MOZ_ASSERT(std::abs(months) < CalendarMonthsPerYear(calendarId)); // |13| < 13 FAILS
File: js/src/builtin/temporal/Calendar.cpp, line 3329-3332
static inline bool CompareSurpasses(int32_t sign, const CalendarDate& one,
const CalendarDate& two) {
return CompareCalendarDate(one, two) * sign > 0; // strict >, equality returns false
}
File: js/src/builtin/temporal/Calendar.cpp, line 3398-3439
auto [year, monthCode, day] = calendarDate; // {4721, M12L, 29}
// Add all duration years.
auto durationYear = mozilla::CheckedInt<int32_t>(year) + duration.years; // 4721 + 2 = 4723
...
year = durationYear.value();
// Regulate according to |overflow|.
auto firstDayOfMonth = CreateDateFromCodes(cx, calendarId, calendar, year,
monthCode, 1, overflow);
// M12L in 4723 (non-leap) constrains to M12; OrdinalMonth=12, MonthsInYear=12
...
if (months != 0) {
if (months > 0) {
while (true) {
int32_t month = OrdinalMonth(firstDayOfMonth.get()); // 12
int32_t monthsInYear = MonthsInYear(firstDayOfMonth.get()); // 12
if (month + months <= monthsInYear) { // 12 + 13 <= 12? No
break;
}
// We've crossed a year boundary.
year += 1; // 4724
months -= (monthsInYear - month + 1); // 13 - 1 = 12
firstDayOfMonth = CreateDateFrom(cx, calendarId, calendar, year, 1, 1,
TemporalOverflow::Constrain);
// 4724 has 13 months (M09L). Next: 1 + 12 <= 13? Yes. Break.
// Final ordinal = 1 + 12 = 13 = M12 (M09L pushed M12 to ordinal 13)
}
File: js/src/builtin/temporal/MonthCode.h, line 298-303
constexpr int32_t CalendarMonthsPerYear(CalendarId id) {
if (CalendarHasLeapMonths(id) || CalendarHasEpagomenalMonths(id)) {
return 13; // Chinese calendar returns 13
}
return 12;
}
The year-estimation phase (lines 3828-3855) can only decrement years via the two years -= sign corrections. When constraining a leap month code that doesn't exist in the intermediate year moves the anchor backward to the last month of a non-leap year, and the target year is a 13-month leap year, the month-counting loop (lines 3858-3871) must traverse all 13 months. The strict-inequality CompareSurpasses allows the loop counter to reach months = CalendarMonthsPerYear() when the intermediate date exactly equals the target date, violating the assertion. The algorithm needs either a years += sign correction when the constrain step undershoots, or a post-loop normalization that folds months >= monthsInYear back into years.
Exploit Chain
- Attacker-controlled JavaScript creates a Temporal.PlainDate in the Chinese (or dangi/hebrew) calendar where the source date falls in leap month M12L of a year where M12L exists (e.g., year 4721).
- Attacker creates a target PlainDate in a later year that itself has 13 months due to a different leap month, with monthCode M12 and the same day-of-month as the source (e.g., {4724, M12, 29}, where 4724 has leap M09L).
- Calling
source.until(target, {largestUnit: "year"})invokes DifferenceNonISODateWithLeapMonth. - The unconstrained check {4724, M12L, 29} > {4724, M12, 29} fires, reducing years from 3 to 2.
- Constraining M12L in non-leap year 4723 yields M12, the last ordinal month (12/12). The second surpass check {4723, M12, 29} vs {4724, M12, 29} does not fire (4723 < 4724).
- The month-counting loop calls AddYearMonthDuration({4721, M12L, 29}, {2, N}) for increasing N. Internally, M12L→M12 in 4723, then immediately crosses into 13-month year 4724.
- At N=13, the intermediate date {4724, M12, 29} exactly equals the target. CompareSurpasses (strict >) returns false; loop continues and sets months=13.
- Debug build: MOZ_ASSERT(|13| < 13) fails → controlled crash. Release build: returns DateDuration{2, 13, 0, 0}, serialized as P2Y13M, an unbalanced result violating the Temporal API contract for largestUnit: "year".
Steps to Reproduce
- Build SpiderMonkey JS shell with --enable-debug.
- Run: js -e 'Temporal.PlainDate.from({year: 4721, monthCode: "M12L", day: 29, calendar: "chinese"}).until(Temporal.PlainDate.from({year: 4724, monthCode: "M12", day: 29, calendar: "chinese"}), {largestUnit: "year"})'
- Observe assertion failure: std::abs(months) < CalendarMonthsPerYear(calendarId) at Calendar.cpp:3872.
- In release/ASAN builds without the assertion, observe the incorrect result P2Y13M (should be P3Y or equivalent balanced form).
Security Impact
- Severity: Low
- Attacker capability: An attacker can trigger a debug-only assertion failure (MOZ_ASSERT) from content JavaScript with no special privileges, causing a controlled abort in debug builds. In release builds, the attacker obtains an incorrect Temporal.Duration result (P2Y13M instead of a properly balanced ~P3Y) from PlainDate.prototype.until(). The months value (13) is stored in an int64_t field and is not used for memory addressing, array indexing, or buffer sizing — there is no memory corruption. The bug is reachable from web content via the standard Temporal API without any unusual configuration.
- Preconditions: Temporal API enabled (default in current builds). Source date must use a leap-month calendar (chinese, dangi, or hebrew) with a leap month code that constrains backward in an intermediate year (e.g., M12L in Chinese). Target year must be a 13-month leap year, target monthCode must equal the constrained source monthCode, and target day must equal source day to produce exact equality in the loop. No user interaction beyond running JS.
ASAN Report
[121242] Assertion failure: std::abs(months) < CalendarMonthsPerYear(calendarId), at /firefox/js/src/builtin/temporal/Calendar.cpp:3872
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==121242==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000000 (pc 0x5842db266a5f bp 0x7ffd06b38860 sp 0x7ffd06b38710 T121242)
==121242==The signal is caused by a WRITE memory access.
==121242==Hint: address points to the zero page.
#0 0x5842db266a5f in MOZ_CrashSequence(void*, long) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:235:3
#1 0x5842db266a5f in DifferenceNonISODateWithLeapMonth(JSContext*, js::temporal::CalendarId, js::temporal::ISODate const&, js::temporal::ISODate const&, js::temporal::TemporalUnit, js::temporal::DateDuration*) /firefox/js/src/builtin/temporal/Calendar.cpp:3872:3
#2 0x5842db266a5f in NonISODateUntil(JSContext*, js::temporal::CalendarId, js::temporal::ISODate const&, js::temporal::ISODate const&, js::temporal::TemporalUnit, js::temporal::DateDuration*) /firefox/js/src/builtin/temporal/Calendar.cpp:3989:14
#3 0x5842db266a5f in js::temporal::CalendarDateUntil(JSContext*, JS::Handle<js::temporal::CalendarValue>, js::temporal::ISODate const&, js::temporal::ISODate const&, js::temporal::TemporalUnit, js::temporal::DateDuration*) /firefox/js/src/builtin/temporal/Calendar.cpp:4021:10
#4 0x5842db28bcdf in DifferenceTemporalPlainDate(JSContext*, js::temporal::TemporalDifference, JS::CallArgs const&) /firefox/js/src/builtin/temporal/PlainDate.cpp:631:8
#5 0x5842db289345 in PlainDate_until(JSContext*, JS::CallArgs const&) /firefox/js/src/builtin/temporal/PlainDate.cpp:1459:10
#6 0x5842db289345 in bool JS::CallNonGenericMethod<&IsPlainDate(JS::Handle<JS::Value>), &PlainDate_until(JSContext*, JS::CallArgs const&)>(JSContext*, JS::CallArgs const&) /firefox/obj-js-debug/dist/include/js/CallNonGenericMethod.h:103:12
#7 0x5842db289345 in PlainDate_until(JSContext*, unsigned int, JS::Value*) /firefox/js/src/builtin/temporal/PlainDate.cpp:1468:10
#8 0x5842da9f3ef4 in CallJSNative(JSContext*, bool (*)(JSContext*, unsigned int, JS::Value*), js::CallReason, JS::CallArgs const&) /firefox/js/src/vm/Interpreter.cpp:490:13
#9 0x5842da9cf2a5 in js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::MaybeConstruct, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:586:12
#10 0x5842da9df04c in js::CallFromStack(JSContext*, JS::CallArgs const&, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:658:10
#11 0x5842da9df04c in js::Interpret(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:3272:16
#12 0x5842da9ce91a in js::RunScript(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:460:13
#13 0x5842da9d1780 in js::ExecuteKernel(JSContext*, JS::Handle<JSScript*>, JS::Handle<JSObject*>, js::AbstractFramePtr, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/Interpreter.cpp:850:10
#14 0x5842da9d1bde in js::Execute(JSContext*, JS::Handle<JSScript*>, JS::Handle<JSObject*>, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/Interpreter.cpp:880:10
#15 0x5842dac8b111 in ExecuteScript(JSContext*, JS::Handle<JSObject*>, JS::Handle<JSScript*>, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/CompilationAndEvaluation.cpp:548:10
#16 0x5842dac8b2f3 in JS_ExecuteScript(JSContext*, JS::Handle<JSScript*>) /firefox/js/src/vm/CompilationAndEvaluation.cpp:572:10
#17 0x5842d95d351d in RunFile(JSContext*, char const*, _IO_FILE*, CompileUtf8, bool, bool) /firefox/js/src/shell/js.cpp:1388:10
#18 0x5842d95d2a09 in Process(JSContext*, char const*, bool, FileKind) /firefox/js/src/shell/js.cpp
#19 0x5842d958e52c in ProcessArgs(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12130:10
#20 0x5842d958e52c in Shell(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12383:12
#21 0x5842d95853f0 in main /firefox/js/src/shell/js.cpp:12789:12
#22 0x7b26c39da1c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 8e9fd827446c24067541ac5390e6f527fb5947bb)
#23 0x7b26c39da28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 8e9fd827446c24067541ac5390e6f527fb5947bb)
#24 0x5842d954cbc8 in _start (/firefox/obj-js-debug/dist/bin/js+0x2690bc8) (BuildId: 462b280863c023beb7967d5cc845269c)
SUMMARY: UndefinedBehaviorSanitizer: SEGV /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:235:3 in MOZ_CrashSequence(void*, long)
==121242==ABORTING
| Reporter | ||
Comment 1•7 months ago
|
||
| Reporter | ||
Comment 2•7 months ago
|
||
| Reporter | ||
Comment 3•7 months ago
|
||
| Reporter | ||
Comment 4•7 months ago
|
||
Updated•7 months ago
|
| Assignee | ||
Comment 6•7 months ago
|
||
(In reply to Bugmon [:jkratzer for issues] from comment #0)
In release builds (no assertion), the function returns
DateDuration{years: 2, months: 13, days: 0}which serializes asP2Y13M. This violates the Temporal API contract thatuntil()withlargestUnit: "year"balances excess months into the years component.
This is wrong. The correct result per spec is P2Y13M, because NonISODateUntil doesn't balance excess months into years.
So this bug is just an incorrect assertion.
| Assignee | ||
Comment 7•7 months ago
|
||
The spec algorithm NonISODateUntil doesn't balance excess months into years,
which means the computed months can be equal to CalendarMonthsPerYear.
Updated•7 months ago
|
Updated•7 months ago
|
Comment 8•7 months ago
|
||
(In reply to André Bargull [:anba] from comment #6)
This is wrong. The correct result per spec is
P2Y13M, because NonISODateUntil doesn't balance excess months into years.So this bug is just an incorrect assertion.
Yes Clauditor sometimes exaggerates the security impact, especially for debug assertion failures. I'll see if we can have it focus more on the impact on release builds instead. Debug builds are a very recent addition so we're still tweaking it.
Thanks for helping out with these bugs!
Comment 9•7 months ago
|
||
Also: I'm CC'ing you on these bugs in case you find them interesting or worth fixing. No pressure to fix them or to fix them urgently, especially the sec-other ones.
| Assignee | ||
Comment 10•7 months ago
|
||
(In reply to Jan de Mooij [:jandem] from comment #8)
Yes Clauditor sometimes exaggerates the security impact, especially for debug assertion failures. I'll see if we can have it focus more on the impact on release builds instead. Debug builds are a very recent addition so we're still tweaking it.
I don't mind too much that Clauditor exaggerates a bit, I just think it's useful for the (human) reviewer to clarify if the actual patch is different from the auto-generated one.
(In reply to Jan de Mooij [:jandem] from comment #9)
Also: I'm CC'ing you on these bugs in case you find them interesting or worth fixing. No pressure to fix them or to fix them urgently, especially the sec-other ones.
Thanks, appreciated!
Updated•7 months ago
|
Comment 11•7 months ago
|
||
Comment 12•7 months ago
|
||
Updated•7 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
Updated•2 months ago
|
Description
•