Date offset cache in DateTimeInfo::getOrComputeValue returns wrong offset for zones with two transitions within 30 days, causing Ion vs interpreter divergence in Date.parse
Categories
(Core :: JavaScript Engine, defect, P3)
Tracking
()
People
(Reporter: bugmon, Unassigned)
Details
(Keywords: testcase)
Attachments
(4 files)
js::DateTimeInfo::getOrComputeValue extends its cached offset range by 30 days after sampling the offset only at the far end of the extended range, and returns the cached offset for the requested time without computing it. That is wrong when a time zone has two offset transitions inside the 30-day window: in Europe/Vienna, after a query for 1945-04-20 (+01:00), a query for 1945-04-05 returns +01:00 instead of +02:00, so Date.parse("1945-04-05T12:00:00") is one hour off. Because the result depends on earlier queries, and Ion removes an unused Date.parse call that the interpreter and Baseline execute, the tiers return different values for the same program.
A web page running where the effective time zone is Europe/Vienna can obtain Date results that are one hour wrong and that differ between Ion and the lower tiers by making a Date query for 1945-04-20 before one for 1945-04-05. The demonstrated harm is an incorrect, history-dependent Date value; getHours showed the same error in a separate run. No crash, assertion failure or memory-safety effect was observed. The page cannot choose the time zone itself, so only users or realms already configured with an affected zone are exposed.
Build Info
- Branch: main
- Revision: bf9757d91e2e53b8ab836f2a08bf4c0275fdcd23
- Timestamp: 2026-10-02T21:18:24+00:00
Affected Code
File: js/src/vm/DateTime.cpp, line 390-397
int64_t newStartSeconds =
std::max<int64_t>({range.startSeconds - RangeExpansionAmount, MinTimeT});
if (newStartSeconds <= seconds) {
int32_t startOffsetMilliseconds = (this->*compute)(newStartSeconds);
if (startOffsetMilliseconds == range.offsetMilliseconds) {
range.startSeconds = newStartSeconds;
return range.offsetMilliseconds;
}
File: js/src/vm/DateTime.h, line 84-89
* The basic idea is premised upon this fact: the DST offset never changes more
* than once in any thirty-day period. If we know the offset at t_0 is o_0,
* the offset at [t_1, t_2] is also o_0, where t_1 + 3_0 days == t_2,
* t_1 <= t_0, and t0 <= t2. (In other words, t_0 is always somewhere within a
* thirty-day range where the DST offset is constant: DST changes never occur
* more than once in any thirty-day period.)
File: js/src/jit/MIROps.yaml, line 4631-4638
- name: LocalTimeToUTC
operands:
localTime: Int64
result_type: Double
type_policy: none
# Neither movable nor implements congruentTo, see DateParse.
alias_set: none
possibly_calls: true
The cache's stated premise is that an offset changes at most once in any 30-day period (js/src/vm/DateTime.h:84-89, RangeExpansionAmount at line 403). In the backward-extension branch below, a prior query for 1945-04-20 leaves the range at [Apr 20, Apr 20] with offset +01:00. The query for 1945-04-05 computes the offset only at Apr 20 minus 30 days (Mar 21, +01:00), finds it equal to the cached offset, and returns +01:00 without computing the offset for Apr 5. Europe/Vienna was at +02:00 between those two dates (Temporal reports +01:00 for Mar 21 and Apr 20, +02:00 for Apr 5). The forward-extension branch at lines 365-373 has the same shape. Both the default-zone instance and per-realm overrides go through this function (js/src/vm/DateTime.h:218-226, js/src/vm/DateTime.cpp:458-466).
The tiers diverge because MDateParse and MLocalTimeToUTC are declared alias_set: none, so they are not effectful and DeadIfUnused (js/src/jit/IonAnalysis.cpp:311-315) lets Ion discard the call whose result is unused. The interpreter and Baseline always make the call and so populate the cache. A control run that keeps the first result alive makes Ion return the same wrong value as the interpreter. The shell has no flag that disables Ion's dead-code elimination, so the pass was identified by that control and by --no-ion (which makes the JIT side fail like the interpreter) rather than by a pass switch.
Exploit Chain
- The realm's time zone is Europe/Vienna. The testcase pins it with
newGlobal({timeZone: "Europe/Vienna"}); a direct run with the process time zone set to Europe/Vienna and nonewGlobalshowed the same two values. - Script calls
Date.parse("1945-04-20T12:00:00")and discards the result. In the interpreter and Baseline this caches the range [Apr 20, Apr 20] with offset +01:00. - Script calls
Date.parse("1945-04-05T12:00:00").getOrComputeValuesamples only 1945-03-21 (+01:00), extends the cached range back to it and returns +01:00, although the offset on Apr 5 is +02:00. The result is -780757200000 instead of -780760800000. - Once the function is Ion-compiled, the unused first call is removed as dead code, the cache is not populated, and the second call returns the correct -780760800000. The same program therefore yields different values depending on the tier.
Steps to Reproduce
- Build the SpiderMonkey debug shell from the unmodified tree with
/app/mozconfigs/mozconfig.js.linux.debug(output/firefox/obj-js-debug/dist/bin/js). No patches, prefs or environment variables are needed. - Run
testcase.jsthroughjs_shell_differential_evaluatorwith the same binary asshell_aandshell_b,flags_a = ["--no-jit-backend"],flags_b = ["--no-threads", "--fast-warmup"]. Result:differs: true; shell A exits 3 withError: got -780757200000, shell B exits 0. - Repeat with
--differential-testingadded to both flag lists. Result:differs: truewith the same exit codes. - Optional tier bisect:
["--differential-testing", "--no-threads", "--fast-warmup", "--no-ion"]against["--differential-testing", "--no-threads", "--fast-warmup"]also givesdiffers: true, with the--no-ionside failing. - Optional control: change the first line of
ftovar b = Date.parse("1945-04-20T12:00:00");and return[c, d, e, b]. Both configurations then exit 3 (differs: false).
Security Impact
- Severity: Low
- Attacker capability: Script observes Date values that are one hour wrong and that depend on earlier Date queries and on the JIT tier. Only incorrect computation was demonstrated; no crash, assertion failure or out-of-bounds access was observed in the debug shell.
- Preconditions: The realm's effective time zone must have two offset transitions within 30 days of each other (demonstrated with Europe/Vienna, April 1945). Web content cannot set the time zone itself; it comes from the system setting or a realm time zone override.
ASAN Report
No sanitizer report: this is a js_differential finding and neither shell crashed. Verifier's runs on /firefox/obj-js-debug/dist/bin/js (clean tree):
=== Run 1 (without --differential-testing): differs = true ===
shell_a argv: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe --no-jit-backend /tmp/fx_audit_jsdiff_q1twcwoz/testcase.js
shell_a exit_code: 3 (succeeded: false, timed_out: false)
shell_a stderr:
/tmp/fx_audit_jsdiff_q1twcwoz/testcase.js:14:35 Error: got -780757200000
Stack:
@/tmp/fx_audit_jsdiff_q1twcwoz/testcase.js:14:35
shell_b argv: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe --no-threads --fast-warmup /tmp/fx_audit_jsdiff_q1twcwoz/testcase.js
shell_b exit_code: 0 (succeeded: true, timed_out: false)
=== Run 2 (with --differential-testing): differs = true ===
shell_a argv: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe --differential-testing --no-jit-backend /tmp/fx_audit_jsdiff_543c4s01/testcase.js
shell_a exit_code: 3 (succeeded: false, timed_out: false)
shell_a stderr:
/tmp/fx_audit_jsdiff_543c4s01/testcase.js:14:35 Error: got -780757200000
Stack:
@/tmp/fx_audit_jsdiff_543c4s01/testcase.js:14:1
shell_b argv: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe --differential-testing --no-threads --fast-warmup /tmp/fx_audit_jsdiff_543c4s01/testcase.js
shell_b exit_code: 0 (succeeded: true, timed_out: false)
=== Tier bisect: differs = true ===
shell_a argv: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe --differential-testing --no-threads --fast-warmup --no-ion /tmp/fx_audit_jsdiff_e341emtu/testcase.js
shell_a exit_code: 3, stderr: Error: got -780757200000
shell_b argv: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe --differential-testing --no-threads --fast-warmup /tmp/fx_audit_jsdiff_e341emtu/testcase.js
shell_b exit_code: 0
=== Control (first Date.parse result kept alive): differs = false ===
[--fuzzing-safe --differential-testing --no-jit-backend] exit 3, Error: got -780757200000
[--fuzzing-safe --differential-testing --no-threads --fast-warmup] exit 3, Error: got -780757200000
=== Oracle run (js_shell_evaluator, --fuzzing-safe --no-jit-backend) stdout ===
temporal: -780760800000 +02:00
temporal Apr20: +01:00
temporal Mar21: +01:00
fresh realm: -780760800000
after Apr20 query: -780757200000
getters fresh: 12
getters after Apr20: 11
default tz offset 1945: 0
=== Scope check, run directly (not via the evaluator) with TZ=Europe/Vienna, no newGlobal, --fuzzing-safe --differential-testing ===
[--no-jit-backend] prints -780757200000
[--no-threads --fast-warmup] prints -780760800000
| Reporter | ||
Comment 1•1 month ago
|
||
| Reporter | ||
Comment 2•1 month ago
|
||
| Reporter | ||
Comment 3•1 month ago
|
||
| Reporter | ||
Comment 4•1 month ago
|
||
| Reporter | ||
Comment 5•1 month ago
|
||
This bug was filed automatically by an AI-powered security testing tool. The analysis, testcase, and any suggested patches were generated by an AI model and have NOT been verified by a human.
Please review all details carefully before triaging. The issue was confirmed by an independent verifier, but for non-ASAN issues, the verifier has been made less strict. Additionally, the root cause analysis and suggested fix may contain errors.
Updated•1 month ago
|
Updated•1 month ago
|
Updated•1 month ago
|
Description
•