Poly Network Cross-Chain Manager / Keeper-Replacement Exploit (2021-08-10)

  • Name: Poly Network cross-chain manager / keeper-replacement exploit
  • Date: 2021-08-10
  • Protocol: Poly Network
  • Chain: Ethereum + BNB Chain + Polygon
  • Category: privileged message dispatcher abuse / selector collision / bridge control-surface failure / keeper-set takeover
  • Loss: about $610M across Ethereum, BNB Chain, and Polygon, though nearly all funds were later returned
  • Summary: Poly Network’s bridge manager doubled as a generic cross-chain business-call executor and as the owner of the storage contract that held the current keeper public keys. The attacker abused verifyHeaderAndExecuteTx() and _executeCrossChainTx() in EthCrossChainManager to make that manager call putCurEpochConPubKeyBytes(bytes) on EthCrossChainData, replacing the keeper set with attacker-controlled key material. The trick was to choose a fake method string, f1121318093, whose 4-byte selector under Poly’s keccak256(_method || "(bytes,bytes,uint64)") dispatch scheme collided with the selector for putCurEpochConPubKeyBytes(bytes). Because Solidity only needed the first bytes argument and tolerated extra trailing calldata, the privileged admin call succeeded. Once the attacker had effectively become keeper, they authorized outbound bridge releases and drained hundreds of millions in bridged assets on multiple chains.
  • Trigger / entry point:
    • EthCrossChainManager.verifyHeaderAndExecuteTx(...) was the destination-side entry point for Poly cross-chain message execution.
    • After header and proof handling, the manager forwarded the message into _executeCrossChainTx(...), which performed a raw low-level call against the target contract.
    • The target contract and method metadata flowed from the decoded cross-chain message rather than from a strict per-target / per-method allowlist.
    • EthCrossChainManager was also the owner of EthCrossChainData, the contract that stored the current keeper public keys and processed-chain bookkeeping.
  • Root cause:
    • Poly collapsed ordinary message execution and control-plane administration into the same authority domain: the generic cross-chain executor was also trusted as owner of the keeper-set storage contract.
    • _executeCrossChainTx() derived the function selector from an attacker-influenced method string, truncated it to 4 bytes, and sent the call with low-level .call(...).
    • No dispatch policy prevented the executor from reaching EthCrossChainData’s privileged owner-only methods.
    • The downstream admin function expected only a single bytes parameter, so the extra ABI words appended by the manager did not stop the call from decoding successfully.
  • Exploit path:
    1. The attacker identified that EthCrossChainManager owned EthCrossChainData, and that keeper rotation lived in the owner-only function putCurEpochConPubKeyBytes(bytes).
    2. They computed the 4-byte selector for that admin function: bytes4(keccak256("putCurEpochConPubKeyBytes(bytes)")) == 0x41973cd9.
    3. They then brute-forced a fake cross-chain method string, f1121318093, such that bytes4(keccak256("f1121318093(bytes,bytes,uint64)")) produced the same selector 0x41973cd9.
    4. Using Poly’s normal message-execution surface, the attacker crafted a cross-chain call that targeted EthCrossChainData and supplied attacker-controlled keeper bytes as _args.
    5. verifyHeaderAndExecuteTx() passed that message into _executeCrossChainTx(), which performed _toContract.call(abi.encodePacked(selector, abi.encode(_args, _fromContractAddr, _fromChainId))) as the owner contract.
    6. On the receiving side, EthCrossChainData.putCurEpochConPubKeyBytes(bytes) decoded only the first ABI argument and ignored the trailing calldata corresponding to _fromContractAddr and _fromChainId.
    7. The call therefore succeeded and replaced Poly’s current keeper public keys with attacker-controlled key material.
    8. With keeper authority now effectively rewritten, the attacker submitted follow-on transactions that released bridge-held assets on Ethereum, BNB Chain, and Polygon to attacker addresses.
  • What the attacker needed to be true:
    • The bridge manager had to own the data contract that controlled keeper membership.
    • Cross-chain business execution had to permit calls into that admin contract instead of being limited to a narrow business-logic allowlist.
    • Selector derivation had to depend on attacker-influenced method metadata and only compare 4-byte selectors.
    • The target function had to accept the first decoded parameter while tolerating extra trailing calldata.
    • Bridge custody on the affected chains had to remain liquid enough that newly granted keeper authority could be monetized quickly.
  • Technical anatomy worth remembering:
    • This was not primarily a stolen-key incident and not a classic invalid-proof verifier bug. The protocol’s own privileged executor was mis-scoped.
    • The decisive design error was the ownership graph: Poly let the same contract both execute arbitrary-ish cross-chain business messages and administer the keeper set that defined bridge authority.
    • Selector collisions matter whenever protocols build dynamic dispatch from attacker-influenced strings. A 4-byte collision is enough if the downstream contract already trusts the caller.
    • Solidity ABI decoding can make these bugs worse: if the callee only needs a prefix of the calldata to decode the expected arguments, appended words do not necessarily save you.
    • The asset drain was a second-stage consequence. The first and more important break was administrative: the attacker rewrote who counted as bridge authority.
  • Representative transactions and contracts:
    • Ethereum EthCrossChainManager: 0x838bf9e95cb12dd76a54c9f9d2e3082eaf928270
    • BNB Chain EthCrossChainManager: 0x7cea671dabfba880af6723bddd6b9f4caa15c87b
    • Ethereum keeper-replacement transaction: 0xb1f70464bd95b774c6ce60fc706eb5f9e35cb5f06e6cfe7c17dcda46ffd59581
    • Representative Ethereum drain transaction: 0xad7a2c70c958fcd3effbf374d0acf3774a9257577625ae4c838e24b0de17602a
    • BNB Chain keeper-replacement transaction: 0x3eba3f1fb50c4cbe76e7cc4dcc14ac7544762a0e785cf22034f175f67c8d3be9
    • Representative BNB Chain drain transactions: 0x50105b6d07b4d738cd11b4b8ae16943bed09c7ce724dc8b171c74155dd496c25, 0xd65025a2dd953f529815bd3c669ada635c6001b3cc50e042f9477c7db077b4c9, 0xea37b320843f75a8a849fdf13cd357cb64761a848d48a516c3cac5bbd6caaad5
    • Attacker addresses highlighted in later incident analysis: 0xC8a65Fadf0e0dDAf421F28FEAb69Bf6E2E589963 (Ethereum), 0x0D6e286A7cfD25E0c01fEe9756765D8033B32C71 (BNB Chain), 0x5dc3603C9D42Ff184153a8a9094a73d461663214 (Polygon)
  • Response / mitigation:
    • Poly Network asked miners, exchanges, and infrastructure partners to blacklist the attacker addresses and block cash-out routes.
    • Tether froze a portion of the stolen stablecoins before they could be laundered further.
    • The attacker began returning funds within roughly a day, and Poly ultimately reported that all user assets had been recovered.
    • Poly later publicized a bug-bounty / recovery arrangement and emphasized post-incident security changes.

