3.10.4 Poly Network (August 2021)

The Poly Network exploit drained approximately $611M from a cross-chain bridge protocol on August 10, 2021 — at the time, the largest single-transaction theft in cryptocurrency history. The attacker did not break any cryptography, did not exploit a flash loan, did not manipulate a price. They sent a single carefully-crafted transaction that asked the bridge's manager contract to call a privileged function on the bridge's data contract. The manager contract had the authority to do so. The data contract trusted the manager. The manager trusted the user-supplied calldata. No layer of the system checked whether the user-supplied call was something the user should have been allowed to ask for.

The case is foundational for two reasons. First, it demonstrated that bridges — protocols that hold pooled assets and rely on off-chain consensus for security — concentrate value in ways that make them the largest theft targets in DeFi. Every subsequent bridge exploit in this section (Ronin, Nomad, Wormhole) reinforced that lesson. Second, the aftermath was unprecedented: within 24 hours of the theft, the attacker announced they would return all the funds. Within two weeks, they had. Poly Network offered them a $500,000 bug bounty and the position of Chief Security Advisor; both offers were declined. The attacker, who went by "Mr. White Hat," said they had executed the attack to expose the vulnerability and never intended to keep the funds.

Section 3.8.4 (Access Control Failures) and Section 3.8.8 (Signature & Replay Issues) both draw on this case. The specific bug — a privileged contract whose calldata path can be manipulated by an unprivileged caller — recurs across many bridge designs and is one of the canonical access-control pitfalls in inter-contract architecture.

Context

Poly Network was (and remains) a cross-chain interoperability protocol that allowed users to transfer tokens between different blockchains: Ethereum, Binance Smart Chain, Polygon, Neo, Ontology, OKExChain, Heco, and others. Each supported chain hosted a set of Poly Network contracts; the contracts coordinated through a separate consensus chain ("Poly Chain") operated by validators called "keepers."

The architecture on each destination chain (Ethereum, BSC, etc.) consisted of three contracts:

  • EthCrossChainManager (Manager) — entry point for cross-chain transactions. Validates that incoming messages have valid keeper signatures and merkle proofs from the Poly Chain. Executes the requested operation on the destination chain.
  • EthCrossChainData (Data) — privileged storage contract holding the current set of keeper public keys. Only the Manager can modify it (Manager is its owner).
  • LockProxy — held the actual pooled token balances. Released tokens upon authenticated cross-chain instructions.

The intended security model: only legitimate cross-chain transactions, signed by the keeper consensus and proved by merkle root inclusion on the Poly Chain, could trigger the Manager to perform actions. The keepers themselves were a permissioned validator set. As long as the keeper keys were secure and the consensus mechanism worked, the bridge was secure.

At the time of the exploit, Poly Network's pooled assets across all chains totaled approximately $611M. There was no public evidence the protocol had been audited.

The Architecture's Flaw

Two design decisions combined to create the vulnerability:

1. The Manager was the owner of the Data contract. The Data contract enforced an onlyOwner modifier on putCurEpochConPubKeyBytes, the function that replaced the current set of keeper public keys. The intent was that only privileged code could update the keeper set. Setting the Manager as owner expressed that intent — but it gave the Manager carte blanche permission to call any function on Data.

2. The Manager forwarded user-supplied calldata to arbitrary contracts. The Manager's verifyHeaderAndExecuteTx function, after validating signatures and merkle proofs, executed the cross-chain payload's instructions. The payload specified a target contract and a method name. The Manager called whatever method, on whatever contract, the payload requested.

Each decision was reasonable in isolation. Together they meant: anyone who could get a valid cross-chain transaction onto the Poly Chain could ask the Manager to call any method on the Data contract — including putCurEpochConPubKeyBytes, the keeper-update method. The Manager had onlyOwner access; the Data contract trusted that owner.

The attacker needed two things:

  1. A cross-chain transaction that the Poly Chain would accept as valid
  2. A way to make that transaction's payload resolve to putCurEpochConPubKeyBytes on the Data contract

Both turned out to be obtainable.

Vulnerable Code

A simplified rendering of the pattern:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract EthCrossChainData {
    address public owner;  // set to EthCrossChainManager
    bytes public curEpochConPubKeyBytes;

    modifier onlyOwner() {
        require(msg.sender == owner);
        _;
    }

    function putCurEpochConPubKeyBytes(bytes calldata curEpochPkBytes)
        external onlyOwner returns (bool)
    {
        curEpochConPubKeyBytes = curEpochPkBytes;
        return true;
    }
}

