往期回顧:
拒絕服務攻擊又稱DoS (Denial of Service)攻擊,該類型的攻擊將使得智能合約在一段時間內(甚至永久)無法被用戶正常使用。
目前已知的原因大致可分為如下兩類:
合約邏輯中存在的某些缺陷。如某一public函數,其實現沒有考慮到計算複雜度。用戶調用該函數時,實際所需消耗的Gas會超出NEAR公鏈創世區塊配置文件(
genesis_config.json
)中所定義的' max_total_prepaid_gas ': 300000000000000` (300TGas),導致交易失敗。某些跨合約調用情形中,合約的執行依賴於其他外部合約的執行狀態。而外部合約的執行並非總是可靠,以至於本合約的執行可能被外部合約阻塞,無法照常運行。該類問題的發生可表現為合約用戶在合約中的資金被鎖定,以至於無法正常的充值或提現。
除了合約邏輯的缺陷,DoS現象發生的原因還可以歸因於人為因素:典型的如:合約的所有者丟失了自己的私鑰,以至於合約中部分only_owner可執行的特權函數無法被調用,使得合約中某些重要的系統狀態值無法及時的更新,這將有可能對項目造成較大的損失。
為方便讀者更加深刻地了解智能合約中的DoS攻擊漏洞,本文後續將結合具體DoS攻擊的例子展開描述與分析。本文代碼已上傳至BlockSec官方github,讀者可以自行下載https://github.com/blocksecteam/near_demo/tree/main/DoSDemo
1. 循環遍歷一個可被外部調用更改的數據結構
以下是一個用於給合約中註冊用戶“分紅”的簡單智能合約,其狀態數據如下:
#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize)]
pub struct Contract {
pub registered: Vec<AccountId>,
pub accounts: UnorderedMap<AccountId, Balance>,
}
用戶可通過調用pub fn register_account()
函數進行註冊並初始化。
pub fn register_account(&mut self ) {
if self .accounts.insert(&env::predecessor_account_id(), & 0 ).is_some() {
env::panic( 'The account is already registered' .to_string().as_bytes());
} else {
self .registered.push(env::predecessor_account_id());
}
log!( 'Registered account{}' ,env::predecessor_account_id());
}
後續該合約的管理者將調用pub fn distribute_token
函數來為系統中用戶進行'分紅'。 “分紅”的方式為遍歷用戶數組self.registered
,並通過跨合約調用向每一個用戶轉入指定額度amount的代幣以做獎勵。
pub fn distribute_token(&mut self , amount: u128) {
// 声明了只有指定的用户可以前来分发“奖励”给其他用户;
assert_eq!(env::predecessor_account_id(),DISTRIBUTOR, 'ERR_NOT_ALLOWED' );
// 遍历系统中注册的用户
for cur_account in self .registered.iter(){
let balance = self .accounts.get(&cur_account).expect( 'ERR_GET' );
self .accounts.insert(&cur_account,&balance.checked_add(amount).expect( 'ERR_ADD' ));
log!( 'Try distribute to account{}' ,&cur_account);
ext_ft_token::ft_transfer(
cur_account. clone (),
amount,
&FTTOKEN,
0 ,
GAS_FOR_SINGLE_CALL
);
}
}
然而該合約狀態數據( self.registered
)的大小沒有限制,並且可以被惡意用戶所操控,使得該合約數據的大小變得過大。以至於DISTRIBUTOR
用戶在調用該合約方法時,可能消耗的Gas費用過高,超出了GAS LIMIT
。
如下是該合約在實際NEAR Localnet中測試的結果
Finish init NEAR
Finish deploy contracts and create test accounts
blocked_contract.test.near balance: 0
Receipt: 9 mFgkbpWDVhEDu5cw6KdfYoaCMjdkRt9VUdMkSqBXQE1
Log [ft_token.test.near]: Transfer 10000 from ft_token_owner.test.near to blocked_contract.test.near
blocked_contract.test.near balance: 10000
now blocked_contract.test.near has 10000 Tokens.
user1.test.near registered with balance: 0
user2.test.near registered with balance: 0
user3.test.near registered with balance: 0
user4.test.near registered with balance: 0
user5.test.near registered with balance: 0
Receipt: 8 xnAZWghLehupax4fXPLsXxqWHviQ8u1eoW8QXN6WqjL
Log [blocked_contract.test.near]: Registered accountuser1.test.near
Receipt: BQ3jKfqptei1nuUhMU5zDfieMA6oUZHThjukqwEa2U6R
Log [blocked_contract.test.near]: Registered accountuser2.test.near
Receipt: 9 qehMEPoCwd6LMFXcmJ88SjFxo5Z1uh9SbMmhZ5MKMvd
Log [blocked_contract.test.near]: Registered accountuser3.test.near
Receipt: J4xfvxPMsKsKfU2qCHxXn4S9R97DhCFX9ZNgqBtHNmP7
Log [blocked_contract.test.near]: Registered accountuser4.test.near
Receipt: F6wMw3r5X6fvX46GSL7vGgpyzv7yVEbFBqZhGGEJrnMM
Log [blocked_contract.test.near]: Registered accountuser5.test.near
Receipt: 3 CyLqThX8NMNxGV6hpSMpV69Ks3tFHUnbN4WyS1Zqrwi
Log [blocked_contract.test.near]: Try distribute to accountuser1.test.near
Log [blocked_contract.test.near]: Try distribute to accountuser2.test.near
Failure [blocked_contract.test.near]: Error : { 'index' : 0 , 'kind' :{ 'ExecutionError' : 'Exceeded the prepaid gas.' }}
可以看到當系統中註冊的用戶較多時,實際在distribute_token
執行的過程中,所設置的prepaid_gas將不足以滿足所有用戶的轉賬操作,以至於本次交易失敗。
推薦的解決方案:
由於Gas Limit的限制,合約方法在執行過程中不建議遍歷一個較大的數據結構(該數據結構的大小可被外部用戶操縱)。確需遍歷的,也需要限制該數據結構的大小,並保證當該數據結構的大小達到該最大值時,也不會觸及Gas Limit的限制。
因此推薦採用withdrawal
模式對上述合約進行改造。即要求合約方不主動地對所有的用戶逐一發放獎勵,而是先記賬,並設置一個withdraw
函數,讓單一用戶通過該函數方法的調用,自行取回“分紅”獎勵。此時合約方也只需要維護逐一用戶已經取回的獎勵數額或者還能取回的獎勵數額即可。
2. 跨合約之間的狀態依賴導致合約阻塞
合約在進行跨合約調用時,可能會對外部合約的狀態存在依賴,不恰當的依賴,會導致該合約阻塞,從而可能被發起DoS攻擊
下面考慮一種利用智能合約進行“競價”的場景:
pub struct Contract {
// 系统注册的用户
pub registered: Vec<AccountId>,
// 用户的出价
pub bid_price: UnorderedMap<AccountId,Balance>,
// 目前为止出价最高的用户ID
pub current_leader: AccountId,
// 目前为止出价最高的用户所出的价格
pub highest_bid: u128,
// 此时是否能够退回上次出价最高者的押金
pub refund: bool
}
用戶可以通過調用“競價合約”中的pub fn register_account
函數方法註冊賬戶,為參與後續的競價做準備
pub fn register_account(&mut self ) {
if self .bid_price.insert(&env::predecessor_account_id(), & 0 ).is_some() {
env::panic( 'The account is already registered' .to_string().as_bytes());
} else {
self .registered.push(env::predecessor_account_id());
}
log!( 'Registered account {}' ,env::predecessor_account_id());
}
用戶還可以通過如下接口函數查詢當前系統中目前為止出價最高的用戶ID,及其所出的價格。
pub fn view_current_leader(&mut self ) -> AccountId{
self .current_leader. clone ()
}
pub fn view_highest_bid(&mut self ) -> u128{
self .highest_bid
}
隨後用戶可以往該“競價合約”轉賬指定amount數額的代幣,表示該用戶願意出價的值為amount。
pub fn ft_transfer_call(
&mut self ,
receiver_id: AccountId,
amount: u128,
) -> Promise {
let sender_id = env::predecessor_account_id();
let amount: Balance = amount.into();
self .internal_transfer(&sender_id, &receiver_id, amount);
// Initiating receiver's call and the callback
ext_fungible_token_receiver::ft_on_transfer(
sender_id. clone (),
amount.into(),
msg,
&receiver_id. clone (),
0 ,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2 ,
)
}
當競價合約收到token時,會通過ft_on_transfer函數調用到如下bid函數。
pub fn bid(&mut self , sender_id: AccountId, amount: u128) -> PromiseOdrValue<u128> {
assert!(amount > self .highest_bid);
if self .current_leader == DEFAULT_ACCOUNT {
// 第一位出价者不需要退回上任出价最高者的token
self .current_leader = sender_id;
self .highest_bid = amount;
} else {
ext_ft_token::account_exist(
self .current_leader. clone (),
&FTTOKEN,
0 ,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 4 ,
).then(ext_self::account_resolve(
sender_id,
amount,
&env::current_account_id(),
0 ,
GAS_FOR_SINGLE_CALL * 3 ,
));
}
log!(
'current_leader: {} highest_bid: {}' ,
self .current_leader,
self .highest_bid
);
PromiseOrValue::Value( 0 )
}
在該出價函數中,函數的執行邏輯將首先檢查本次用戶的出價是否高於之前出價最高用戶的出價值。如果滿足該條件,將執行self.refund_exe()
從“競價合約”中退回之前出價最高用戶的出價代幣。隨後更新目前為止出價最高的用戶ID及其所出的價格。
實際的情況是,根據該合約的邏輯定義:必須要退回之前出價最高用戶的出價代幣,才能將目前為止出價最高的用戶ID進行更替。
#[private]
pub fn account_resolve(&mut self ,sender_id: AccountId,amount: u128) {
match env::promise_result( 0 ) {
PromiseResult::NotReady => unreachable!(),
PromiseResult::Successful(_) => {
// 如果上任出价者的账户存在,退回上任出价最高者的token
ext_ft_token::ft_transfer(
self .current_leader. clone (),
self .highest_bid,
&FTTOKEN,
0 ,
GAS_FOR_SINGLE_CALL * 2 ,
);
self .current_leader = sender_id;
self .highest_bid = amount;
}
PromiseResult::Failed => {
// 退回当前出价最高者的token
ext_ft_token::ft_transfer(
sender_id. clone (),
amount,
&FTTOKEN,
0 ,
GAS_FOR_SINGLE_CALL * 2 ,
);
log!( 'Return Back Now' );
}
};
}
但若此前出價最高的用戶在外部“代幣合約”註銷了該賬戶,則後續出價更高的用戶在退回前者代幣的過程時將阻塞於assert!(self.refund,'{}',ERR_REFUND);
以至於無法完成對系統中出價最高者的更替,整體競拍的過程將出現問題。
如下是該合約在實際NEAR Localnet中測試的結果:
Finish init NEAR
Finish deploy contracts and create test accounts
Inited bid_contract .test .near with balance :0
Receipt : 5 sF66bxnTu8VLauKNm7QqSPMXErF2LrQ65KLzTQqn9L5
Log [ft_token.test.near] : Transfer 10000 from ft_token_owner .test .near to user0 .test .near
user0 .test .near registered in ft_token .test .near with balance :10000
Receipt : 6 NBhWH6exbU4fYuecv8QJNMTP9aiQFUXJVjN2A1DqXpT
Log [ft_token.test.near] : Transfer 10000 from ft_token_owner .test .near to user1 .test .near
user1 .test .near registered in ft_token .test .near with balance :10000
Receipt : ESA4B293ytMUnPbW2XqQzVY74527R6J8o6X3cY8VzhpC
Log [ft_token.test.near] : Transfer 10000 from ft_token_owner .test .near to user2 .test .near
user2 .test .near registered in ft_token .test .near with balance :10000
Receipt : 4 WFv1edjCYTwstcs9jiLotUzt3HwJ5j5qVk6rDbVsVJF
Log [bid_contract.test.near] : Registered account user0 .test .near
Receipt : Hc6M5VsbjsFfSvSMTAkZHAk2uLKu81jhHYFAWcBE6TKk
Log [bid_contract.test.near] : Registered account user1 .test .near
Receipt : GXFoVAUwkztT9isWcfXBvAkXq8uboPpuVBKq9wtrBMTE
Log [bid_contract.test.near] : Registered account user2 .test .near
此時測試模擬了“競價系統”的參與的用戶:user0、user1和user2
他們分別擁有10000個初始代幣。 user0首先在“競價系統”中出價1000,此時查詢可知current_leader: user0.test.near highest_bid: 1000
。隨後user0立即將剩餘的9000個代幣轉給了user2,並銷毀了代幣賬戶。
此後,當user1出價2000時,系統將打算退回user0之前的出價值。但由於此時user0的賬戶已不存在,系統將提示'Cannot Refund',始終無法成功完成後續的交易更新狀態。
Receipts : Cwh3bfvtenSmVChkWnVBuPEh5arA4aPEgAgZEJMW2Ayz , FDdzGteHv9Q7VFd9imDxVQPgPgnofWAEFzvcVo84gDeB
Log [ft_token.test.near] : Transfer 1000 from user0 .test .near to bid_contract .test .near
Receipt : ByZgRoWVzbZM6nBaWE4hK9eN1if31PDDqFnV6wyvkhKc
Log [ft_token.test.near] : current_leader : user0 .test .near highest_bid : 1000
Receipt : 6 c1MzmrnRDB5SmHiCd8KwoSt33kVwiSxQmAWYnSpUjzU
Log [ft_token.test.near] : Transfer 9000 from user0 .test .near to user2 .test .near
Receipt : 8 YjQqEXNm1NEb1yEKUuKpbL85LrztUMDKpLKJNT2cWfd
Log [ft_token.test.near] : The account is already unregistered
current_leader : user0 .test .near
highest_bid : 1000
此時第二位出價者想出價2000:
Receipts : 5iiM4ujfA8u4p6L4obNcwjcWwsT4utSkP8HzLDHE8PK, 4QRUoJU5hVeTZGJhJNT31emH1FJnb4hgJVUE91Vgg2Nq
Log [ft_token.test.near]: Transfer 2000 from user1.test.near to bid_contract.test.near
Receipts : FJKhWALPRnMZo4EhK6r7FydH1o4Eonu3HC5kHezmXy14, CitDuviHJXVYnSTB5TxzQBsjEuFHATnrCiVtd4xaR3bm, Ex6DtdtMb4FuKYJUKUk2w7hQRVpKqmGwHykqV1FCkgpb
Log [ft_token.test.near]: current_leader: user0.test.near highest_bid: 1000
Receipt : C5UgiikqmPXgnKQ4ymD5Zizy6USm5eJgTSZVx6TCCAhi
Log [ft_token.test.near]: Account not found
Failure [ft_token.test.near]: Error: {'index':0,'kind':{'ExecutionError':'Smart contract panicked: panicked at 'Cannot Refund', src/lib.rs:74:13'}}
Receipts : UTXBCWBgiUWh67qunwRV8cEyFNB8CFkV7rir6cGvneY, 9yJ428yfrsuZXJ8JN3p3CDAS3MLcBgvUAs9wfnqKh7Ej
Log [ft_token.test.near]: Return Back Now
Receipt : 8TfieBuBqoDsrpWtz7w6qsUqd2qBEabtWAFWtUiiPp6P
Log [ft_token.test.near]: Transfer 2000 from bid_contract.test.near to user1.test.near
current_leader : user0.test.near
highest_bid : 1000
解決方法:
如果合約的狀態的轉化需要依賴於外部合約的調用處理,則需要考慮外部合約調用可能失敗的情形,防止合約的執行邏輯被阻塞而拒絕服務,即我們需要實現合理的錯誤處理手段。在本例子中,我們可以將無法退回的代幣寄存於合約新增的lost_found用戶組中,當後續用戶滿足條件refund條件時,再由用戶本身來進一步取回代幣(同樣可以實現withdraw函數) 。
3. Owner私鑰丟失
去中性化智能合約項目中往往也存在部分中心化的現象:如存在合約的owner。部分合約函數的執行被設置為僅owner可以執行,用以對合約中某些關鍵系統變量值的進行設置更改。我們可以將此類函數稱之為only_owner類型函數。
例如前文在“分紅”合約中所定義的pub fn distribute_token
, 該函數即為only_owner函數。當合約的owner無法履行職能(私鑰丟失)時,資金將一直被鎖定在合約之中,無法分發給其他用戶。另有大多數的情況下,only_owner函數還可以用來暫停或者重啟合約中的所有交易,可見owner正常履行其職能的重要性。
解決方法:
為避免上述owner個人“失能”情形的發生,我們可增設多位合約的owner共同治理合約,甚至可採用多簽請求的方式來替換原有的合約權限控制方案,以此實現合約的去中心化治理效果。有關智能合約中多簽請求功能的設計實現,將在後續的《智能合約養成日記》中展開詳細的描述。