Pattern tags

  • privileged-message-dispatcher-abuse
  • Generic cross-chain business execution doubled as direct keeper-set administration because EthCrossChainManager already sat as owner over EthCrossChainData
  • Attacker-shaped method metadata plus 4-byte selector truncation let f1121318093(bytes,bytes,uint64) inherit the meaning of putCurEpochConPubKeyBytes(bytes)
  • Trailing-calldata tolerance turned a mis-scoped dispatcher into a working admin primitive instead of merely a malformed call
  • The keeper-rewrite path still had immediate line-of-sight to live bridge custody across Ethereum, BNB Chain, and Polygon rather than hitting narrower route-local release brakes first

Three bridge-truth promotions this incident collapsed

  1. Some authenticated-enough cross-chain message reached Poly’s executor

    • verifyHeaderAndExecuteTx() accepted a message and forwarded it into _executeCrossChainTx().
    • That weakest fact only meant something was allowed to reach the destination-side dispatcher.
  2. That message was allowed to count as one bounded business instruction rather than as open-ended bridge-root authority

    • This was the decisive broken promotion.
    • Poly let attacker-shaped method metadata, selector truncation, and a permissive ownership graph turn cross-chain message may execute business logic into cross-chain message may call keeper-set admin storage as owner.
  3. That accepted authority still deserved live multi-chain custody reach right then

    • Even after the dispatcher crossed into keeper-management territory, the resulting authority still had immediate line-of-sight to Ethereum, BNB Chain, and Polygon bridge reserves.
    • This is the separate bounded-release failure: one misrouted privileged call still inherited nine-figure live release power.

Why the exploit worked

  • Poly’s destination-side bridge security object was already split in the wrong place: verifyHeaderAndExecuteTx() authenticated a cross-chain message, but the same execution lane still decided both routine business logic and who counted as keeper authority. Once those two meanings shared one executor, authentic enough to dispatch became privileged enough to rewrite bridge truth.
  • The decisive mistake was the owner-through-owner graph, not merely the selector collision in isolation. EthCrossChainManager was trusted as the caller of ordinary cross-chain business messages and as the owner of EthCrossChainData, so any dispatch ambiguity automatically inherited keeper-management power.
  • Poly’s dynamic selector construction made that ambiguity cheaply exploitable. Because _executeCrossChainTx() derived a 4-byte selector from attacker-influenced method metadata, the attacker only needed a collision at the selector prefix, not a full semantic match to the intended business method string.
  • ABI permissiveness completed the exploit path. putCurEpochConPubKeyBytes(bytes) only needed the first decoded argument, so the extra encoded _fromContractAddr and _fromChainId words did not fail closed; the call stayed valid enough to replace the keeper set.
  • The later nine-figure drain was therefore a second-stage monetization event. The first break was that Poly let one generic executor mutate the root authority object that decided who could authorize bridge releases on three chains.

Detection and audit pivots

  • Inventory every contract a bridge executor can call and every contract it owns. If a routine message processor is also owner of keeper storage, upgrade storage, minter roles, or vault-control contracts, the dispatch path is already custody code.
  • Recompute selectors for any attacker-influenced method-name or metadata scheme and compare them against privileged admin surfaces. If the protocol hashes strings down to four bytes, collision search should be part of the audit plan, not treated as a theoretical edge case.
  • Validate exact executable intent, not just authenticated message -> low-level call. A healthy bridge should prove one typed message can reach only one bounded target / selector / ABI shape instead of trusting generic .call plumbing with admin-adjacent reachability.
  • Test whether downstream privileged functions still succeed when extra calldata words are appended. Prefix-compatible admin setters are especially dangerous when called through bridge executors, routers, or upgrade helpers that pack more arguments than the callee actually consumes.
  • Ask what happens if the dispatcher lands one wrong privileged call but no cryptographic verifier has failed. If the answer is still keeper set changes, then hundreds of millions can move before intervention, the protocol is over-concentrating control-plane authority behind one execution helper.

Failure-prevention checklist

  • Keep cross-chain business execution and bridge-root administration on different lanes, contracts, and trust roots. A contract that executes routine bridge traffic should not also be owner of keeper-set storage, upgrade controllers, vault admins, or token-root roles.
  • Bind every executable message family to a closed target set, closed selector set, and exact calldata schema. Poly’s method field should never have been able to steer a generic executor toward EthCrossChainData.putCurEpochConPubKeyBytes(bytes) through 4-byte selector aliasing.
  • Reject attacker-influenced selector derivation for privileged paths. If a route still derives selectors from strings or metadata, prove the output belongs to a fixed allowlist and cannot collide with admin surfaces through truncation.
  • Validate exact ABI layout and calldata length on privileged internal calls. A bridge-admin setter should fail closed if extra words are appended for another message family instead of happily decoding the first bytes argument and ignoring the rest.
  • Review ownership graphs as part of the bridge threat model: not only what can the dispatcher call directly? but also what contracts does it own, and what root authority do those contracts control?
  • Treat internal low-level .call helpers as root-key code whenever they run with protocol authority. They deserve the same review bar, invariant testing, and change control as signer-set rotation and upgrader code.
  • Put route-local blast-radius controls under any path that can mutate bridge authority indirectly: delayed keeper rotation, fast route pauses, per-asset release caps, and rollback procedures that do not depend on bridge-wide crisis coordination.
  • Test selector collisions, alternate method metadata, overlong calldata, wrong-target reachability, and owner-through-owner admin reach in audits and fuzzing, not just in postmortems.
  • Log the typed message family, target, selector, and downstream privileged path for every cross-chain execution so operators can see when business traffic is drifting toward control-plane mutation.

