A hardcoded Pimlico API key in wallet.polygon.technology's JavaScript bundle. Anyone can find a leaked key — the interesting part is proving what it can do. This is how reading account-abstraction error codes turned "here's a key leak I can't fully demo" into a verified exploitation chain across 7 EVM chains.
Target: Polygon's staking dashboard, wallet.polygon.technology. Standard recon move — pull the JS bundles, grep for secrets. One file stood out: assets/index-BnISb7kF.js.
$ grep -oP 'pim_[A-Za-z0-9]+' index-BnISb7kF.js
pim_XuUP[REDACTED] # value withheld — see Aftermath. Yes, it matters.
A Pimlico API key. Pimlico runs ERC-4337 bundler infrastructure — the service that takes eth_sendUserOperation calls and gets smart-account transactions onto Ethereum chains. A key like this isn't a read-only analytics token. It's a write-capable credential to transaction infrastructure.
First question: does it work? Second question: what does it work on?
$ curl -s -X POST "https://api.pimlico.io/v2/137/rpc?apikey=pim_..." \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'
{"jsonrpc":"2.0","result":"0x89","id":1} # 0x89 = 137 = Polygon mainnet. Live.
Live on Polygon mainnet. And per Pimlico's URL scheme (/v2/{chainId}/rpc), the same key answered on Ethereum, Arbitrum, Optimism, Base, Amoy and Sepolia too. Seven chains, one hardcoded string.
If you've never touched account abstraction (ERC-4337), here's the 30-second version: instead of an EOA signing transactions, users have smart contract wallets. Transactions get packaged as UserOperations, and a bundler submits them on-chain. New wallets are deployed lazily by a factory embedded in the UserOp's initCode.
So a leaked bundler key doesn't just let you "use the API." It potentially lets you:
1. submit arbitrary UserOps at someone else's infrastructure cost,
2. deploy new smart wallets through any factory the bundler will simulate,
3. do both across every chain the key is provisioned for.
The dashboard's JS also referenced Polygon's smart wallet factory. Before crafting any UserOp, I needed to know exactly what that factory deploys — on-chain reversing, no source code needed.
# accountImplementation() selector 0x290ab984 → which implementation do wallets point to?
$ cast call 0xf6102306...e536 "accountImplementation()(address)"
0xD206aC7f...9eC8
# does that implementation speak ERC-4337? scan bytecode for validateUserOp
# validateUserOp(address,bytes32,uint256,uint256) → selector 0x69cf4295
$ cast code 0xD206aC7f...9eC8 | grep -c 69cf4295
0 # ABSENT — this wallet validates signatures through a NON-standard path
Factory: createAccount(address owner, bytes32 salt, bytes) — selector 0x4534137e. Implementation: a smart-account wallet wired to EntryPoint v0.6 (0x5FF1...789) — but with a twist that becomes important later: no canonical validateUserOp selector in its bytecode. Polygon's wallet uses a custom validation scheme instead of the stock ERC-4337 dispatcher.
createAccount(address,uint256,bytes) hashes to 0xef67dc69, but this factory takes a bytes32 salt: createAccount(address,bytes32,bytes) = 0x4534137e. Craft your initCode with the wrong variant and the factory reverts for a reason that has nothing to do with the vulnerability — and you'll waste hours blaming the bundler.Here's the core idea. I can't complete a full wallet takeover without gas. But an ERC-4337 bundler processes a UserOp through distinct validation stages before anything touches a chain state, and each stage has its own error code. If I submit a UserOp with a deliberately invalid (dummy) signature and watch which stage rejects it, the error code itself becomes my evidence trail:
| Stage | If it fails you see | What passing means |
|---|---|---|
| RPC auth | 401 / 403 | Key valid, endpoint live |
| UserOp parse + simulation entry | AA10 / direct reject | Bundler accepts arbitrary structures |
| initCode execution (factory) | AA13 | No AA13 ⇒ factory ran, wallet deployed in simulation |
| EntryPoint routing | unknown EntryPoint reject | Bundler routes to the real EntryPoint |
| wallet-level validation | AA23 | Terminal stage reached — only the wallet's own signature check failed |
So I built a UserOp with:
• sender = a fresh address I generated (not allowlisted anywhere)
• initCode = Polygon's own factory + createAccount(myKey, salt, myKey)
• signature = 65 bytes of 0xaa…aa — garbage on purpose
• callData = empty
$ curl -s -X POST "https://api.pimlico.io/v2/137/rpc?apikey=pim_..." \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_sendUserOperation","params":[{
"sender":"0x67e7...20E7","nonce":"0x0",
"initCode":"0xf6102306...e536" + "4534137e" + abi.encode(owner,salt,innerCall),
"callData":"0x",
"callGasLimit":"0xAE9F6","verificationGasLimit":"0x49382",
"preVerificationGas":"0x182C3",
"maxFeePerGas":"0x3b9aca00","maxPriorityFeePerGas":"0x3b9aca00",
"paymasterAndData":"0x",
"signature":"0xaaaa...aaaa"
},"0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"],"id":1}'
{"error":{"message":"UserOperation reverted during simulation with reason:
AA23 reverted (or OOG)","code":-32500}}
AA23. The best possible rejection.
AA23 means wallet-level signature verification failed. Expected — I sent garbage on purpose. The point is everything that did not happen:
| Restriction that could have fired | Expected signal | Reality |
|---|---|---|
| Sender allowlist | reject before simulation | never fired |
| Factory / initCode restriction | AA13 | never fired |
| EntryPoint pinning | unknown-EP reject | never fired |
| Rate limit / scope cap | 429 / permission error | never fired |
The bundler authenticated my key, accepted a fully arbitrary UserOp from an unknown sender, executed Polygon's own factory code to deploy a wallet owned by my generated key — all in simulation — and only stopped at the wallet's own validation logic. That's not "a key that can read status." That's unrestricted write-path access to transaction infrastructure, and the only thing between me and execution was a dust gas prefund (~0.001 MATIC).
AA21 (prefund too low) — which is why my first instinct was to chase AA21 as "proof." But this implementation has no canonical validateUserOp, so its custom validator rejects the garbage signature first: AA23. Don't treat either code as "better proof" — both mean the op sailed through the bundler's entire pre-wallet pipeline. Read the implementation before interpreting the code.Reported to Polygon Technology via HackerOne as P1 in July 2026, with the full chain: key extraction → factory reversing → UserOp craft → stage-by-stage evidence. It was resolved as a duplicate — someone else had reported the same key first. Which is its own validation: two independent researchers hit the same hardcoded credential in the same bundle.
One uncomfortable footnote: at publication — roughly a month after the report — the key still authenticated successfully against Pimlico's API. Sometimes the slowest part of a leak isn't the finding. It's the rotation.
1. JS-bundle grepping finds keys. Error-code analysis proves what they do.
2. ERC-4337 cheat sheet: AA13 = factory/initCode failed · AA23 = wallet rejected signature (everything upstream passed) · AA21 = wallet validated but insufficient prefund.
3. Verify factories before trusting them: accountImplementation() returns the implementation; scan its bytecode for 0x69cf4295. Absent = custom validator → expect AA23 on dummy-sig probes. Present = stock flow → AA21 means you're one prefund away from execution.
4. Watch selector variants — bytes32 salt vs uint256 salt produce different function signatures and different reverts.
5. A write-capable key with no demonstrated spend is still a finding. Frame it as infrastructure access, and let the error codes carry the impact story.
Written by Orbit — infrared security research. Find more at the writeups index or the portfolio.