The Poly Network Exploit: $611 Million Stolen, Then Returned

On August 10, 2021, a single attacker stole $611 million from the Poly Network cross-chain bridge across Ethereum, BSC, and Polygon simultaneously. At the time, it was the largest theft in DeFi history. Within two weeks, every dollar was returned. The attacker claimed they did it for fun.

What Poly Network Actually Did

Poly Network was a cross-chain bridge. If you wanted to move USDC from Ethereum to BSC, you’d lock it on Ethereum, and the bridge would release equivalent tokens on BSC. The system that made this work had two key contracts:

  • EthCrossChainData: Stored the public keys of “keepers”—the authorized parties who validate cross-chain transactions
  • EthCrossChainManager: Processed incoming cross-chain messages and executed the resulting calls

The EthCrossChainData contract was owned by EthCrossChainManager. That ownership relationship became the exploit.

The Vulnerability

The manager contract was designed to receive cross-chain messages and execute whatever they instructed. The problem was that “whatever they instructed” included no restrictions on which contract or which function to call.

// EthCrossChainManager (simplified)
function verifyHeaderAndExecuteTx(
    bytes memory proof,
    bytes memory rawHeader,
    /* ... */
) public returns (bool) {
    // Verify the cross-chain message format...
    // Then execute the call with NO whitelist:
    (bool success, ) = _toContract.call(
        abi.encodePacked(
            bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))),
            _args
        )
    );
}

The manager ran as the owner of EthCrossChainData. That meant if you could make the manager call putCurEpochConPubKeyBytes—the function that updates keeper public keys—the ownership check would pass automatically. msg.sender would be the manager, which is the owner, so access granted.

The attacker’s payload was simple: craft a cross-chain message targeting EthCrossChainData, calling putCurEpochConPubKeyBytes, with their own public key as the argument.

The Attack

The attacker sent one crafted transaction to each chain—Ethereum, BSC, and Polygon. Each transaction replaced the legitimate keeper public keys with the attacker’s own keys. Once they controlled keeper validation, they could authorize any cross-chain withdrawal they wanted.

The breakdown of what was taken:

ChainNotable Assets
Ethereum2,858 ETH, 96.4M USDC, 1,032 WBTC, 673M USDT (frozen)
BSC6,610 BNB, 87M USDC
Polygon85M USDC

Tether moved faster than the attacker expected. Within hours of the theft, Tether blacklisted the attacker’s Ethereum address and froze the ~$33 million USDT sitting there.

The Peculiar Return

The attacker started communicating through transaction input data—embedding messages directly in the calldata of Ethereum transactions. Their claims were strange:

“IT WOULD HAVE BEEN A BILLION DOLLAR HACK IF I HAD MOVED REMAINING SHITCOINS!”

“I AM NOT VERY INTERESTED IN MONEY!”

Whether the Tether freeze spooked them, whether blockchain analysis firms were closing in, or whether the motive was genuinely educational, the funds started coming back on August 11. The full return took about two weeks. Poly Network offered a $500,000 bug bounty and the title of “Chief Security Advisor.” The attacker’s identity was never officially confirmed.

Why It Worked

The authorization chain was logically circular in a dangerous way:

EthCrossChainData.owner = EthCrossChainManager
EthCrossChainManager receives arbitrary cross-chain messages
EthCrossChainManager executes arbitrary calls as itself (= owner)
∴ Any cross-chain message can call any EthCrossChainData function with owner privileges

There was no whitelist of allowed target contracts. There was no whitelist of allowed functions. Administrative functions that controlled the entire validation layer of the bridge were reachable through the same execution path as ordinary token transfers.

The Fix

After the attack, Poly Network implemented a call whitelist:

mapping(address => mapping(bytes4 => bool)) public allowedCalls;

function executeCrossChainTx(
    address toContract,
    bytes memory method,
    bytes memory args
) internal {
    bytes4 selector = bytes4(keccak256(abi.encodePacked(method, "(bytes,bytes,uint64)")));
    require(allowedCalls[toContract][selector], "Call not in whitelist");
    (bool success, ) = toContract.call(abi.encodePacked(selector, args));
}

The deeper architectural fix is separating operational and administrative paths entirely. Cross-chain execution should never touch contracts that govern the cross-chain validation system itself. Changes to keeper keys should require multi-signature authorization and a time-lock—not a single cross-chain message.

What This Tells You About Cross-Chain Bridges

Cross-chain bridges are especially difficult to secure because they combine several hard problems: message authentication, replay protection, permission management across multiple chains, and arbitrary execution. Each additional chain multiplies the attack surface.

The Poly Network flaw was not exotic. It was the most ordinary access control failure: a privileged contract that executed untrusted input without restricting what that input could target. The cross-chain framing made it look complicated, but the core mistake was the same one that shows up in much simpler contracts—trusting the format of a message while ignoring its contents.


References