Poly Network Cross-Chain (2021)
Smart contract security audit by TierZero · 2023-04-15
Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.
Findings summary
Independent research — not a commissioned audit. TierZero was not engaged by Poly Network; this write-up is based on public post-mortems and on-chain data.
What the protocol did
Poly Network is a cross-chain interoperability layer moving assets between otherwise incompatible chains — Ethereum, BSC, and Polygon at the time of the incident. Each chain ran a LockProxy contract holding locked assets, and an EthCrossChainManager contract acting as entry point for incoming cross-chain messages. A rotating set of "keepers" — off-chain relayers whose public keys were stored on each chain in an EthCrossChainData contract — signed attestations for transactions originating on one chain, which the destination chain's manager verified before releasing funds. In August 2021 an attacker used this design to grant themselves permission to move essentially anything the protocol held, draining roughly $610 million across the three chains in a single afternoon — at the time the largest DeFi exploit on record.
The vulnerability
The bug was not in the signature verification math. It was in what the verified message was allowed to do. EthCrossChainManager.verifyHeaderAndExecuteTx checked that a cross-chain proof was signed by the current keeper set, then handed the destination contract address and a raw method payload straight to a low-level call:
// EthCrossChainManager.sol (simplified)
function _executeCrossChainTx(
address toContract,
bytes memory method, // selector + args, taken verbatim from the cross-chain payload
bytes memory args,
bytes32 fromChainTxHash
) internal returns (bool) {
(bool success, ) = toContract.call(
abi.encodePacked(bytes4(keccak256(method)), args)
);
require(success, "EXECUTE_CROSSCHAIN_TX_FAILED");
return true;
}
EthCrossChainData, the contract holding the trusted keeper public keys, gated its state-changing functions with onlyOwner, and owner was set to the address of EthCrossChainManager itself:
// EthCrossChainData.sol (simplified)
modifier onlyOwner() {
require(msg.sender == owner); // owner == EthCrossChainManager
_;
}
function putCurEpochConPubKeyBytes(bytes memory keepers) public onlyOwner {
currEpochConPubKeyBytes = keepers; // overwrites the trusted keeper set
}
onlyOwner checked who was calling, not which selector the payload was meant to reach. Because the manager's dispatcher would call any selector on any target contract that a validly-signed message named, putCurEpochConPubKeyBytes — an administrative, keeper-rotation function — was just as reachable as an ordinary asset-unlock call. Privileged and business-logic function spaces were never separated; anything the manager was authorized to call, a crafted payload could call, given a signature from any valid historical transaction. That's the selector-collision pattern: one generic dispatch surface with no allowlist distinguishing "safe to reach externally" from "must never be reachable that way."
How the exploit worked
- The attacker built a cross-chain transaction whose destination contract was
EthCrossChainDataand whose method field encoded the selector forputCurEpochConPubKeyBytes. - The payload's arguments substituted the attacker's own public key for the legitimate keeper set.
- Because
EthCrossChainManagerwas the recognizedownerofEthCrossChainData, and its dispatcher didn't restrict which functions a message could target, the call succeeded and silently overwrote the trusted keepers on Ethereum, and separately on BSC and Polygon. - From that point, anything the attacker signed with their own key counted as a validly-signed message from "the keepers." They crafted further transactions instructing each chain's
LockProxyto release its balances to attacker-controlled addresses. - Roughly $610M moved out across the three chains before the team could react. The attacker, who described the act as exposing a flaw rather than pure theft, returned nearly all funds within about a week of public negotiation; Poly Network offered them a bug-bounty/advisor role.
How an audit catches this
This is a textbook finding for a call-target and access-control review, not something that needs exotic tooling. The concrete checks we run:
- Enumerate every externally reachable low-level
call/delegatecallwhere the target address or calldata — including the selector — is influenced by user- or message-supplied input, and require an explicit allowlist of permitted selectors rather than "anything the caller happens to own." - Trace
onlyOwner/onlyAuthorizedchains to their root. If a privileged contract's owner is itself a contract with a generic dispatch function, that dispatcher inherits every privilege of the owner — flag any administrative function (keeper rotation, upgrade, pause-lift, fee-sink change) reachable through a path meant for routine business calls. - Write a proof-of-concept that reaches the sensitive selector through the "normal" entry point, not just unit tests with expected inputs — the same instinct we apply when checking whether an Anchor discriminator can be spoofed to alias one account type as another, covered in our piece on discriminator type-confusion attacks.
- Invariant test: keeper/validator set changes require a distinct, non-message-driven code path — never one shared with asset-movement logic.
This "the trusted caller can reach more than it should" pattern isn't EVM-specific — the same root cause, an overly generic call surface, shows up whenever a program blindly forwards an external instruction to another module, which is the risk we walk through for Solana's inter-program calls in our CPI risk analysis. A smart contract audit engagement exists precisely to catch this before mainnet, by mapping every privileged function back to every path that can reach it, not just the intended one.
Remediation
- Separate privileged administrative functions (keeper rotation, upgrades, emergency controls) from the generic cross-chain dispatch path entirely — different contract, different access-control root, ideally a different key.
- Require a timelock and multisig/quorum threshold specifically on keeper-set changes, independent of routine message processing, so a single forged message can't take effect immediately.
- Replace "owner is a contract with a generic call forwarder" with an explicit selector allowlist enforced at the point of dispatch, not merely at the point of ownership.
- Add real-time monitoring that alerts on any change to the keeper/validator public key set — that state transition should never happen silently.
- Treat message verification and message authorization scope as two separate properties to test; a valid signature says nothing about whether the signed action should be permitted.
Deep review of privilege-boundary logic, not just syntax, is what a line-by-line code review is built to surface before a keeper-rotation bug like this one reaches mainnet. If your protocol has a similar generic dispatcher sitting between untrusted input and privileged state, it's worth mapping that surface before an attacker does — see our smart contract audit service.
Thinking about a review like this for your own contracts? Get a smart contract audit from an independent team.
Need your contracts audited?
Manual review + tooling across TON, Solana and EVM — certificate and full report included.
Get an audit