Open Bug 2078865 Opened 1 month ago Updated 1 month ago

Atomics.waitAsync timeout of 2^32 ms or more is truncated to uint32_t and fires early

Categories

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

defect

Tracking

()

UNCONFIRMED

People

(Reporter: bugmon, Unassigned)

Details

(Keywords: testcase)

Attachments

(3 files)

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

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

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. 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.
  2. 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.
  3. Repeat with flags_a=["--differential-testing"]. Result: differs=true, shell_a exit_code 3, shell_b exit_code 0.
  4. 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.
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —

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.

Group: core-security → javascript-core-security
Blocks: sm-security
Severity: -- → S3
Priority: -- → P3
No longer blocks: sm-security
Group: javascript-core-security
Keywords: sec-other
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: