作者:Cobo安全團隊原文: 《Cobo安全團隊——ETH 硬分叉裡那些隱藏的風險和套利機會》
前言
隨著ETH升級PoS共識系統,原有的PoW機制的ETH鏈在部分社區的支持下成功硬分叉(下文簡稱ETHW )。但是,由於某些鏈上協議在設計之初沒有對可能的硬分叉做好準備,導致對應的協議在ETHW分叉鏈存在一定的安全隱患,其中最為嚴重的安全隱患則是重放攻擊。
在完成硬分叉後, ETHW主網出現了至少2起利用重放機制進行的攻擊,分別是OmniBridge的重放攻擊和Polygon Bridge的重放攻擊。本文將以這兩個事件作為案例,分別分析重放攻擊對分叉鏈的影響,以及協議應如何防範此類攻擊。
重放的類型
首先,在開始分析之前,我們需要先對重放攻擊的類型做一個初步的了解,一般而言,我們對重放攻擊分成兩類,分別是交易重放和簽名消息重放。下面,我們來分別說下這兩類重放機制的區別
交易重放
交易重放指的是將在原有鏈的交易原封不動的遷移到目標鏈的操作,屬於是交易層面上的重放,重放過後交易也是可以正常執行並完成交易驗證。最著名的案例莫過於Wintermute在Optimism上的攻擊事件,直接導致了超2000萬OP代幣的損失。但是在EIP 155實施以後,由於交易的簽名本身帶有chainId (一種用於鏈本身區別與其他分叉鏈的標識符),在重放的目標鏈chainId不同的情況下,交易本身是無法完成重放的。
簽名消息重放
簽名消息重放區別於交易重放,是針對的用私鑰簽名的消息(eg Cobo is the best ) 進行的重放,在簽名消息重放中,攻擊者不需要對整個交易進行重放,而只需將簽名的消息進行重放即可。在消息簽名中,以Cobo is the best為例,由於該消息中並不含任何和鏈相關的特殊參數,所以該消息在簽名後理論上是可以在任意的分叉鏈中均是有效的,可以驗簽通過。為了避免該消息在分叉上的重放,可以消息內容中添加chainId ,如Cobo is the best + chainId() 。在帶上特定的鏈標識符之後,在不同分叉鏈上的消息內容不同,消息簽名不同,因此無法直接進行重放復用。
OmniBridge 和Polygon Bridge 的攻擊原理
下面我們來分析OmniBridge和Polygon Bridge的攻擊原理。首先拋出結論,這兩起攻擊事件本身都不是交易重放攻擊,原因在於ETHW使用了區別於ETH主網的chainId ,所以直接重放交易無法被驗證通過。那麼剩下的選項就只有消息重放了,那下面我們就來逐個分析它們各自是如何在ETHW分叉鏈上被消息重放攻擊的。
OmniBridge
OmniBridge是用於在xDAI和ETH主網之間進行資產轉移而使用的橋,主要依賴橋的指定的validator提交跨鏈消息完成跨鏈接資產的轉移。在OmniBridge中, validator提交的驗證消息的邏輯是這樣的
functionexecuteSignatures(bytes_data,bytes_signatures)public{
_allowMessageExecution(_data,_signatures);
bytes32msgId;
addresssender;
addressexecutor;
uint32gasLimit;
uint8dataType;
uint256[2]memorychainIds;
bytesmemorydata;
(msgId,sender,executor,gasLimit,dataType,chainIds,data)=ArbitraryMessage.unpackData(_data);
_executeMessage(msgId,sender,executor,gasLimit,dataType,chainIds,data);
}
在這個函數中,首先會根據#L2 行的簽名檢查來確定提交的簽名是不是由指定的validator進行簽名,然後再在#L11 行對data消息進行解碼。從解碼內容上看,不難發現,返回字段中包含了chainId字段,那麼是不是說明無法進行簽名消息重放呢?我們繼續分析。
function_executeMessage(
bytes32msgId,
addresssender,
addressexecutor,
uint32gasLimit,
uint8dataType,
uint256[2]memorychainIds,
bytesmemorydata
)internal{
require(_isMessageVersionValid(msgId));
require(_isDestinationChainIdValid(chainIds[1]));
require(!relayedMessages(msgId));
setRelayedMessages(msgId,true);
processMessage(sender,executor,msgId,gasLimit,dataType,chainIds[0],data);
}
通過追查_executeMessage函數,發現函數在#L11 行對chaindId進行了合法性的檢查
function_isDestinationChainIdValid(uint256_chainId)internalreturns(boolres){
return_chainId==sourceChainId();
}
functionsourceChainId()publicviewreturns(uint256){
returnuintStorage[SOURCE_CHAIN_ID];
}
通過繼續分析後續的函數邏輯,不難發現其實針對chainId的檢查其實並沒有使用evm原生的chainId操作碼來獲取鏈本身的chainId ,而是直接使用存儲在uintStorage變量中的值,那這個值很明顯是管理員設置進去的,所以可以認為消息本身並不帶有鏈標識,那麼理論上就是可以進行簽名消息重放的。
由於在硬分叉過程中,分叉前的所有狀態在兩條鏈上都會原封不動的保留,在後續xDAI團隊沒有額外操作的情況下。分叉後ETHW和ETH主網上Omni Bridge合約的狀態是不會有變化的,也就是說合約的validator也是不會有變化的。根據這一個情況,我們就能推斷出validator在主網上的簽名也是可以在ETHW上完成驗證的。那麼,由於簽名消息本身不包含chainId ,攻擊者就可以利用簽名重放,在ETHW上提取同一個合約的資產。
Polygon Bridge
和Omni Bridge一樣, Polygon Bridge是用於在Polygon和ETH主網進行資產轉移的橋。與Omni Bridge不同, Polygon Bridge依賴區塊證明進行提款,邏輯如下:
functionexit(bytescalldatainputData)externaloverride{
//...省略不重要邏輯
//verifyreceiptinclusion
require(
MerklePatriciaProof.verify(
receipt.toBytes(),
branchMaskBytes,
payload.getReceiptProof(),
payload.getReceiptRoot()
),
"RootChainManager:INVALID_PROOF"
);
//verifycheckpointinclusion
_checkBlockMembershipInCheckpoint(
payload.getBlockNumber(),
payload.getBlockTime(),
payload.getTxRoot(),
payload.getReceiptRoot(),
payload.getHeaderNumber(),
payload.getBlockProof()
);
ITokenPredicate(predicateAddress).exitTokens(
_msgSender(),
rootToken,
log.toRlpBytes()
);
}
通過函數邏輯,不難發現合約通過2個檢查確定消息的合法性,分別是通過檢查t ransactionRoot和BlockNumber來確保交易真實發生在子鏈(Ploygon Chain),第一個檢查其實可以繞過,因為任何人都可以通過交易數據來構造屬於自己的transactionRoot ,但是第二個檢查是無法繞過的,因為通過查看_checkBlockMembershipInCheckpoint 邏輯可以發現:
function_checkBlockMembershipInCheckpoint(
uint256blockNumber,
uint256blockTime,
bytes32txRoot,
bytes32receiptRoot,
uint256headerNumber,
bytesmemoryblockProof
)privateviewreturns(uint256){
(
bytes32headerRoot,
uint256startBlock,
,
uint256createdAt,
)=_checkpointManager.headerBlocks(headerNumber);
require(
keccak256(
abi.encodePacked(blockNumber,blockTime,txRoot,receiptRoot)
)
.checkMembership(
blockNumber.sub(startBlock),
headerRoot,
blockProof
),
"RootChainManager:INVALID_HEADER"
);
returncreatedAt;
}
對應的headerRoot是從_checkpointManager合約中提取的,順著這個邏輯我們查看_checkpointManager設置headerRoot的地方
functionsubmitCheckpoint(bytescalldatadata,uint[3][]calldatasigs)external{
(addressproposer,uint256start,uint256end,bytes32rootHash,bytes32accountHash,uint256_borChainID)=abi
.decode(data,(address,uint256,uint256,bytes32,bytes32,uint256));
require(CHAINID==_borChainID,"Invalidborchainid");
require(_buildHeaderBlock(proposer,start,end,rootHash),"INCORRECT_HEADER_DATA");
//checkifitisbettertokeepitinlocalstorageinstead
IStakeManagerstakeManager=IStakeManager(registry.getStakeManagerAddress());
uint256_reward=stakeManager.checkSignatures(
end.sub(start).add(1),
/**
prefix01todata
01representspositivevoteondataand00isnegativevote
maliciousvalidatorcantrytosend2/3onnegativevoteso01isappended
*/
keccak256(abi.encodePacked(bytes(hex"01"),data)),
accountHash,
proposer,
sigs
);
//....剩餘邏輯省略
不難發現在#L2 行代碼中,簽名數據僅對borChianId進行了檢查,而沒有對鏈本身的chainId進行檢查,由於該消息是由合約指定的proposer進行簽名的,那麼理論上攻擊者也可以在分叉鏈上重放proposer的消息簽名,提交合法的headerRoot ,後續再通過Polygon Bridge進行在ETHW鏈中調用exit函數並提交相應的交易merkle proof後就可以提現成功並通過headerRoot的檢查。
以地址0x7dbf18f679fa07d943613193e347ca72ef4642b9為例,該地址就成功通過以下幾步操作完成了對ETHW鏈的套利
首先依靠鈔能力主網交易所提幣。
在Ploygon鏈上通過Polygon Bridge的depositFor函數進行充幣;
ETH主網調用Polygon Bridge的exit函數提幣;
複製提取ETH主網proposer提交的headerRoo t;
在ETHW中重放上一步提取的proposer的簽名消息;
在ETHW中的Polygon Bridge上調用exit進行提幣
為什麼會發生這種情況?
從上面分析的兩個例子中,不難發現這兩個協議在ETHW上遭遇重放攻擊是因為協議本身沒有做好防重放的保護,導致協議對應的資產在分叉鏈上被掏空。但是由於這兩個橋本身並不支持ETHW分叉鏈,所以用戶並沒有遭受任何損失。但我們要考慮的事情是為什麼這兩個橋在設計之初就沒有加入重放保護的措施呢?其實原因很簡單,因為無論是OmniBridge還是Polygon Bridge ,他們設計的應用場景都非常單一,只是用於到自己指定的對應鏈上進行資產轉移,並沒有一個多鏈部署的計劃,所以沒有重放保護而言對協議本身並不造成安全影響。
反觀ETHW上的用戶,由於這些橋本身並不支持多鏈場景,如果用戶在ETHW分叉鏈上進行操作的話,反而會在ETH主網上遭受消息重放攻擊。
以UniswapV2為例,目前在UnswapV2的pool合約中,存在permit函數,該函數中存在變量PERMIT_TYPEHASH ,其中包含變量DOMAIN_SEPARATOR 。
functionpermit(addressowner,addressspender,uintvalue,uintdeadline,uint8v,bytes32r,bytes32s)external{
require(deadline>=block.timestamp,'UniswapV2:EXPIRED');
bytes32digest=keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH,owner,spender,value,nonces[owner]++,deadline))
)
);
addressrecoveredAddress=ecrecover(digest,v,r,s);
require(recoveredAddress!=address(0)&&recoveredAddress==owner,'UniswapV2:INVALID_SIGNATURE');
_approve(owner,spender,value);
}
此變量最早在EIP712中定義,該變量中含有chainId ,在設計之初就包含可能的多鏈場景的重放預防,但是根據uniswapV2 pool合約的邏輯,如下:
constructor()public{
uintchainId;
assembly{
chainId:=chainid
}
DOMAIN_SEPARATOR=keccak256(
abi.encode(
keccak256('EIP712Domain(stringname,stringversion,uint256chainId,addressverifyingContract)'),
keccak256(bytes(name)),
keccak256(bytes('1')),
chainId,
address(this)
)
);
}
DOMAIN_SEPARATOR在構造函數中已經定義好,也就是說在硬分叉後,就算鏈本身的chainId已經改變, pool合約也無法獲取到新的chianId來更新DOMAIN_SEPARATOR ,如果未來用戶在ETHW上進行相關授權,那麼ETHW上的permit簽名授權可以被重放到ETH主網上。除了Uniswap外,類似的協議還有很多,比如特定版本下的yearn vault合約,同樣也是採用了固定DOMAIN_SEPARATOR的情況。用戶在ETHW上交互的時候也需要防範此類協議的重放風險。
協議設計之初的防範措施
對於開發者而言,在為協議本身定制消息簽名機制的時候,應該考慮後續可能的多鏈場景,如果路線圖中存在多鏈部署的可能,應該把chainId作為變量加入到簽名消息中,同時,在驗證簽名的時候,由於硬分叉不會改變分叉前的任何狀態,用於驗證簽名消息的chainId不應該設置為合約變量,而應該在每次驗證前重新獲取,然後進行驗簽,保證安全性。
影響
對用戶的影響
普通在協議不支持分叉鏈的情況下,應盡量不在分叉鏈上進行任何操作,防止對應的簽名消息重放到主網上,造成用戶在主網上損失資產
對交易所和託管機構的影響
由於很多交易所本身都支持了ETHW代幣,所以這些由於攻擊而提取出來的代幣都有可能充值到交易所中進行拋售,但需要注意的是,此類攻擊並不是鏈共識本身的問題而導致的惡意增發,所以對交易所而言,此類攻擊無需進行額外的防範
總結
隨著多鏈場景的發展,重放攻擊從理論層面逐步變成主流的攻擊方式,開發者應當仔細考量協議設計,在進行消息簽名機制的設計時,盡可能的加入chainId等因子作為簽名內容,並遵循相關的最佳實踐,防止用戶資產的損失。