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.
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?
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();
}
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:
condition
object base
to_number field
containing 64-byte line
true
0x...0c8
0x...0cc
0x...0c0
false
0x...0ac
0x...0b0
0x...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.
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).
@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).
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);
{
var_result =
smi_op(CAST(value), &var_feedback, &do_float_op, &var_float_value);
Goto(&end);
}
BIND(&if_heapnumber);
{
var_float_value = LoadHeapNumberValue(value_heap_object);
Goto(&do_float_op);
}
BIND(&if_bigint);
{
var_result = bigint_op(context, value_heap_object);
CombineFeedback(&var_feedback, BinaryOperationFeedback::kBigInt);
Goto(&end);
}
BIND(&if_oddball);
{
// We do not require an Or with earlier feedback here because once we
// convert the value to a number, we cannot reach this path. We can
// only reach this path on the first pass when the feedback is kNone.
CSA_DCHECK(this, SmiEqual(var_feedback.value(),
SmiConstant(BinaryOperationFeedback::kNone)));
OverwriteFeedback(&var_feedback,
BinaryOperationFeedback::kNumberOrOddball);
var_value = LoadOddballToNumber(CAST(value_heap_object));
Goto(&start);
}
BIND(&if_other);
{
// We do not require an Or with earlier feedback here because once we
// convert the value to a number, we cannot reach this path. We can
// only reach this path on the first pass when the feedback is kNone.
CSA_DCHECK(this, SmiEqual(var_feedback.value(),
SmiConstant(BinaryOperationFeedback::kNone)));
OverwriteFeedback(&var_feedback, BinaryOperationFeedback::kAny);
{
ScopedExceptionHandler handler(this, &if_exception, &var_exception);
var_value = CallBuiltin(Builtin::kNonNumberToNumeric, context,
value_heap_object);
}
Goto(&start);
}
}
BIND(&if_exception);
{
UpdateFeedback(var_feedback.value(), maybe_feedback_vector, slot,
update_feedback_mode);
CallRuntime(Runtime::kReThrow, context, var_exception.value());
Unreachable();
}
BIND(&do_float_op);
{
CombineFeedback(&var_feedback, BinaryOperationFeedback::kNumber);
var_result =
AllocateHeapNumberWithValue(float_op(var_float_value.value()));
Goto(&end);
}
BIND(&end);
UpdateFeedback(var_feedback.value(), maybe_feedback_vector, slot,
update_feedback_mode);
return var_result.value();
}
The first highlight is a branch short-circuit that runs if the feedback isn’t undefined. The second highlight explains that this feedback mechanism can only be undefined on the first pass. This is the sort of engine optimization that makes side-channel-resistant code difficult.
Finally, we must look at unary negation (affects the (-var) & 0xff pattern used in constant-time programming):
BIND(&if_min_smi) calls SmiToFloat64 and jumps to do_float_op. This, altogether, is not guaranteed to take the same amount of time as simply returning a constant.
Without having to compile any code or figure out how to instrument any JIT behaviors, we can be sure that this code is designed to have side-channels.
This confirms the entirety of Yayu’s report. Now that we know where the problems lie, let’s figure out how to fix them.
Do not use the boolean type at all. That includes double-negation (!!var) or equality operators (===, !==).
Do not negate-and-mask, due to how -0 (signed zero) is handled in V8.
For example, the final step of my is_nonzero function looked like this:
/**
* Is this number anything except zero?
*
* @param {Uint8Array} num
* @returns {boolean}
*/
export function is_nonzero(num: Uint8Array): boolean {
let d: number = 0;
for (let i: number = num.length - 1; i >= 0; i--) {
d |= num[i];
}
return d !== 0;
}
To avoid the boolean type, we can use addition and bit-shifts. However, this changes the contract for is_nonzero(), since it now returns a number. To minimize compatibility woes, we can write an internal function (called is_nonzero_flag() since it returns a bit flag as a number rather than a boolean value) and keep the existing boolean is_nonzero(), which now just calls the new function and compares its value to 1.
@@ -95,11 +95,25 @@ export function gcd(a: Uint8Array, b: Uint8Array): Uint8Array {
* @returns {boolean}
*/
export function is_nonzero(num: Uint8Array): boolean {
+ return is_nonzero_flag(num) === 1;
+}
+
+/**
+ * Return 1 if this number is nonzero, or 0 otherwise.
+ *
+ * This numeric form is used as input to the selection functions so that a
+ * secret-derived boolean is never passed through V8's ToBoolean machinery.
+ *
+ * @param {Uint8Array} num
+ * @returns {number}
+ */
+function is_nonzero_flag(num: Uint8Array): number {
let d: number = 0;
for (let i: number = num.length - 1; i >= 0; i--) {
d |= num[i];
}
- return d !== 0;
+ // d is in [0, 255], so this maps zero to 0 and every other value to 1.
+ return (d + 0xff) >>> 8;
}
The existing outer API still has the boolean issues, but the places is_nonzero() is used in our library was more problematic because it was called in loops over potentially secret information. See also, the implementation for pow(), whose patch looks like so:
The same approach will be used to make select() constant-time.
Replace boolean with number, to avoid the boolean pitfalls
Change the mask generation strategy to not use !!
Since we’re no longer dealing with booleans, !!x can be (x & 1)
Avoid signed zeroes in interim calculations
-x can be replaced with 0 - (x & 1); subtracting from zero is a distinct operation from negating
The changes to select.ts are pretty short:
diff --git a/lib/select.ts b/lib/select.ts
index df6966d..7a8d4c3 100644
--- a/lib/select.ts
+++ b/lib/select.ts
@@ -1,21 +1,24 @@
import { int32 } from './int32';
/**
- * If TRUE, return left; else, return right.
+ * If choice is 1, return left; if it is 0, return right.
*
- * @param {boolean} returnLeft
+ * @param {number} choice (0 or 1)
* @param {Uint8Array} left
* @param {Uint8Array} right
* @returns {Uint8Array}
*/
-export function select(returnLeft: boolean, left: Uint8Array, right: Uint8Array): Uint8Array {
+export function select(choice: number, left: Uint8Array, right: Uint8Array): Uint8Array {
if (left.length !== right.length) {
throw new Error('select() expects two Uint8Array objects of equal length');
}
/*
- If returnLeft, mask = 0xFF; else, mask = 0x00;
+ If choice is 1, mask = 0xFF; else, mask = 0x00.
+
+ Keep this as subtraction from positive zero. Unary negation produces -0
+ for a zero choice, which V8 represents differently from the Smi value -1.
*/
- const mask: number = (-!!returnLeft) & 0xff;
+ const mask: number = (0 - (choice & 1)) & 0xff;
const out: Uint8Array = new Uint8Array(left.length);
for (let i: number = 0; i < left.length; i++) {
out[i] = right[i] ^ ((left[i] ^ right[i]) & mask);
@@ -35,7 +38,8 @@ export function select_alt(choice: number, m: Uint8Array, n: Uint8Array): Uint8A
if (m.length !== n.length) {
throw new Error('Both Uint8Arrays must be the same length');
}
- const mask = (-choice) & 0xff;
+ // See select(): subtraction from positive zero avoids materializing -0.
+ const mask = (0 - (choice & 1)) & 0xff;
const out = new Uint8Array(m.length);
for (let i: number = 0; i < out.length; i++) {
out[i] = n[i] ^ ((m[i] ^ n[i]) & mask);
@@ -55,7 +59,8 @@ export function select_ints(returnLeft: number, left: number, right: number): nu
/*
If returnLeft, mask = 0xFFFFFFFF; else, mask = 0x00000000;
*/
- const mask: number = (-(returnLeft & 1)) & 0xfffffffff;
+ // See select(): subtraction from positive zero avoids materializing -0.
+ const mask: number = 0 - (returnLeft & 1);
return right ^ ((left ^ right) & mask);
}
At this point, I still wasn’t finished there were still some administrivia to address (tests, README updates), and I also had to fix the CI configuration. Those aren’t interesting, though. With the power of hand-waving our problems away, we move on.
Verifying The Fix
Just because the patch remediated the specific patterns that were initially reported doesn’t mean that there aren’t other leaks present. Nor does it mean that my fixes were complete.
Indeed, the is_nonzero() and pow() leakage I used as the earliest example in the previous section was identified after I fixed select() and looked for other crimes against the type system. If I had only looked at the scope of the reported issues, I might have missed that one.
But, okay. Since booleans are a lot of the problem, just avoiding using boolean variables at all is simple enough to verify: Does this code only use numbers and unary operators in places I formerly used booleans? Yes.
The other interesting question to address is: Did we successfully avoid -0?
If you recall from above, the diffs all had this pattern:
- const mask: number = (-!!returnLeft) & 0xff;
+ const mask: number = (0 - (choice & 1)) & 0xff;
The corrected code will be interpreted as bitwise AND operations (which, if those aren’t constant-time, this whole effort is doomed) and a single subtraction from zero.
TNode<Object> BinaryOpAssembler::Generate_SubtractWithFeedback(
const LazyNode<Context>& context, TNode<Object> lhs, TNode<Object> rhs,
TNode<UintPtrT> slot_id, const LazyNode<HeapObject>& maybe_feedback_vector,
UpdateFeedbackMode update_feedback_mode, bool rhs_known_smi) {
auto smiFunction = [=, this](TNode<Smi> lhs, TNode<Smi> rhs,
TVariable<Smi>* var_type_feedback) {
Label end(this);
TVARIABLE(Number, var_result);
// If rhs is known to be an Smi (for SubSmi) we want to fast path Smi
// operation. For the normal Sub operation, we want to fast path both
// Smi and Number operations, so this path should not be marked as Deferred.
Label if_overflow(this,
rhs_known_smi ? Label::kDeferred : Label::kNonDeferred);
var_result = TrySmiSub(lhs, rhs, &if_overflow);
*var_type_feedback = SmiConstant(BinaryOperationFeedback::kSignedSmall);
Goto(&end);
BIND(&if_overflow);
{
*var_type_feedback = SmiConstant(BinaryOperationFeedback::kNumber);
TNode<Float64T> value = Float64Sub(SmiToFloat64(lhs), SmiToFloat64(rhs));
var_result = AllocateHeapNumberWithValue(value);
Goto(&end);
}
BIND(&end);
return var_result.value();
};
auto floatFunction = [=, this](TNode<Float64T> lhs, TNode<Float64T> rhs) {
return Float64Sub(lhs, rhs);
};
return Generate_BinaryOperationWithFeedback(
context, lhs, rhs, slot_id, maybe_feedback_vector, smiFunction,
floatFunction, Operation::kSubtract, update_feedback_mode, rhs_known_smi);
}
In the highlighted section, rhs_known_smi is typically false (since this is a computed value, not a bytecode immediate value). This means that the decision will be determined by TrySmiSub, with the following:
lhs will be 0
rhs will be the result of (choice & 1)
Since this doesn’t overflow (any number between INT32_MIN and INT32_MAX is not considered to have overflowed; the Smi tagging is implemented here), the if_overflow label is never jumped to.
The only things that can easily go wrong now are:
The compiler turns out to be evil and introduces an “optimization” that I don’t anticipate
The V8 developers turn out to be evil and modify how this logic works and break my assumptions in a future release
Other supply-chain-shaped shenanigans with package management
And, well, that’s not part of the threat model for this demo code, now is it?
When I referred to the compiler or V8 developers turning “evil” above, I meant this in a completely tongue-in-cheek way. I don’t think either party is literally malicious.
I thought this was obvious, but a Hacker News commentator took offense to that, so consider this my clarification.
Neither the V8 developer team nor the various C/C++ compiler projects give a single shit about my little demo code. Nor should they.
I’ll take “Clarifications I didn’t think needed to exist” for $300, Alex.
After I got the fix written, I generated a diff and included it in my email to Yayu, who did not observe the same leakage when they applied their methodology to the patched code.
What Would Higher Assurance Look Like?
In the previous sections, I described what efforts I took to verify the side-channel vulnerability was real, how I wrote the patch, and how I checked that the patch actually solves the problem.
Some people will read this and wonder, “Why are we even trying to do this in JavaScript?”
The only way we solve the “don’t roll your own crypto” problem is if we meet developers halfway. Give them high-quality tools that solve their problem that undergo expert scrutiny, and they will have no reason to roll their own crypto.
Many developers who find themselves needing cryptography tools are writing software in languages that security geeks and cryptographers do not willingly or readily use for their work.
To that end, I take it upon myself to at least make an effort to satisfy their unmet needs.
it’s something!
But a more interesting question to consider is, “Can we do even better?”
One tempting solution is to write all of our cryptography in Rust (or some other security nerd approved language), compile the algorithms into WebAssembly (or, if you’re only server-side, FFI it instead), and not try to make interpreted code isochronic.
Unfortunately, there is no real constant-time guarantee for WebAssembly. As of this writing, a proposal exists, but it hasn’t been updated in 4 years.
While we’re talking about it, there really isn’t (broadly speaking) any guarantee around systems programming languages (C, Rust, etc.), either.