foreword
Original source: Cobo Global
foreword
As ETH upgrades the PoS consensus system, the ETH chain of the original PoW mechanism has successfully hard forked with the support of some communities (hereinafter referred to as ETHW). However, because some on-chain protocols were not prepared for possible hard forks at the beginning of the design, the corresponding protocols have certain security risks in the ETHW fork chain, and the most serious security risks are replay attacks.
After completing the hard fork, there were at least two attacks using the replay mechanism on the ETHW main network, namelyOmniBridgereplay attack andPolygon Bridgefirst level title
type of replay
First of all, before starting the analysis, we need to have a preliminary understanding of the types of replay attacks. Generally speaking, we divide replay attacks into two categories, namelyandandSigned message replaysecondary title
transaction replay
secondary title
Signed message replay
first level title
Attack principle of OmniBridge and Polygon Bridge
secondary title
OmniBridge
OmniBridge is a bridge used for asset transfer between xDAI and the ETH mainnet, which mainly relies on the designated validator of the bridge to submit cross-chain messages to complete the transfer of cross-link assets. In OmniBridge, the logic of the verification message submitted by the validator is as follows
function executeSignatures(bytes _data, bytes _signatures) public {
_allowMessageExecution(_data, _signatures);
bytes32 msgId;
address sender;
address executor;
uint32 gasLimit;
uint8 dataType;
uint256[2] memory chainIds;
bytes memory data;
(msgId, sender, executor, gasLimit, dataType, chainIds, data) = ArbitraryMessage.unpackData(_data);
_executeMessage(msgId, sender, executor, gasLimit, dataType, chainIds, data);
}
In this function, firstly, according to the signature check of line #L2, it is determined whether the submitted signature is signed by the specified validator, and then the data message is decoded in line #L11. From the decoded content, it is not difficult to find that the returned field contains the chainId field, so does it mean that the signed message cannot be replayed? Lets continue the analysis.
function _executeMessage(
bytes32 msgId,
address sender,
address executor,
uint32 gasLimit,
uint8 dataType,
uint256[2] memory chainIds,
bytes memory data
) internal {
require(_isMessageVersionValid(msgId));
require(_isDestinationChainIdValid(chainIds[1]));
require(!relayedMessages(msgId));
setRelayedMessages(msgId, true);
processMessage(sender, executor, msgId, gasLimit, dataType, chainIds[0], data);
}
By tracing the _executeMessage function, it is found that the function has checked the validity of chaindId in line #L11 function _isDestinationChainIdValid(uint256 _chainId) internal returns (bool res) {
return _chainId == sourceChainId();
}
function sourceChainId() public view returns (uint256) {
return uintStorage[SOURCE_CHAIN_ID];
}
By continuing to analyze the subsequent function logic, it is not difficult to find that the check for chainId actually does not use the original chainId opcode of evm to obtain the chainId of the chain itself, but directly uses the value stored in the uintStorage variable, then this value is obvious It is set by the administrator, so it can be considered that the message itself does not have a chain identifier, so in theory, it is possible to replay the signed message.
secondary title
Polygon Bridge
Like Omni Bridge, Polygon Bridge is a bridge for asset transfer between Polygon and ETH mainnet. Unlike Omni Bridge, Polygon Bridge relies on block proofs for withdrawals, the logic is as follows:
function exit(bytes calldata inputData) external override {
//...omit unimportant logic
// verify receipt inclusion
require(
MerklePatriciaProof.verify(
receipt.toBytes(),
branchMaskBytes,
payload.getReceiptProof(),
payload.getReceiptRoot()
),
"RootChainManager: INVALID_PROOF"
);
// verify checkpoint inclusion
_checkBlockMembershipInCheckpoint(
payload.getBlockNumber(),
payload.getBlockTime(),
payload.getTxRoot(),
payload.getReceiptRoot(),
payload.getHeaderNumber(),
payload.getBlockProof()
);
ITokenPredicate(predicateAddress).exitTokens(
_msgSender(),
rootToken,
log.toRlpBytes()
);
}
Through the function logic, it is not difficult to find that the contract determines the legitimacy of the message through two checks, respectively, by checking transactionRoot and BlockNumber to ensure that the transaction actually occurs in the sub-chain (Ploygon Chain). The first check can actually be bypassed, because anyone You can construct your own transactionRoot through transaction data, but the second check cannot be bypassed, because you can find out by looking at the logic of _checkBlockMembershipInCheckpoint:
function _checkBlockMembershipInCheckpoint(
uint256 blockNumber,
uint256 blockTime,
bytes32 txRoot,
bytes32 receiptRoot,
uint256 headerNumber,
bytes memory blockProof
) private view returns (uint256) {
(
bytes32 headerRoot,
uint256 startBlock,
,
uint256 createdAt,
) = _checkpointManager.headerBlocks(headerNumber);
require(
keccak256(
abi.encodePacked(blockNumber, blockTime, txRoot, receiptRoot)
)
.checkMembership(
blockNumber.sub(startBlock),
headerRoot,
blockProof
),
"RootChainManager: INVALID_HEADER"
);
return createdAt;
}
The corresponding headerRoot is extracted from the _checkpointManager contract. Following this logic, we look at the place where _checkpointManager sets the headerRoot
function submitCheckpoint(bytes calldata data, uint[3][] calldata sigs) external {
(address proposer, uint256 start, uint256 end, bytes32 rootHash, bytes32 accountHash, uint256 _borChainID) = abi
.decode(data, (address, uint256, uint256, bytes32, bytes32, uint256));
require(CHAINID == _borChainID, "Invalid bor chain id");
require(_buildHeaderBlock(proposer, start, end, rootHash), "INCORRECT_HEADER_DATA");
// check if it is better to keep it in local storage instead
IStakeManager stakeManager = IStakeManager(registry.getStakeManagerAddress());
uint256 _reward = stakeManager.checkSignatures(
end.sub(start).add(1),
/**
prefix 01 to data
01 represents positive vote on data and 00 is negative vote
malicious validator can try to send 2/3 on negative vote so 01 is appended
*/
keccak256(abi.encodePacked(bytes(hex"01"), data)),
accountHash,
proposer,
sigs
);
//....remaining logic omitted
It is not difficult to find that in the #L2 line of code, the signature data only checks the borChianId, but not the chainId of the chain itself. Since the message is signed by the proposer specified by the contract, the attacker can also theoretically Replay the proposers message signature on the forked chain, submit the legal headerRoot, and then use the Polygon Bridge to call the exit function in the ETHW chain and submit the corresponding transaction merkle proof, then the withdrawal can be successful and pass the headerRoot check.
Taking the address 0x7dbf18f679fa07d943613193e347ca72ef4642b9 as an example, this address has successfully completed the arbitrage on the ETHW chain through the following steps
First of all, rely on the banknote ability to withdraw coins from the mainnet exchange.
Deposit coins on the Polygon chain through the depositFor function of Polygon Bridge;
The ETH main network calls the exit function of Polygon Bridge to withdraw coins;
Copy and extract the headerRoot submitted by the ETH main network proposer;
Replay the signature message of the proposer extracted in the previous step in ETHW;
first level title
Why is this happening?
From the two examples analyzed above, it is not difficult to find that these two protocols encountered replay attacks on ETHW because the protocols themselves did not have good anti-replay protection, resulting in the assets corresponding to the protocols being hollowed out on the forked chain. However, since the two bridges themselves do not support the ETHW fork chain, users have not suffered any losses. But the thing we have to consider is why didnt these two bridges add replay protection measures at the beginning of their design? In fact, the reason is very simple, because whether it is OmniBridge or Polygon Bridge, the application scenarios they designed are very single, and they are only used to transfer assets to the corresponding chain designated by themselves. There is no plan for multi-chain deployment, so there is no replay protection There is no security impact on the protocol itself.
In contrast, users on ETHW, since these bridges do not support multi-chain scenarios, if users operate on the ETHW forked chain, they will suffer message replay attacks on the ETH main network instead.
Taking UniswapV2 as an example, currently in the pool contract of UnswapV2, there is a permit function, and there is a variable PERMIT_TYPEHASH in this function, which contains the variable DOMAIN_SEPARATOR.
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, UniswapV2: EXPIRED);
bytes32 digest = keccak256(
abi.encodePacked(
\x19\x01,
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
)
);
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, UniswapV2: INVALID_SIGNATURE);
_approve(owner, spender, value);
}
This variable was first defined in EIP712, which contains chainId, which included possible replay prevention for multi-chain scenarios at the beginning of the design, but according to the logic of the uniswapV2 pool contract, it is as follows:
constructor() public {
uint chainId;
assembly {
chainId := chainid
}
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256(EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)),
keccak256(bytes(name)),
keccak256(bytes(1)),
chainId,
address(this)
)
);
}
first level title
Precautions at the beginning of protocol design
first level title
secondary title
Impact on users
secondary title
Implications for exchanges and custodians
Summarize
Summarize
With the development of multi-chain scenarios, replay attacks have gradually become a mainstream attack method from the theoretical level. Developers should carefully consider the protocol design. When designing the message signature mechanism, add factors such as chainId as the signature content as much as possible. And follow the relevant best practices to prevent the loss of user assets.