Better Mixed Boolean Arithmetic in JavaScript
Mixed boolean arithmetic (MBA) is great for obfuscation. It’s hard to read, obscures algorithms very well, and for years it really had no good solution.
In recent years, tooling such as CoBRA building on the long-established Z3 Theorem Prover have helped mitigate this challenge greatly.
In JavaScript, one of the hard parts is mapping your expression to a tool like CoBRA before you can simplify the expression and understand the algorithm its obfuscating.
Sadly, in the world of JavaScript obfuscation this hasn’t been done that well. Any obfuscators & antibots that choose to incorporate MBA do so sparingly and in a way which is trivially undone.
JavaScript MBA is powerful
I’m going to make an argument for why MBA is incredibly interesting (and arguably better) in the world of JavaScript obfuscation despite having barely been touched.
All MBA I have seen in-the-wild mirrors the patterns used in binary-obfuscation. However, JavaScript is weird. We can use this weirdness to great effect.
Numbers in JavaScript are floats until they’re not
Internally the number datatype in JavaScript is actually a float64. This is the IEEE 754 double-precision format. In this, it packs a number into 64 bits split across 3 fields:

- The first bit is allocated to the sign. This is used to represent if the number is positive or negative.
- The next 11 bits are the exponent bits (scale: a power of 2): 1026 = 1023 + 3 - multiply by 2^3 (the 1023 is the fixed “bias”).
- The final bits are the precision bits. This is known as the Mantissa, this contains the significant digits.
You may already be well versed with this. It’s a frequently asked question: “Why cant computers get floating point arithmetic right?” and the internal structure of the float64 representation is widely understood.
One interesting thing you may not know though. When you do any bitwise operation (excluding >>>) on a float64 in JavaScript, internally gets recast into int32.
That means that if we do:
const x = 2;
const y = 5;
console.log(x ^ y);
The JavaScript runtime is defining the x and y values as float64 and then re-casting them as int32 before it XORs them both, the result is then returned back as a float64.
How can this be abused?
MBA solvers (and humans) make the reasonable assumption that the program they are analysing will only contain numbers which are going to have a static bitwidth.
However, as JavaScript has this implicit coercion, we can generate expressions which have fluctuating bitwidths “invisibly”. This means we can create expressions which when passed to a solver like CoBRA will produce totally incorrect simplifications compared to their true value in the JavaScript runtime.
2^53: The Max Safe Integer
A float64 can store every integer up to 2^53 (Number.MAX_SAFE_INTEGER is 2^53 - 1). Past that the mantissa runs out of bits, so the representable integers start to spread apart: between 2^53 and 2^54 only even numbers exist, between 2^62 and 2^63 the gap between neighbours (the ULP) is 1024, and so on. If you add a small number to a large one up there and the result just rounds to the nearest representable value, and the low bits drop off the end.
A solver like CoBRA (reasonably) doesnt model this. It reasons integers are fixed-width; modulo 2^n, where (M + a) - M is always going to be a, no matter how big M is. As a result we can abuse this.
Variants
Here are 2 simple variants I have constructed:
- Runtime evaluates to
x & 255,CoBRAsimplifies toy & 255:
(x & 0xff) + ((0x9E3779B1 * 0x85EBCA77 + (y & 0xff)) - 0x9E3779B1 * 0x85EBCA77) - ((0x9E3779B1 * 0x85EBCA77 + (x & 0xff)) - 0x9E3779B1 * 0x85EBCA77)
- Runtime evaluates to
x & 255,CoBRAsimplifies to0
(x & 0xff) - ((0x9E3779B1 * 0x85EBCA77 + (x & 0xff)) - 0x9E3779B1 * 0x85EBCA77)
Lets explore #1.
A “ghost” term
const g = (M, a) => (M + a) - M;
To CoBRA and to anyone reading it as plain integer math g(M, a) is just a; the Ms cancel. But if M is far enough past 2^53, the addition M + a rounds straight back to M, and JavaScript hands you back 0, for example:
const M = 6e18;
g(M, 123);
This would result in 0 in JavaScript. So g is a ghost 👻: it equals a in every model a solver knows about, and 0 at runtime. We now have a term we can make appear or disappear depending on who’s looking at it.
Hiding the constants
const M = 0x9E3779B1 * 0x85EBCA77;
0x9E3779B1 is xxHash’s PRIME32_1 and 0x85EBCA77 is xxHashes PRIME32_2.
I picked these as there are many applications where these values could be re-used to add to confusion while contributing to your MBA.
Their product is 5964046043053701959. JavaScript numbers can’t represent this, evaluating to 5964046043053702000.
The deception
We want an expression that is x & 0xff at runtime but y & 0xff to the solver. Use one ghost to smuggle in y and another to delete x:
function f(x, y) {
const M = 0x9E3779B1 * 0x85EBCA77; // ~2^62.4
return (x & 0xff) +
((M + (y & 0xff)) - M) - // a = (y & 0xff)
((M + (x & 0xff)) - M); // a = (x & 0xff)
}
- In V8: every
(M + a) - Mrounds away to0, sofreturns(x & 0xff) + 0 - 0=x & 0xff. - To a solver: every
(M + a) - Misa, sofreturns(x & 0xff) + (y & 0xff) - (x & 0xff)=y & 0xff.
Hand the expression to CoBRA:
$ cobra-cli --mba "(x & 0xff) + ((0x9E3779B1 * 0x85EBCA77 + (y & 0xff)) - 0x9E3779B1 * 0x85EBCA77) - ((0x9E3779B1 * 0x85EBCA77 + (x & 0xff)) - 0x9E3779B1 * 0x85EBCA77)" --verbose
Variables: x, y
Classification: kSemilinear
Flags: bitwise, arithmetic, arith-over-bitwise
Evaluating signature vector...
Signature vector (2 vars): [0, 0, 1, 1]
Simplifying...
Running spot-check... passed
Verifying full-width equivalence... passed
y & 255
It returns that the masked byte comes from y. We know at runtime it comes from x. f really is y & 255 as a fixed-width integer expression, and its check confirms that. The answer is right for it’s own integer model, there’s correctly no float64 anywhere in that pipeline, so nothing in it can see that V8 does the + and * in float64 and never reaches y.
Tell the solver it’s bitwidth?
This doesnt help. (M + a) - M is a in every modular ring 2^n, so the solver prints y & 255 at anything. Our runtime’s x & 255 isn’t reachable at any width, because V8 isn’t doing modular arithmetic. A JavaScript expression doesn’t have a bitwidth: arithmetic runs ~53-bit-exact-then-floats, bitwise ops are exactly 32, and they alternate. Any single modulus a solver commits to, it’s wrong in one of the two phases.
An int32 past 2^53 comes back one of three ways:
- exact: float64 didn’t round, so there’s no divergence to exploit.
- corrupted: it rounded, so it’s now wrong for some input and your function is broken.
- destroyed: it was below the local ULP, so it rounded to
0(we use this in our “ghost” case).
Do a float64-aware pass first?
With our specific example the divergence we used is only (M + a) - M. Any deobfuscator that recognises the shape a constant M whose ULP/2 exceeds the range of a and rewrites it to 0 strips the ghost in a few lines. However, this doesnt need to be the only representation, that’s part of what MBA is.
Practical attacks
The attacker must be first aware this is even possible. When adversarially testing using Claude Opus 4.8, given a hardened implementation of a loop-unrolled RC4 PRGA implementation with the KSA array constants embedded, generated at request-time. The agent took a combined 3 hours (with slight redirection) to correctly dump the KSA array over 10/10 requests using Python. It’s interesting to observe how unqiue techniques slow-down LLMs when tasked with less-common reverse engineering goals.
1. Z3 with proper mapping
If youre willing to take a on average a ~2126.8% slowdown compared to CoBRA, Z3 has full IEEE-754 theory and can model this if you encode float + ToInt32 at the bitwise boundary.
2. Building a state machine
Another approach could be building a state-machine which at runtime resolves the data/algorithm obfuscated via this technique. Features such as SWC’s JsNumber could make this a bit easier.
3. Sandboxing
Easiest approach attack-wise, but also the worst, if a defender cannot prevent someone determined enough to solve their challenge (which you never can), this is really what they want to force an attacker to do. If an antibot can “encourage” this, attack volume has been mitigated significatnly.
Other interesting notes
Z3 can be “attacked” without any float64 tricks
Z3 is incredibly expensive on dense linear MBA. It cannot prove equivalence for either of the following cases in 5 minutes at 64-bit. CoBRA would give the answer in ~10ms:
11*~(x&~y) - 9*~(x|y) + 2*(x&~y)which is just9*y - 2-8*~y*(x&y) - 8*~y*(x&~y) + 10*~y*x + 3*~y*~(x|y) + 8*(x^y)*(x&y) + 8*(x^y)*(x&~y) - 10*(x^y)*x - 3*(x^y)*~(x|y) - 3*~y*(x|~y) - 1*~y*~x + 3*(x^y)*(x|~y) + 1*(x^y)*~xwhich is~y - (x^y)
Create difficult static expressions
In the case where its necessary for an attacker to have opaque predicates resolved at runtime. This technique could likely be used to break dead code elimination (DCE) by both; improperly having important functionality removed, and/or making DCE exceptionally difficult. It could also be speculated that this could effectively be used to flood the context window of LLM-assisted reverse engineering attempts.
Smuggling coefficients through the float64 multiply
You can achieve a similar goal by smuggling a coefficient:
((F1*F2|0)+OFF) * basis
This will “fool” CoBRA in the exact same way but the divergence is a constant subexpression, so a float64 const-folder evaluates it away. This could be hardened by better-deriving these subexpressions.
JavaScript’s coercion is highly effective
Take for example the expression: a | b = (a ^ b) + (a & b)XOR holds the differing bits, AND holds the common bits, and they’re bit-disjoint so + reconstructs OR.
You could just gate the carry term with x: (a ^ b) + x * (a & b)
x = 0: (a ^ b) + 0 = XORx = 1: (a ^ b) + (a & b) = OR (the + is exact because the two terms touch disjoint bits) Virtually all datatypes in JavaScript are coercible, it’s ugly and beautiful. Soundefinedwhen used in an expression such as this, can be used to gate an expression. Empty array accesses can be used to representundefined. This can create some cool logic:
const G = []; // Each expression is identical but produces divergent results:
console.log((()=>(G[0]^G[1])+(G[3]|(G[3]=1,0))*(G[0]&G[1]))(G[0]=0x78,G[1]=0xff).toString(16)); // "87" - XOR
console.log((()=>(G[0]^G[1])+(G[3]|(G[3]=1,0))*(G[0]&G[1]))(G[0]=0x78,G[1]=0xff).toString(16)); // "ff" - OR
Detecting DevTools via WebAssembly Overhead Asymmetry
Most DevTools detections rely on the same techniques:
console.logside-effectsdebuggertimingErrorbehaviour
These are fairly documented, easily mitigated and these patterns are widely recognised by reverse engineers.
This post describes a different approach. By exploiting an asymmetry between WebAssembly.Module() and WebAssembly.validate() inside V8, we can detect whether the Chrome DevTools inspector is attached using a signal that is structural to V8’s architecture.
My Initial Observation
While building a proof-of-work system that used WebAssembly for hash computation, I noticed a consistent performance degradation when DevTools were open.
I could have left this alone, but I have been collecting novel detections for a while, and I did think there was likely a new and interesting detection vector hiding here.
My initial theory was this was something related to DWARF. Maybe some debug information was being injected into the WASM pipeline when DevTools were attached?
After pulling up the chromium source I went hunting. Which is where I found this fairly interesting detection vector. V8 fires a synchronous chain of inspector callbacks on every WASM module compilation with no overhead when no inspector is connected.
How can this be abused?
WebAssembly.validate(bytes) and new WebAssembly.Module(bytes) both accept the same WASM bytecode, decoding and validating the module structure. But their implementation differences can be leveraged to flag devtools in a pretty sneaky way.
validate(): WASM Validation
WebAssembly.validate() enters V8 through WebAssemblyValidateImpl which calls WasmEngine::SyncValidate():
// v8/src/wasm/wasm-engine.cc
bool WasmEngine::SyncValidate(Isolate* isolate, WasmEnabledFeatures enabled,
CompileTimeImports compile_imports,
base::Vector<const uint8_t> bytes) {
TRACE_EVENT0("v8.wasm", "wasm.SyncValidate");
if (bytes.empty()) return false;
WasmDetectedFeatures unused_detected_features;
auto result =
DecodeWasmModule(isolate, enabled, bytes, true, kWasmOrigin,
DecodingMethod::kSync, &unused_detected_features);
if (result.failed()) return false;
WasmError error = ValidateAndSetBuiltinImports(
result.value().get(), bytes, compile_imports, &unused_detected_features);
return !error.has_error();
}
It calls DecodeWasmModule() to parse and validate the structure, checks import compatibility, then returns a boolean. The cost of validate() is constant regardless of DevTools being attached.
Module(): Compilation + Debug Hooks
new WebAssembly.Module() enters through WebAssemblyModuleImpl which calls WasmEngine::SyncCompile(). This function performs the same DecodeWasmModule() and ValidateAndSetBuiltinImports() calls, but then continues:
// v8/src/wasm/wasm-engine.cc - SyncCompile()
std::shared_ptr<NativeModule> native_module = CompileToNativeModule(
isolate, enabled_features, detected_features, std::move(compile_imports),
thrower, std::move(module), std::move(bytes), compilation_id, context_id,
pgo_info.get());
DirectHandle<Script> script =
GetOrCreateScript(isolate, native_module, kNoSourceUrl);
native_module->LogWasmCodes(isolate, *script);
isolate->debug()->OnAfterCompile(script);
CompileToNativeModule() compiles the module to native code or retrieves it from V8’s engine-wide NativeModule cache. When you compile the same bytes repeatedly, MaybeGetNativeModule() returns the cached result with no recompilation. GetOrCreateScript() caches Script handles per NativeModule. For repeated compilations of identical bytes, these are virtually free cache lookups.
LogWasmCodes() and OnAfterCompile() on the other hand, execute every time. These are the two calls that behave differently when DevTools is attached.
OnAfterCompile() delegation
OnAfterCompile() delegates to Debug::ProcessCompileEvent():
// v8/src/debug/debug.cc
void Debug::ProcessCompileEvent(bool has_compile_error,
DirectHandle<Script> script) {
RCS_SCOPE(isolate_, RuntimeCallCounterId::kDebugger);
if (script->id() == Script::kTemporaryScriptId) return;
if (running_live_edit_) return;
script->set_context_data(isolate_->native_context()->debug_context_id());
if (ignore_events()) return;
if (!script->IsSubjectToDebugging()) return;
if (!debug_delegate_) return;
SuppressDebug while_processing(this);
DebugScope debug_scope(this);
HandleScope scope(isolate_);
DisableBreak no_recursive_break(this);
AllowJavascriptExecution allow_script(isolate_);
{
RCS_SCOPE(isolate_, RuntimeCallCounterId::kDebuggerCallback);
debug_delegate_->ScriptCompiled(ToApiHandle<debug::Script>(script),
running_live_edit_, has_compile_error);
}
}
When no inspector session is connected, ignore_events() returns true (because is_active_ is false) and OnAfterCompile() returns at that check, never reaching the delegate call. The cost is some comparisons and a return (virtually zero).
When DevTools opens and enables the Debugger domain, V8’s inspector registers itself as the debug delegate and sets is_active_ to true. Now ignore_events() returns false, the call falls through to ScriptCompiled(), which iterates every active inspector session and calls didParseSource() on each one.
The expensive logic
V8DebuggerAgentImpl::didParseSource() runs synchronously on the main thread, the Module() constructor doesn’t return to JavaScript until this entire function completes. For WASM modules specifically, it performs:
- WASM debug symbol extraction - reads DWARF/sourcemap info from the module via
getDebugSymbols(), even when no debug info is present - Script registration - inserts the script into the agent’s
m_scriptsmap - Blackbox cache invalidation - resets cached blackboxed state
- Stack trace capture - walks the V8 stack via
V8StackTraceImpl::capture() - Breakpoint matching - iterates stored breakpoints (by URL, regex, and script hash) and tests each matching group against the new script. Matching breakpoints trigger
setBreakpointImpl(), which can cause WASM function recompilation with breakpoint instrumentation scriptParsedCDP event construction - serializes the full script metadata (ID, URL, hash, source map, debug symbols, resolved breakpoints) into a Chrome DevTools Protocol event for the frontend- Breakpoint resolution events - sends
breakpointResolvedfor each matched breakpoint - Instrumentation breakpoint setup - checks and sets instrumentation breakpoints if configured
Additionally, LogWasmCodes() (see SyncCompile above) is guarded by WasmCode::ShouldBeLogged(), which returns isolate->IsLoggingCodeCreation(). This is true when the inspector has registered a code event listener. With DevTools attached, it iterates every compiled function in the module and logs it. Without DevTools, it returns immediately.
Why the ratio inverts
For a minimal WASM module (34 bytes, single function returning a constant), the per-call costs break down roughly as:
Baseline - validate() cost:
| Function | Time | Note |
|---|---|---|
DecodeWasmModule() | ~15us | full decode + validation |
ValidateAndSetBuiltinImports() | ~3us | import compatibility |
| Total | ~18-19us |
Without DevTools - Module() cost:
| Function | Time | Note |
|---|---|---|
DecodeWasmModule() | ~9us | decode without validation |
ValidateAndSetBuiltinImports() | ~2us | import compatibility |
CompileToNativeModule() | ~2us | cache hit via MaybeGetNativeModule |
GetOrCreateScript() | ~1us | cache hit |
LogWasmCodes() | ~0us | ShouldBeLogged = false, returns |
OnAfterCompile() | ~0us | ignore_events() = true, returns |
| Total | ~14-16us |
With DevTools - Module() cost:
| Function | Time | Note |
|---|---|---|
DecodeWasmModule() | ~9us | same |
ValidateAndSetBuiltinImports() | ~2us | same |
CompileToNativeModule() | ~2us | same cache hit |
GetOrCreateScript() | ~1us | same cache hit |
LogWasmCodes() | ~3-5us | iterates compiled functions |
OnAfterCompile() | ~22-25us | the signal |
- didParseSource() | ~20us | debug symbols, script registration, stack capture, breakpoint matching, scriptParsed serialization |
| Total | ~40-44us |
Module() is slightly faster than validate() because cached compilations skip the full validation that SyncValidate always performs. SyncCompile passes validate_module = v8_flags.wasm_jitless (which is false in the standard non-jitless path), deferring validation to the compiler, while SyncValidate passes true. The cached NativeModule was already validated during its initial compilation.
When devtools are open and the compile / validate ratio changes from ~0.78 to ~2.33.
Proof of Concept (PoC)
The detection loops both operations, computes the compile/validate ratio, and averages over 5 rounds. A threshold of 1.5 provides comfortable margin above the clean baseline (~0.78) while staying well below the DevTools ratio (~2.33):
const THRESHOLD = 1.5;
const WASM_BYTES = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60,
0x00, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x05, 0x01, 0x01, 0x66,
0x00, 0x00, 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b,
]);
function sample(N) {
const t0 = performance.now();
for (let i = 0; i < N; i++) new WebAssembly.Module(WASM_BYTES);
const compileMs = performance.now() - t0;
const t1 = performance.now();
for (let i = 0; i < N; i++) WebAssembly.validate(WASM_BYTES);
const validateMs = performance.now() - t1;
const ratio = compileMs / Math.max(validateMs, 0.001);
return { compileMs, validateMs, ratio };
}
function runDetection() {
const N = 1000;
sample(50);
const results = [];
for (let i = 0; i < 5; i++) results.push(sample(N));
const avgRatio = results.reduce((s, r) => s + r.ratio, 0) / results.length;
return avgRatio > THRESHOLD;
}
Observed results
Tested on Windows 11, Microsoft Edge 148.0.3967.70:
| State | Module() per-call | validate() per-call | Ratio |
|---|---|---|---|
| No DevTools | ~15us | ~19us | 0.78 |
| DevTools open | ~42us | ~18us | 2.33 |
The validate() cost stays constant at ~18-19us regardless of DevTools state. The Module() cost jumps from ~15us to ~42us, close to a 3x increase driven entirely by the didParseSource chain.