Atomics.waitAsync timeout of 2^32 ms or more is truncated to uint32_t and fires early
Categories
(Core :: JavaScript: Standard Library, defect, P3)
Tracking
()
People
(Reporter: bugmon, Unassigned)
Details
(Keywords: testcase)
Attachments
(3 files)
|
3.12 KB,
patch
|
Details | Diff | Splinter Review | |
|
6.34 KB,
text/plain
|
Details | |
|
505 bytes,
text/plain
|
Details |
AtomicsWaitAsyncCriticalSection in js/src/builtin/AtomicsObject.cpp passes the double returned by timeout.value().ToMilliseconds() to OffThreadPromiseRuntimeState::delayedDispatchToEventLoop, whose delay parameter is uint32_t, with no range check or clamp. A timeout of 2^32 ms or more is therefore implicitly narrowed; on the tested x86-64 debug build it wraps modulo 2^32, so 4294967296 ms becomes a 0 ms delay and the waitAsync promise resolves with "timed-out" on the next event-loop turn, ahead of an earlier waiter with a 4294967295 ms timeout.
A script that calls Atomics.waitAsync can make the returned promise resolve with "timed-out" long before the requested timeout has elapsed by passing a finite timeout of 2^32 ms (about 49.7 days) or more. The demonstrated effect is a wrong result only: the SpiderMonkey shell throws where V8 completes, with and without --differential-testing. No crash, assertion failure or memory-safety effect was observed, and the behaviour was reproduced in the JS shell only, not in the browser.
Build Info
- Branch: main
- Revision: b4b9efb6ee130740a94caf46ffff0625af1d51db
- Timestamp: 2026-10-04T19:31:16+00:00
Affected Code
File: js/src/builtin/AtomicsObject.cpp, line 1306-1314
// Step 9.
if (!std::isnan(timeout_ms)) {
if (timeout_ms < 0) {
timeout = mozilla::Some(mozilla::TimeDuration::FromSeconds(0.0));
} else if (!std::isinf(timeout_ms)) {
timeout =
mozilla::Some(mozilla::TimeDuration::FromMilliseconds(timeout_ms));
}
}
File: js/src/builtin/AtomicsObject.cpp, line 1178-1188
if (timeoutTask) {
OffThreadPromiseRuntimeState& state =
cx->runtime()->offThreadPromiseState.ref();
// We are not tracking the dispatch of the timeout task using the
// OffThreadPromiseRuntimeState, so we ignore the return value. If this
// fails, the embeddings should call transferToRuntime on timeoutTask
// which will clear itself, and set the notify task to be cleaned on
// shutdown.
(void)state.delayedDispatchToEventLoop(std::move(timeoutTask),
timeout.value().ToMilliseconds());
}
File: js/src/vm/OffThreadPromiseRuntimeState.cpp, line 289-293
bool OffThreadPromiseRuntimeState::delayedDispatchToEventLoop(
js::UniquePtr<JS::Dispatchable>&& dispatchable, uint32_t delay) {
return delayedDispatchToEventLoopCallback_(dispatchToEventLoopClosure_,
std::move(dispatchable), delay);
}
File: js/public/Promise.h, line 734-735
typedef bool (*DelayedDispatchToEventLoopCallback)(
void* closure, js::UniquePtr<Dispatchable>&& dispatchable, uint32_t delay);
File: js/src/vm/OffThreadPromiseRuntimeState.cpp, line 365-383
bool OffThreadPromiseRuntimeState::internalDelayedDispatchToEventLoop(
void* closure, js::UniquePtr<JS::Dispatchable>&& d, uint32_t delay) {
...
mozilla::TimeStamp endTime = mozilla::TimeStamp::Now() +
mozilla::TimeDuration::FromMilliseconds(delay);
The timeout is kept as a mozilla::TimeDuration built from the unclamped double argument (finite, non-negative values pass through unchanged). When the timeout task is dispatched, the duration is converted back to a double number of milliseconds and passed to a function whose parameter is uint32_t, so the conversion is an implicit, unchecked double-to-uint32_t narrowing. The embedding callback type also takes uint32_t, and both Gecko callbacks (main thread in dom/base/nsJSEnvironment.cpp, workers in dom/workers/RuntimeService.cpp) receive the already-narrowed value. Controls run in the debug shell showed the wrap is modulo 2^32: timeouts of 2^32+200 and 2^33+200 ms fired after a 600 ms spin but 2^32+200 did not fire after a 50 ms spin, and 2^32-1 and 3000 ms did not fire after 600 ms.
Exploit Chain
- Script creates an Int32Array over a SharedArrayBuffer and calls Atomics.waitAsync(ia, 0, 0, 4294967295); the engine queues a timeout task with a delay of 4294967295 ms.
- Script calls Atomics.waitAsync(ia, 0, 0, 4294967296); AtomicsWaitAsyncCriticalSection passes 4294967296.0 to delayedDispatchToEventLoop(uint32_t delay), which receives 0 on the tested x86-64 build.
- The second timeout task is due immediately, so when the shell drains its queues at script end the second promise resolves with "timed-out" while the first, shorter-timeout waiter is still pending.
- The testcase's reaction detects that the longer-timeout waiter settled first and throws; SpiderMonkey exits 3 while V8 d8, which does not settle either waiter, exits 0.
Steps to Reproduce
- Use an unmodified tree and build the SpiderMonkey debug shell with /app/mozconfigs/mozconfig.js.linux.debug (build_dir /firefox/obj-js-debug). No source patches, prefs or extra flags are needed.
- Run waitasync_timeout_truncation.js through js_shell_differential_evaluator with shell_a=/firefox/obj-js-debug/dist/bin/js (engine spidermonkey) and shell_b=/app/tools/d8/d8 (engine v8). Result: differs=true, shell_a exit_code 3, shell_b exit_code 0.
- Repeat with flags_a=["--differential-testing"]. Result: differs=true, shell_a exit_code 3, shell_b exit_code 0.
- Optional control: the same testcase with --no-jit-backend on the SpiderMonkey side also exits 3, so the divergence is not JIT-tier dependent.
Security Impact
- Severity: Low
- Attacker capability: A script can cause an Atomics.waitAsync promise to resolve with "timed-out" earlier than the requested timeout (immediately for exactly 2^32 ms; after timeout mod 2^32 ms on the tested build). This is a specification-conformance error in timer behaviour. No memory corruption, assertion failure, information disclosure or privilege change was demonstrated.
- Preconditions: The script must pass a finite timeout of at least 2^32 ms to Atomics.waitAsync on a shared-memory typed array. Atomics.waitAsync is enabled by default (javascript.options.atomics_wait_async is true in modules/libpref/init/StaticPrefList.yaml). Reproduced in the JS shell on x86-64 only; browser behaviour was not tested.
ASAN Report
No sanitizer report: this is a js_differential finding (exit-status divergence), reproduced by the verifier.
Run 1 (without --differential-testing): differs = true
shell_a argv: ["/firefox/obj-js-debug/dist/bin/js","--fuzzing-safe","/tmp/fx_audit_jsdiff_smxokwa4/testcase.js"]
shell_a engine: spidermonkey, exit_code: 3, timed_out: false, succeeded: false
shell_a stderr:
Unhandled rejection: (new Error("waiter with the longer timeout settled first: timed-out", "/tmp/fx_audit_jsdiff_smxokwa4/testcase.js", 13))
Stack:
@/tmp/fx_audit_jsdiff_smxokwa4/testcase.js:13:11
shell_b argv: ["/app/tools/d8/d8","/tmp/fx_audit_jsdiff_smxokwa4/testcase.js"]
shell_b engine: v8, exit_code: 0, timed_out: false, succeeded: true (stdout and stderr empty)
Run 2 (with --differential-testing on the SpiderMonkey side): differs = true
shell_a argv: ["/firefox/obj-js-debug/dist/bin/js","--fuzzing-safe","--differential-testing","/tmp/fx_audit_jsdiff_2gye58q9/testcase.js"]
shell_a engine: spidermonkey, exit_code: 3, timed_out: false, succeeded: false
shell_a stderr:
Unhandled rejection: (new Error("waiter with the longer timeout settled first: timed-out", "/tmp/fx_audit_jsdiff_2gye58q9/testcase.js", 13))
Stack:
@/tmp/fx_audit_jsdiff_2gye58q9/testcase.js:13:1
shell_b argv: ["/app/tools/d8/d8","/tmp/fx_audit_jsdiff_2gye58q9/testcase.js"]
shell_b engine: v8, exit_code: 0, timed_out: false, succeeded: true (stdout and stderr empty)
Direct repeat runs (8x, SpiderMonkey with --fuzzing-safe --differential-testing / d8): exit codes 3/0 every time.
| Reporter | ||
Comment 1•1 month ago
|
||
| Reporter | ||
Comment 2•1 month ago
|
||
| Reporter | ||
Comment 3•1 month ago
|
||
| Reporter | ||
Comment 4•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
•