Audit prompts

  • What exact contracts can verifyHeaderAndExecuteTx() or its executor path call directly, own indirectly, or influence as owner, operator, or upgrader?
  • Which message families are supposed to exist on this bridge, and where in code is each family bound to a closed target / selector / schema set before low-level execution occurs?
  • Can attacker-shaped method metadata, strings, or hashes collide with any keeper-rotation, upgrader, vault-admin, or token-root selector after 4-byte truncation?
  • Would any privileged downstream function still succeed if the dispatcher supplied the right selector prefix plus extra trailing ABI words for another message family?
  • If a business executor is only meant to complete routine settlement, what concrete code or ownership boundary prevents it from reaching keeper-set storage like EthCrossChainData?
  • Which admin or custody-changing actions are intentionally callable cross-chain, and which ones should already require a distinct admin lane, local governance path, or human-gated ceremony instead?
  • If one misrouted privileged call lands without any verifier break, what exact control stops immediate keeper replacement and multi-chain asset release before operators intervene?
  • During upgrades, what test proves no newly owned contract or newly reachable selector silently turns a business executor into a bridge-root admin surface?
  • Can operators explain, for any executed bridge message, which authority class it claimed and which downstream privilege boundary it actually crossed?

Lessons that should already be standard

  • Authentic cross-chain delivery is not the same thing as authorization to mutate bridge-root state. A message can be real enough to arrive and still be catastrophically over-privileged once it lands.
  • Ownership graphs are part of the bridge security model. If the business executor owns keeper storage, then parsing and dispatch logic have already become signer-set custody code.
  • Four-byte selector space is too small to trust when attackers can shape method metadata and the caller already has protocol privilege. Selector collisions are an expected review problem, not a cute edge case.
  • ABI exactness is authority hygiene. Prefix-compatible calldata and ignored trailing words can turn almost the intended call into a live admin primitive.
  • Bridge admin mutation deserves a different lane from routine business execution. Reusing one executor for both is a convenience trade that turns dispatch bugs into bridge-wide insolvency risk.
  • Bridges should assume one executor mistake will eventually happen and bound it. If one wrong privileged call can immediately rewrite authority and empty custody, the protocol has only one real line of defense.
  • The healthy design target is boringly explicit: one message family, one narrow authority class, one closed target set, one exact calldata shape, and one separate admin lane for keeper or upgrade mutation.

Why this incident matters

  • It is one of the cleanest examples of a bridge failing because its message executor was effectively a root key for admin state.
  • The exploit teaches that who owns the keeper-set contract? can matter more than the cryptographic verifier itself.
  • It is also a strong reminder that selector collisions and ABI shape mismatches are not cute edge cases when a low-level call already carries protocol-level authority.
  • The note is especially useful because it preserves three separate bridge questions instead of flattening them into the message executed: what was authentic enough to arrive, what authority class that message was allowed to claim, and how much live custody that authority class still carried after the dispatcher crossed into keeper administration.
  • It sharpens the separate blast-radius lesson preserved in bridge bounded release design: once Poly’s generic cross-chain executor could rewrite keeper truth, that same authority still had immediate line-of-sight to hundreds of millions in live Ethereum, BNB Chain, and Polygon bridge custody instead of first hitting narrower per-route limits, staged release, or another high-value brake.

Internal linkages

  • Core recurring pattern: privileged-message-dispatcher-abuse.
  • Adjacent pattern: bridge-verification-failure — this exploit still lived on the bridge’s destination-side trust boundary, but the failure was overpowered execution authority rather than a forged proof or signer quorum.
  • Positive-design companion: bridge-typed-intent-dispatch-and-control-plane-separation — useful when the sharper healthy-side question is how Poly should have kept routine cross-chain business execution, keeper rotation, and other bridge-root mutations on distinct typed lanes instead of letting one executor inherit all three.
  • Positive-design blast-radius companion: bridge-bounded-release-design — useful for the separate question of why a manager contract that could rewrite keeper truth was still allowed to control such large immediately spendable bridge custody across multiple chains.

Control surface