往期回顧:
- Rust智能合約養成日記(1)合約狀態數據定義與方法實現
- Rust智能合約養成日記(2)編寫Rust智能合約單元測試
- Rust智能合約養成日記(3)Rust智能合約部署,函數調用及Explorer的使用
- Rust智能合約養成日記(4)Rust 智能合約整數溢出
這一期中我們將向大家展示Rust合約中重入攻擊,並提供給開發者相應的建議。本文中的相關代碼,已上傳至BlockSec的Github上,讀者可以自行下載:https://github.com/blocksecteam/near_demo
1. 重入攻擊原理
我們用現實生活中的簡單例子來理解重入攻擊:即假設某用戶在銀行中存有100元現金,當用戶想要從銀行中取錢時,他將首先告訴櫃員-A:“我想要取60元”。櫃員-A此時將查詢用戶的餘額為100元,由於該餘額大於用戶想要取出的數額,所以櫃員-A首先將60元現金交給了該位用戶。但是當櫃員-A還沒有來得及將用戶的餘額更新為40元的時,用戶跑去隔壁告訴另一位櫃員-B:“我想要取60元”,並隱瞞了剛才已經向櫃員-A取錢的事實。由於用戶的餘額還沒有被櫃員-A更新,櫃員-B檢查用戶的餘額仍舊為100元,因此櫃員-B將毫不猶豫地繼續將60元交給用戶。最終用戶實際已經獲得了120元現金,大於之前存在銀行中的100元現金。
為什麼會發生這樣的事情呢?究其原因還是因為櫃員-A沒有事先將用戶的60元從該用戶的賬戶中扣除。若櫃員-A能事先扣除金額。用戶再詢問櫃員-B取錢時,櫃員-B就會發現用戶的餘額已更新,無法取出比餘額(40元)更多的現金了。
以上述“從銀行取錢”這一典型過程為例,映射到具體的智能合約世界中來,實際上跨合約調用行為的發生和真正更新本地所維護的合約數據之間也同樣地存在一定的時間間隔。而該時間間隔的存在以及這兩個步驟之前不恰當的順序關係,將給攻擊者實施重入攻擊創造有利條件。
下文第2小節將首先介紹相關的背景知識,第3小節將在NEAR LocalNet中演示說明一個具體的重入攻擊例子,以體現代碼重入對於部署在NEAR鏈上的智能合約的危害性。本文最後將具體介紹針對重入攻擊的防護技術,幫助大家更好的編寫Rust智能合約。
2. 背景知識:NEP141的轉賬操作
NEP141為NEAR公鏈上的Fungible Token (以下均用Token簡稱)標準 。大部分NEAR上的Token都遵循NEP141標準。
當某一用戶想要從某一個Pool中,如去中心化交易所 (DEX), 充值(deposite)或者提現(withdraw)一定數額的Token時,用戶便可以調用相應的合約接口完成具體的操作。
DEX項目合約在執行所對應的接口函數時,將調用Token合約中的ft_transfer/ft_transfer_call函數,實現正式的轉賬操作。這兩個函數的區別如下:
- 當調用Token合約中的ft_transfer函數時,轉賬的接收者(receiver_id)為EOA賬戶。
- 當調用Token合約中的ft_transfer_call函數時,轉賬的接收者(receiver_id)為合約賬戶。
而對於ft_transfer_call
而言,該方法內部除了首先會扣除該筆交易發起者(sender_id)的轉賬數額,並增加受轉賬用戶(receiver_id)的餘額,此外還額外增加了對receiver_id合約中ft_on_transfer(收幣函數)的跨合約調用。這裡可以簡單理解為,此時Token合約將提醒receiver_id合約,有用戶存入了指定數額的Token。receiver_id合約將在ft_on_transfer函數中自行維護內部賬戶的餘額管理。
3. 代碼重入的具體實例
假設存在如下3個智能合約:
- 合約A: Attacker合約;
攻擊者將利用該合約實施後續的攻擊交易。 - 合約B: Victim合約。
為一個DEX合約。初始化的時候,Attacker賬戶擁有餘額100,DEX的其他用戶擁有餘額100。即此時DEX合約總共持有了200個Token。
#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize)]
pub struct VictimContract {
attacker_balance: u128,
other_balance: u128,
}
impl Default for VictimContract {
fn default() -> Self {
Self {
attacker_balance: 100,
other_balance:100
}
}
}
合約C: Token合約 (NEP141)。
攻擊發生前,因為Attacker賬戶沒有從Victim合約提現,所以餘額為0,此時Victim合約(DEX)的餘額為100+100 =200;
#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize)]
pub struct FungibleToken {
attacker_balance: u128,
victim_balance: u128
}
impl Default for FungibleToken {
fn default() -> Self {
Self {
attacker_balance: 0,
victim_balance: 200
}
}
下面描述該代碼重入攻擊的具體流程:
- Attacker合約通過
malicious_call
函數,調用Victim合約(合約B)中的withdraw
函數;
例如此時Attacker給withdraw函數傳入amount參數的值為60,希望從合約B中提現60;
impl MaliciousContract {
pub fn malicious_call(&mut self, amount:u128){
ext_victim::withdraw(
amount.into(),
&VICTIM,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL
);
}
...
}
- 在合約B中,
withdraw函數開頭處的
assert!(self.attacker_balance>= amount);`將檢查Attacker賬戶是否有足夠的餘額,此時餘額100>60,將通過斷言,執行withdraw中後續的步驟。
impl VictimContract {
pub fn withdraw(&mut self,amount: u128) -> Promise{
assert!(self.attacker_balance>= amount);
// Call Attacker的收幣函數
ext_ft_token::ft_transfer_call(
amount.into(),
&FT_TOKEN,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
)
.then(ext_self::ft_resolve_transfer(
amount.into(),
&env::current_account_id(),
0,
GAS_FOR_SINGLE_CALL,
))
}
...
}
- 合約B中的withdraw函數接着將調用合約C(FT_Token合約)中的ft_transfer_call函數;
通過上述代碼中的ext_ft_token::ft_transfer_call實現跨合約調用。
- 合約C中的ft_transfer_call函數,將更新attacker賬戶的餘額 = 0 + 60 = 60,以及Victim合約賬戶的餘額 = 200 – 60 = 140,隨後通過ext_fungible_token_receiver::ft_on_transfer調用合約A的ft_on_transfer“收幣”函數。
#[near_bindgen]
impl FungibleToken {
pub fn ft_transfer_call(&mut self,amount: u128)-> PromiseOrValue<U128>{
// 相當於 internal_ft_transfer
self.attacker_balance += amount;
self.victim_balance -= amount;
// Call Attacker的收幣函數
ext_fungible_token_receiver::ft_on_transfer(
amount.into(),
&ATTACKER,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL
).into()
}
...
}
- 由於合約A被Attacker所控制,並且代碼存在惡意的行為。所以該“惡意”的ft_on_transfer函數可以再次通過執行
ext_victim::withdraw
,調用合約B中的withdraw函數,以此達到重入的效果。
#[near_bindgen]
impl MaliciousContract {
pub fn ft_on_transfer(&mut self, amount: u128){
// 惡意合約的收幣函數
if self.reentered == false{
ext_victim::withdraw(
amount.into(),
&VICTIM,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL
);
}
self.reentered = true;
}
...
}
- 由於上一次進入withdraw以來,victim合約中的attacker_balance還沒有更新,所以還是100,因此此時仍舊可以通過
assert!(self.attacker_balance>= amount)
的檢查。withdraw後續將再次在FT_Token合約中跨合約調用ft_transfer_call函數,更新attacker賬戶的餘額 = 60 + 60 = 120,以及Victim合約賬戶的餘額 = 140 – 60 = 80; - ft_transfer_call再次調用回Attacker合約中的ft_on_transfer函數。由於目前設置合約A中ft_on_transfer函數只會重入withdraw函數一次,所以重入行為在本次ft_on_transfer的調用時終止。
- 此後函數將沿着之前的調用鏈逐級返回,導致合約B中的withdraw函數中在更新self.attacker_balance的時候,最終使得self.attacker_balance = 100 -60 -60 = -20
- 由於self.attacker_balance是u128,且並沒有使用safe_math,因此將導致整數的溢出現象。
最終執行的結果如下:
$ node Triple_Contracts_Reentrancy.js
Finish init NEAR
Finish deploy contracts and create test accounts
Victim::attacker_balance:3.402823669209385e+38
FT_Token::attacker_balance:120
FT_Token::victim_balance:80
即儘管用戶Attacker在DEX中鎖定的FungibleToken餘額僅100,但是最終Attacker實際獲得的轉賬為120,實現了本次代碼重入攻擊的目的。
4. 代碼重入防護技術
4.1 先更新和與狀態(先扣錢),再轉賬。
更改合約B代碼 withdraw中的執行邏輯為:
#[near_bindgen]
impl VictimContract {
pub fn withdraw(&mut self,amount: u128) -> Promise{
assert!(self.attacker_balance>= amount);
self.attacker_balance -= amount;
// Call Attacker的收幣函數
ext_ft_token::ft_transfer_call(
amount.into(),
&FT_TOKEN,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
)
.then(ext_self::ft_resolve_transfer(
amount.into(),
&env::current_account_id(),
0,
GAS_FOR_SINGLE_CALL,
))
}
#[private]
pub fn ft_resolve_transfer(&mut self, amount: u128) {
match env::promise_result(0) {
PromiseResult::NotReady => unreachable!(),
PromiseResult::Successful(_) => {
}
PromiseResult::Failed => {
// 若ext_ft_token::ft_transfer_call跨合約調用轉賬失敗,
// 則回滾之前賬戶餘額狀態的更新
self.attacker_balance += amount;
}
};
}
此時的執行效果如下:
$ node Triple_Contracts_Reentrancy.js
Finish init NEAR
Finish deploy contracts and create test accounts
Receipt: 873C5WqMyaXBFM3dmoR9t1sSo4g5PugUF8ddvmBS6g3X
Failure [attacker.test.near]: Error: {"index":0,"kind":{"ExecutionError":"Smart contract panicked: panicked at 'assertion failed: self.attacker_balance >= amount', src/lib.rs:45:9"}}
Victim::attacker_balance:40
FT_Token::attacker_balance:60
FT_Token::victim_balance:140
可見由於此時的Victim合約在withdraw的時候事先更新了用戶的餘額,在調用外部的FungibleToken實施轉賬。因此當第二次重入了withdraw的時候,Victim合約中保存的attacker_balance已經更新為40,因此將無法通過assert!(self.attacker_balance>= amount);
使得Attcker的調用流程由於觸發了Assertion Panic,無法利用代碼重入進行套利。
4.2 引入互斥鎖
該方法類似於當櫃員-A還沒有來得及將用戶的餘額更新為40元的時,用戶跑去隔壁告訴另一位櫃員-B:“我想要取60元”。儘管用戶隱瞞了剛才已經向櫃員-A取錢的事實。但是櫃員-B卻能夠知道用戶已經去過櫃員-A那裡,並且還沒有辦結所有的事項,此時櫃員-B便可以拒絕用戶來取錢。通常情況下可以通過引入一個狀態變量,來實現一個互斥鎖
4.3 設置Gas Limit
例如在DEX合約的withdraw方法調用ext_ft_token::ft_transfer_call時,設置一個適當的Gas Limit。此Gas Limit將不夠支持下一次代碼再次重入DEX合約的withdraw函數,以此阻斷重入攻擊的能力。
例如對代碼做如下修改,限制withdraw方法調用外部函數時的Gas Limit:
pub fn withdraw(&mut self,amount: u128) -> Promise{
assert!(self.attacker_balance>= amount);
// Call Attacker的收幣函數
ext_ft_token::ft_transfer_call(
amount.into(),
&FT_TOKEN,
0,
- env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
+ GAS_FOR_SINGLE_CALL * 3
)
.then(ext_self::ft_resolve_transfer(
amount.into(),
&env::current_account_id(),
0,
GAS_FOR_SINGLE_CALL,
))
}
修改後執行效果如下
$ node Triple_Contracts_Reentrancy.js
Finish init NEAR
Finish deploy contracts and create test accounts
Receipt: 5xsywUr4SePqfuotLXMragAC8P6wJuKGBuy5CTJSxRMX
Failure [attacker.test.near]: Error: {"index":0,"kind":{"ExecutionError":"Exceeded the prepaid gas."}}
Victim::attacker_balance:40
FT_Token::attacker_balance:60
FT_Token::victim_balance:140
可見限制跨合約函數調用時的Gas Limit也能起到防止重入攻擊的效果。
本期總結和預告
這一期我們講述了rust智能合約中的整數溢出問題,同時給出了建議,在書寫代碼時盡量先更新狀態,再執行轉賬操作,並且設定合適的gas值,可以有效抵禦重入攻擊,下一期我們將講述rust智能合約中的DoS問題,敬請關注。
本文鏈接:https://www.8btc.com/article/6708655
轉載請註明文章出處