← all writeups
P1 · DUPED ERC-4337 Account Abstraction Polygon Technology · HackerOne · July 2026

Proving Unrestricted Write Access to an ERC-4337 Bundler — Without Spending a Rupee of Gas

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.

The find

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.

Why a bundler key is worse than it looks

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 PROBLEM Every triager's first question: "so did you actually take over a wallet?" No — completing that chain needs gas prefund in a wallet I'd create, plus a valid signature. That's ~$0.008 of MATIC and one ECDSA signature away, but "it would work with more steps" is not a report. I needed proof that didn't cost anything.

Reversing the wallet factory

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.

GOTCHA WORTH REMEMBERING Selector math matters here. 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.

The trick: let the error codes testify

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:

StageIf it fails you seeWhat passing means
RPC auth401 / 403Key valid, endpoint live
UserOp parse + simulation entryAA10 / direct rejectBundler accepts arbitrary structures
initCode execution (factory)AA13No AA13 ⇒ factory ran, wallet deployed in simulation
EntryPoint routingunknown EntryPoint rejectBundler routes to the real EntryPoint
wallet-level validationAA23Terminal 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.

Reading the verdict

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 firedExpected signalReality
Sender allowlistreject before simulationnever fired
Factory / initCode restrictionAA13never fired
EntryPoint pinningunknown-EP rejectnever fired
Rate limit / scope cap429 / permission errornever 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).

THE GENERALIZABLE LESSON When you can't complete an exploitation chain, don't stop at "I couldn't demo it." Find the protocol's stage-gated error codes and make each stage testify. A transition between two error codes (AA23 with dummy sig vs. AA13 with broken initCode) is protocol-defined proof of exactly how far your access reaches. This works anywhere systems validate in stages: OAuth flows, JWT libraries, SAML, cloud IAM policy evaluation.
WHY AA23 AND NOT AA21 On a stock ERC-4337 wallet, a valid-signature-but-no-gas UserOp dies with 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.

Aftermath

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.

TL;DR for the next hunter

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.