contract EthCrossChainManager {
    EthCrossChainData public ccd;

    function verifyHeaderAndExecuteTx(
        bytes calldata proof,
        bytes calldata rawHeader,
        bytes calldata headerProof,
        bytes calldata curRawHeader,
        bytes calldata headerSig
    ) external returns (bool) {
        // Verify keeper signatures and merkle proof
        require(_verifyHeader(rawHeader, headerSig, headerProof), "bad header");
        ToMerkleValue memory toMerkleValue = _executeProof(proof, rawHeader);

        // Execute the cross-chain transaction
        return _executeCrossChainTx(
            toMerkleValue.makeTxParam.toContract,
            toMerkleValue.makeTxParam.method,
            toMerkleValue.makeTxParam.args,
            toMerkleValue.fromChainID
        );
    }

    function _executeCrossChainTx(
        bytes memory toContract,
        bytes memory method,
        bytes memory args,
        uint64 fromChainID
    ) internal returns (bool) {
        // BUG: target contract and method name come from user payload
        // No whitelist of allowed methods; no check that target is appropriate
        address contractAddr = abi.decode(toContract, (address));

        // Construct the function call by computing the selector from the method name
        bytes memory callData = abi.encodePacked(
            bytes4(keccak256(abi.encodePacked(method, "(bytes,bytes,uint64)"))),
            abi.encode(args, "", fromChainID)
        );

        (bool ok, ) = contractAddr.call(callData);
        return ok;
    }
}

The pattern: _executeCrossChainTx computes a function selector from a user-supplied method name, then calls that selector on a user-supplied contractAddr. The Manager has full authority to call any method on the Data contract (or any other contract). The cross-chain authentication ensures the payload is one that passed Poly Chain consensus, but the payload itself can specify any target.

The Attack

The attacker assembled the exploit in three conceptual steps, executed as transactions on different chains.

Step 1: Find a Function Name That Hashes to putCurEpochConPubKeyBytes's Selector

The Solidity function selector is the first 4 bytes of keccak256(signature). For the target function:

keccak256("putCurEpochConPubKeyBytes(bytes)")[0:4] = 0x41973cd9

The Manager's _executeCrossChainTx constructs the call differently: it appends "(bytes,bytes,uint64)" to the user-supplied method name and hashes the concatenation. So the attacker needed to find a method name string X such that:

keccak256(X + "(bytes,bytes,uint64)")[0:4] == 0x41973cd9

The selector space is only 4 bytes (2^32 possibilities). Brute-forcing strings until one produces the desired prefix takes on the order of minutes on a modern laptop. The attacker found the string f1121318093:

ethers.utils.id('f1121318093(bytes,bytes,uint64)').slice(0, 10)
// '0x41973cd9'  — matches putCurEpochConPubKeyBytes(bytes)

This is a 4-byte selector collision, not a full hash collision. The fact that 4-byte selectors are short enough to brute-force is well-known (covered in Section 3.7.7 anti-patterns); the Poly Network architecture made the collision exploitable rather than merely possible.

Step 2: Initiate a Cross-Chain Transaction from Ontology

The attacker submitted a transaction on the Ontology chain that, when processed by Poly Chain consensus, would produce a cross-chain message targeting Ethereum. The message specified:

  • Target contract: EthCrossChainData (the privileged data contract on Ethereum)
  • Method name: f1121318093
  • Args: the attacker's own public key, ABI-encoded as the new keeper set

The Ontology relayer accepted the transaction. The Poly Chain validated it (no rule prevented arbitrary cross-chain calls targeting the Data contract). The transaction got included in a Poly Chain block. A valid merkle proof of inclusion now existed.

Step 3: Execute on Ethereum

The attacker called verifyHeaderAndExecuteTx on Ethereum's Manager contract, passing the proof of inclusion from Step 2. The Manager:

  1. Verified the merkle proof and keeper signatures — both valid, because the Poly Chain consensus had legitimately approved the transaction
  2. Decoded the payload: target = Data contract, method = "f1121318093", args = attacker's public key
  3. Computed the selector: keccak256("f1121318093(bytes,bytes,uint64)")[0:4] = 0x41973cd9
  4. Called EthCrossChainData.0x41973cd9(args) — which is putCurEpochConPubKeyBytes(args)
  5. The Data contract's onlyOwner modifier passed (caller was the Manager)
  6. The keeper public key was overwritten with the attacker's public key

The attacker was now the sole keeper for Ethereum's bridge contracts.

Step 4: Drain the Bridge

With the keeper role compromised, the attacker constructed new cross-chain transactions — this time signed by their own (now-authoritative) keeper key. Each transaction unlocked tokens from the LockProxy contract and sent them to the attacker's wallet.

