The V8 JavaScript Runtime Undermined My Constant-Time JavaScript Library - Dhole Moments
Pangram verdict · v3.3
We believe that this entire text is human-written.
AI likelihood · overall
HumanArticle text · 1,359 words · 1 segments analyzed
Six years ago, I wrote a blog post titled Soatok’s Guide to Side-Channel Attacks in which I discussed the general topic of side-channels in cryptographic applications, and how to avoid them, with example code in PHP. I had called out that the algorithms discussed on the page cannot rule out compiler or runtime optimizations that undermine your security goals: You can achieve algorithmic constant-time, but any higher assurance was outside the scope of the work being done. For that reason, we’re going to assume that algorithmic constant-time is adequate for the duration of this blog post. If your threat model prevents you from accepting this assumption, feel free to put in the extra effort yourself and tell me how it goes. After all, as a furry who writes blog posts in my spare time for fun, I don’t exactly have the budget for massive research projects in formal verification. Soatok’s Guide to Side-Channel Attacks (Aug. 2020) To make it easier to see in action, I also wrote a separate TypeScript / JavaScript library called constant-time-js for demonstration purposes. Over the years, this demo code has been adopted by precisely zero dependent packages, according to NPM (at least, as of this writing). My warnings to not rely on this in production worked!Art: AJ_LovesDinos However, that only covers open source dependencies; I have no idea if some proprietary software decided to build on my designs. While at a furry convention last month, I received an email from a Ph.D student named Yayu Wang which disclosed a side-channel in constant-time-js. I have since released version 0.5.0 of the library, which fixes the issue reported, and used GitHub’s security advisories feature to request a CVE. The full credits are as follows: Vulnerability discovered and reported by Yayu Wang. Research done by Yayu Wang, Kjell Dankert, Duy Kha Dinh, and Aastha Mehta, University of British Columbia. My reason for posting an advisory and requesting a CVE is simple: If anyone is actually using this code (e.g., in a proprietary software system I have no visibility into), then either npm audit or a CVE being assigned is likely to trigger internal security mechanisms and prompt them to upgrade to the latest version as soon as possible. Since the urgent stuff (advisory, patch, etc.) has already been handled elsewhere, I thought I’d write a blog post that dives deeper into the more interesting parts of this finding and its remediation. Because, let’s be real, if you read my blog you’re either a nerd or part of a nerdy subculture, so why not nerd out a bit? Contents Timeline Verifying The Report Writing The Patch Verifying The Fix What Would Higher Assurance Look Like? Timeline 2026-08-21: Disclosure email received at 4:51 PM. I reply only to Yayu at 4:54 PM to say “Hey, I [got] your email. I’m currently traveling and will not be at a keyboard until Tuesday. I’ll follow up as soon as I can.” 2026-08-25: I verify the report and then write a patch. 2026-08-26: I reply-all to the disclosure email with a locally-tested proposed patch. 2026-09-09: Yayu responds that they tested the patch and confirmed it removed the leakage. 2026-09-10: Public disclosure and new version tagged. 2026-09-11: This blog post is written. Though I didn’t post it until a little after 3 AM. Oops. Verifying The Report In the age of AI-driven vulnerability hunting–punctuated by severity inflation and frequent hallucinations–the proportion of bogus reports to legitimate ones has increased significantly. Verifying that the bug actually exists and is as severe as the researcher claims has always been important for maintainers, but it invites much more emphasis when you’re drowning in slop. The initial report email was shared in the pull request description for the fix, if you want to read it in full. The relevant excerpt from the description tells us enough to figure out where to start looking: The conditional-selection functions in constant-time-js use branchless JavaScript expressions to construct selection masks. We found that select, select_alt, and select_ints nevertheless produce secret-dependent instruction- and data-cache behavior in V8. The leakage arises from V8’s ToBoolean mechanism, its different handling of -0 and -1, and direct accesses to the raw true and false Oddball objects on different cache lines. The resulting cache-access patterns can reveal the value of the selection condition. Yayu’s disclosure email So, obviously, the first thing to check is V8’s ToBoolean mechanism. Which looks like this: // ToString // // Convert the accumulator to a String. IGNITION_HANDLER(ToBoolean, InterpreterAssembler) { TNode<Object> value = GetAccumulator(); TVARIABLE(Boolean, result); Label if_true(this), if_false(this), end(this); BranchIfToBooleanIsTrue(value, &if_true, &if_false); BIND(&if_true); { result = TrueConstant(); Goto(&end); } BIND(&if_false); { result = FalseConstant(); Goto(&end); } BIND(&end); SetAccumulator(result.value()); Dispatch(); } Oh, hey, BranchIfToBooleanIsTrue(). That name sure sounds like a branching side-channel would be exposed. And, indeed, that is what we observe. Next, we need to look at converting from booleans. As Yayu observes: For select, we observed that the !!returnLeft conversion reaches V8’s Builtins_ToBoolean. The true and false cases execute instructions at different addresses within the builtin. We separately used hardware read watchpoints to measure the data addresses accessed while V8 converts the boolean condition. This confirmed direct reads from the raw true and false Oddball objects in V8’s read-only heap: conditionobject baseto_number fieldcontaining 64-byte linetrue0x...0c80x...0cc0x...0c0false0x...0ac0x...0b00x...080 The conversion executes loads of the following form, with rdi pointing to the condition’s Oddball object: movl rdx, [rdi - 0x1] // load the Oddball's map vmovsd xmm0, [rdi + 0x3] // load the Oddball's to_number field Thus, the same load instruction accesses 0x...0cc for true and 0x...0b0 for false. The instruction address can be identical while its data operand reveals the condition through two different cache lines. This means that an executed-instruction trace alone can incorrectly classify the operation as constant-time; both instruction and data addresses must be considered. This is observable from the source code, as the values for true and false are statically allocated. static constexpr Tagged_t kUndefinedValue = 0x69; static constexpr Tagged_t kNullValue = 0x85; static constexpr Tagged_t kempty_string = 0xa1; static constexpr Tagged_t kFalseValue = 0xad; static constexpr Tagged_t kTrueValue = 0xc9; This lines up close to what was reported in the disclosure, except 0xc9 is one off from the 0x...0c8 from the table, and 0xad is also one off from 0x...0ac. I don’t actually know why there’s a discrepancy but it’s probably something harmless (like byte-alignment or a compiler optimization).Art: CMYKat We can further inspect the OddBall object… @cppObjectLayoutDefinition @apiExposedInstanceTypeValue(0x83) @highestInstanceTypeWithinParentClassRange extern class Oddball extends PrimitiveHeapObject { to_number_raw: float64; to_string: String; to_number: Number; type_of: String; kind: Smi; } …and the True/False subclass definitions. @cppObjectLayoutDefinition @hasSameInstanceTypeAsParent @doNotGenerateCast extern class Boolean extends Oddball {} @cppObjectLayoutDefinition @hasSameInstanceTypeAsParent @doNotGenerateCast extern class True extends Boolean {} @cppObjectLayoutDefinition @hasSameInstanceTypeAsParent @doNotGenerateCast extern class False extends Boolean {} This tells me that, internally, both true and false inherit from OddBall, so any of the branches that only trigger for OddBalls is relevant. This isn’t currently relevant, but is worth keeping in mind in the subsequent code snippets. Altogether, this confirms the boolean type aspects of the report. Observation: Avoiding booleans is the only way to be safe. Next, we need to look at the double-negation (!!) behavior and how V8 handles signed zero, because -0 is a special value in IEEE 754 that JavaScript runtimes like V8 support, and how they support it could lead to timing leaks (as reported by Yayu). Observe from the AccessBuilder class that HeapNumber::value_ and OddBall::to_number_raw_ are equivalent. // static FieldAccess AccessBuilder::ForHeapNumberOrOddballOrHoleValue() { STATIC_ASSERT_FIELD_OFFSETS_EQUAL(offsetof(HeapNumber, value_), offsetof(Oddball, to_number_raw_)); STATIC_ASSERT_FIELD_OFFSETS_EQUAL(offsetof(HeapNumber, value_), Hole::kRawNumericValueOffset); return ForHeapNumberValue(); } Let’s also look at unary operators, with a focus on OddBalls, to tie into the earlier analysis. TNode<Object> UnaryOpWithFeedback(TNode<Context> context, TNode<Object> value, TNode<UintPtrT> slot, TNode<HeapObject> maybe_feedback_vector, const SmiOperation& smi_op, const FloatOperation& float_op, const BigIntOperation& bigint_op, UpdateFeedbackMode update_feedback_mode) { TVARIABLE(Object, var_value, value); TVARIABLE(Object, var_result); TVARIABLE(Float64T, var_float_value); TVARIABLE(Smi, var_feedback, SmiConstant(BinaryOperationFeedback::kNone)); TVARIABLE(Object, var_exception); Label start(this, {&var_value, &var_feedback}), end(this); Label do_float_op(this, &var_float_value); Label if_exception(this, Label::kDeferred); Goto(&start); // We might have to try again after ToNumeric conversion. BIND(&start); { Label if_smi(this), if_heapnumber(this), if_oddball(this); Label if_bigint(this, Label::kDeferred); Label if_other(this, Label::kDeferred); value = var_value.value(); GotoIf(TaggedIsSmi(value), &if_smi); TNode<HeapObject> value_heap_object = CAST(value); TNode<Map> map = LoadMap(value_heap_object); GotoIf(IsHeapNumberMap(map), &if_heapnumber); TNode<Uint16T> instance_type = LoadMapInstanceType(map); GotoIf(IsBigIntInstanceType(instance_type), &if_bigint); Branch(InstanceTypeEqual(instance_type, ODDBALL_TYPE), &if_oddball, &if_other); BIND(&if_smi);