Lost
Came back
Attacked
Audit
EthCrossChainManager executes cross-chain messages by calling whatever contract a message names, and it owned EthCrossChainData, the contract holding the keepers' public keys. A message aimed at EthCrossChainData, with a method name chosen so its 4-byte selector matched putCurEpochConPubKeyBytes(bytes), replaced the keepers with the attacker's key; the attacker then signed withdrawals from LockProxy on three chains.
bridgecross-chain messageselector collisionownership
Relayers.send
Sends a cross-chain transaction from another chain. Its target is EthCrossChainData; its method name was searched for until its 4-byte selector equalled that of putCurEpochConPubKeyBytes(bytes).
EthCrossChainManager.verifyHeaderAndExecuteTx
The message is carried and signed like any other, and the manager verifies and executes it: it calls the named target with a selector built from the method name. The only check on the target is that it is a contract.
Missing check. Any contract could be the target, including the manager's own key store.
EthCrossChainData.putCurEpochConPubKeyBytes
The call comes from EthCrossChainManager, EthCrossChainData's owner, so onlyOwner passes: the keepers' public keys are replaced with the attacker's.
Missing check. The owner of the key store was a contract that executes arbitrary messages.
EthCrossChainManager.verifyHeaderAndExecuteTx
Now the keeper, the attacker signs withdrawal messages, and the manager accepts them.
LockProxy.unlock
LockProxy releases the locked assets to the attacker. The same was repeated on BSC and Polygon.
Fund flow, in order
A selector is only four bytes, so a name colliding with any function can be searched for; and the contract allowed to change the keepers would call any contract a message named. Together, a message could rewrite the keepers.
The invariant that would have failed
The keeper set changes only through the keeper-change flow, signed by the current keepers.
function test_message_cannotReachTheKeyStore() public { bytes memory message = _messageTo(address(ccData)); // any method name vm.expectRevert("privileged target"); manager.verifyHeaderAndExecuteTx(message, proof, header, headerProof, curHeader, headerSig);}