The drained Ethereum holdings totaled approximately $273M. The attacker repeated the process on BSC ($253M) and Polygon ($85M), for a total exceeding $611M.

The attack took place in a roughly 30-minute window. The Poly Network team became aware of the theft via on-chain monitoring; the bridge was paused but only after the funds were gone.

The Unprecedented Aftermath

Within an hour of the theft, Poly Network posted an open letter on Twitter pleading with the attacker to return the funds. Within 24 hours, the attacker had responded — by inscribing messages on Ethereum transactions:

"I am not very interested in money. I know it hurts when people are attacked, but shouldn't they learn something from those attacks? I announced the situation to let the project know — including all of you. To take important money to keep it safe is the only solution I could think of. I am also exposing the vulnerability. They should be the last people to do this."

Over the following days, the attacker began returning funds. Poly Network and the attacker negotiated openly on-chain. The attacker provided private keys for the wallets holding the stolen funds. Poly Network offered a $500,000 "bug bounty" and the position of Chief Security Advisor. The attacker declined both offers.

The full recovery took approximately two weeks. By August 25, 2021, essentially all of the stolen funds had been returned. The actual realized loss to users was zero.

The episode raised — and partially answered — a question the community had been wrestling with since The DAO: when a smart contract bug enables theft, is the theft a crime, or is it the smart contract's responsibility to enforce intent? The Poly Network attacker's framing was the latter: they had "borrowed" the funds to expose a vulnerability, and the protocol's pleas were essentially asking them to be a white-hat. The attacker accepted that framing. Subsequent exploits have rarely produced similar outcomes.

Root Cause

The Poly Network exploit had several compounding causes:

1. Privileged contract calling user-supplied targets (Section 3.8.4). The Manager was authorized to call any function on the Data contract. The Manager's calls to Data were driven by user-supplied payloads. The combination meant any user (whose payload made it through Poly Chain consensus) could ask the Manager to call any function on the Data contract — including the privileged keeper-update function.

2. No method whitelist. The Manager should have restricted itself to a small set of operations that were appropriate for cross-chain payloads (typically mint, unlock, transfer on the LockProxy). Allowing arbitrary method calls was a generality that served no use case but enabled the entire exploit.

3. Hash collision in 4-byte selectors (Section 3.7.7). The 32-bit function selector space is small enough that any specific selector can be brute-forced. The attacker found a method name that mapped to putCurEpochConPubKeyBytes's selector — entirely standard adversarial behavior. The architectural assumption "no one will find a method name that collides with this important method" was naive.

4. Owner relationship granting full ABI access. Setting Manager as owner of Data was meant to be a coarse-grained access control: "only the Manager can update Data." But onlyOwner doesn't say what the owner can do — it grants full ABI access. If Data exposed any sensitive function, the Manager had the authority to call it. The intent was narrower; the implementation was full-trust.

5. No audit (apparently). No public evidence indicates Poly Network's contracts were audited before launch. A protocol holding $611M in pooled assets across multiple chains was, by most security-conscious standards, dramatically under-reviewed.

6. The Ontology relayer accepted the malicious payload. The cross-chain transaction's target and method should have been validated at the Poly Chain layer too, not just trusted because of valid signatures. The signatures proved the transaction was authentic; they did not prove the transaction was legitimate.

Lessons

The Poly Network exploit produced several specific patterns that have become standard for bridge design:

1. Whitelist callable methods on privileged paths. A privileged contract that calls into other contracts on behalf of users must enforce a whitelist of which methods can be called. Free-form forwarding of user-supplied selectors is a known vulnerability pattern.

mapping(bytes4 => bool) public allowedMethods;

function _executeCrossChainTx(bytes memory method, bytes memory args, ...) internal {
    bytes4 selector = bytes4(keccak256(abi.encodePacked(method, "(bytes,bytes,uint64)")));
    require(allowedMethods[selector], "method not allowed");
    // ... proceed with the call
}

2. Separate privileged operations into separate target contracts. The Data contract held both user-facing data (the keeper public keys, used by the Manager for legitimate operations) and privileged write functions (updates to those keeper keys). A cleaner separation would put the write functions in a separate contract whose owner is not the Manager but a dedicated governance address.

3. Restrict cross-chain payload targets. The Manager should have been restricted to forwarding calls only to a specific set of "destination" contracts (typically LockProxy or similar). Allowing the Manager to call into EthCrossChainData at all was the structural mistake.

4. Defense in depth at the relayer layer. The Poly Chain itself should have validated that cross-chain transactions targeting the Data contract were authorized at a higher level than "any well-formed transaction signed by the validator set." Bridge designs that emerged after Poly Network typically include destination-specific authorization rules.

5. Bridges need disproportionate security investment. $611M in pooled assets across multiple chains, with no public audit. The asymmetry between value-at-risk and security-review-depth was extreme. Modern bridge launches typically include multiple audits, contests, formal verification of critical components, and substantial bug bounties.

6. The bytes4 selector space is brute-forceable. Hash collision in 4-byte selectors is a known property; designs that assume "no one will find a colliding method name" are designs that haven't done threat modeling. Whitelists by full method signature (not just selector) avoid this class of bug.

7. The "Mr. White Hat" outcome is not the expected outcome. Poly Network was extraordinarily lucky that the attacker chose to return the funds. The vast majority of subsequent large exploits — Ronin, Nomad, Wormhole — did not produce similar outcomes. Designing security under the assumption that attackers will be benevolent is not a security design.

Modern Reproduction

A simplified version of the pattern in modern Solidity:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableData {
    address public manager;
    address public currentKeeper;

    modifier onlyManager() {
        require(msg.sender == manager, "not manager");
        _;
    }

    constructor(address _manager) {
        manager = _manager;
    }

    function setKeeper(address newKeeper) external onlyManager {
        currentKeeper = newKeeper;
    }
}

contract VulnerableManager {
    VulnerableData public data;

    constructor() {
        data = new VulnerableData(address(this));
    }

    function executeMessage(
        address target,
        string calldata method,
        bytes calldata args
    ) external {
        // BUG: no whitelist of methods or targets
        bytes4 selector = bytes4(keccak256(abi.encodePacked(method, "(address)")));
        bytes memory callData = abi.encodePacked(selector, args);
        (bool ok, ) = target.call(callData);
        require(ok, "execution failed");
    }
}

// Attacker can call executeMessage(address(data), "f...", attacker_address_bytes)
// where "f..." hashes to setKeeper's selector

A Foundry test demonstrating the takeover:

function test_PolyPattern_keeper_takeover() public {
    VulnerableManager manager = new VulnerableManager();
    VulnerableData data = manager.data();

    address attacker = makeAddr("attacker");

    // Step 1: find a method name that collides with setKeeper's selector
    // setKeeper(address)'s selector is 0xca6d56dc
    // (Brute-force off-chain — here we use the literal name for simplicity)
    string memory collidingName = "setKeeper";  // simplified for test

    // Step 2: invoke executeMessage with the colliding name and target = data contract
    bytes memory args = abi.encode(attacker);
    manager.executeMessage(address(data), collidingName, args);

    // Attacker is now the keeper
    assertEq(data.currentKeeper(), attacker);
}

The fixed version with a method whitelist:

contract SafeManager {
    VulnerableData public data;
    mapping(bytes4 => bool) public allowedSelectors;

    constructor() {
        data = new VulnerableData(address(this));
        // Explicitly allow ONLY the operations that are appropriate
        // for cross-chain payloads. Do NOT allow setKeeper.
        allowedSelectors[bytes4(keccak256("unlock(address,uint256)"))] = true;
        allowedSelectors[bytes4(keccak256("mint(address,uint256)"))] = true;
    }

    function executeMessage(
        address target,
        string calldata method,
        bytes calldata args
    ) external {
        bytes4 selector = bytes4(keccak256(abi.encodePacked(method, "(address,uint256)")));
        require(allowedSelectors[selector], "method not allowed");

        // Additionally: restrict which target contracts can be called
        require(target == lockProxyAddress, "target not allowed");

        bytes memory callData = abi.encodePacked(selector, args);
        (bool ok, ) = target.call(callData);
        require(ok);
    }
}

Two layers of defense: method whitelist plus target whitelist. Either alone would have prevented the Poly Network exploit; both together is defense in depth.

Cross-References

  • Access control failures — Section 3.8.4 covers the privileged-contract-calling-user-targets pattern as a standalone vulnerability class
  • Signature & replay issues — Section 3.8.8 covers signature verification, including the parameter-binding patterns that bridges need
  • Anti-patterns — Section 3.7.7 covers selector collision and unrestricted external calls
  • Subsequent bridge exploits — Section 3.10.5 (Ronin), 3.10.6 (Nomad), 3.10.7 (Wormhole) all reinforce different aspects of bridge security
  • Cross-chain security — Section 3.11.5 covers cross-chain and bridge security in depth
  • Audit gaps — Section 3.9 covers the audit practices that should be applied to value-bearing protocols
  • Incident response — Section 2.9 covers post-incident communication, including the unprecedented negotiations in this case