往期回顾:

拒绝服务攻击又称DoS (Denial of Service)攻击,该类型的攻击将使得智能合约在一段时间内(甚至永久)无法被用户正常使用。

目前已知的原因大致可分为如下两类:

  1. 合约逻辑中存在的某些缺陷。如某一public函数,其实现没有考虑到计算复杂度。用户调用该函数时,实际所需消耗的Gas会超出NEAR公链创世区块配置文件(genesis_config.json)中所定义的'max_total_prepaid_gas': 300000000000000` (300TGas),导致交易失败。

  2. 某些跨合约调用情形中,合约的执行依赖于其他外部合约的执行状态。而外部合约的执行并非总是可靠,以至于本合约的执行可能被外部合约阻塞,无法照常运行。该类问题的发生可表现为合约用户在合约中的资金被锁定,以至于无法正常的充值或提现。

  3. 除了合约逻辑的缺陷,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 NEARFinish deploy contracts and create test accountsblocked_contract.test.near balance:0Receipt: 9mFgkbpWDVhEDu5cw6KdfYoaCMjdkRt9VUdMkSqBXQE1        Log [ft_token.test.near]: Transfer 10000 from ft_token_owner.test.near to blocked_contract.test.nearblocked_contract.test.near balance:10000now blocked_contract.test.near has 10000 Tokens.user1.test.near registered with balance:0user2.test.near registered with balance:0user3.test.near registered with balance:0user4.test.near registered with balance:0user5.test.near registered with balance:0Receipt: 8xnAZWghLehupax4fXPLsXxqWHviQ8u1eoW8QXN6WqjL        Log [blocked_contract.test.near]: Registered accountuser1.test.nearReceipt: BQ3jKfqptei1nuUhMU5zDfieMA6oUZHThjukqwEa2U6R        Log [blocked_contract.test.near]: Registered accountuser2.test.nearReceipt: 9qehMEPoCwd6LMFXcmJ88SjFxo5Z1uh9SbMmhZ5MKMvd        Log [blocked_contract.test.near]: Registered accountuser3.test.nearReceipt: J4xfvxPMsKsKfU2qCHxXn4S9R97DhCFX9ZNgqBtHNmP7        Log [blocked_contract.test.near]: Registered accountuser4.test.nearReceipt: F6wMw3r5X6fvX46GSL7vGgpyzv7yVEbFBqZhGGEJrnMM        Log [blocked_contract.test.near]: Registered accountuser5.test.nearReceipt: 3CyLqThX8NMNxGV6hpSMpV69Ks3tFHUnbN4WyS1Zqrwi        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攻击

下面考虑一种利用智能合约进行“竞价”的场景:

#[near_bindgen]#[derive(BorshDeserialize, BorshSerialize)]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 NEARFinish deploy contracts and create test accountsInited bid_contract.test.near with balance:0Receipt: 5sF66bxnTu8VLauKNm7QqSPMXErF2LrQ65KLzTQqn9L5        Log [ft_token.test.near]: Transfer 10000 from ft_token_owner.test.near to user0.test.nearuser0.test.near registered in ft_token.test.near with balance:10000Receipt: 6NBhWH6exbU4fYuecv8QJNMTP9aiQFUXJVjN2A1DqXpT        Log [ft_token.test.near]: Transfer 10000 from ft_token_owner.test.near to user1.test.nearuser1.test.near registered in ft_token.test.near with balance:10000Receipt: ESA4B293ytMUnPbW2XqQzVY74527R6J8o6X3cY8VzhpC        Log [ft_token.test.near]: Transfer 10000 from ft_token_owner.test.near to user2.test.nearuser2.test.near registered in ft_token.test.near with balance:10000Receipt: 4WFv1edjCYTwstcs9jiLotUzt3HwJ5j5qVk6rDbVsVJF        Log [bid_contract.test.near]: Registered account user0.test.nearReceipt: Hc6M5VsbjsFfSvSMTAkZHAk2uLKu81jhHYFAWcBE6TKk        Log [bid_contract.test.near]: Registered account user1.test.nearReceipt: 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.nearReceipt: ByZgRoWVzbZM6nBaWE4hK9eN1if31PDDqFnV6wyvkhKc        Log [ft_token.test.near]: current_leader: user0.test.near highest_bid: 1000Receipt: 6c1MzmrnRDB5SmHiCd8KwoSt33kVwiSxQmAWYnSpUjzU        Log [ft_token.test.near]: Transfer 9000 from user0.test.near to user2.test.nearReceipt: 8YjQqEXNm1NEb1yEKUuKpbL85LrztUMDKpLKJNT2cWfd        Log [ft_token.test.near]: The account is already unregisteredcurrent_leader: user0.test.nearhighest_bid: 1000

此时第二位出价者想出价2000:

Receipts: 5iiM4ujfA8u4p6L4obNcwjcWwsT4utSkP8HzLDHE8PK, 4QRUoJU5hVeTZGJhJNT31emH1FJnb4hgJVUE91Vgg2Nq        Log [ft_token.test.near]: Transfer 2000 from user1.test.near to bid_contract.test.nearReceipts: FJKhWALPRnMZo4EhK6r7FydH1o4Eonu3HC5kHezmXy14, CitDuviHJXVYnSTB5TxzQBsjEuFHATnrCiVtd4xaR3bm, Ex6DtdtMb4FuKYJUKUk2w7hQRVpKqmGwHykqV1FCkgpb        Log [ft_token.test.near]: current_leader: user0.test.near highest_bid: 1000Receipt: 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 NowReceipt: 8TfieBuBqoDsrpWtz7w6qsUqd2qBEabtWAFWtUiiPp6P        Log [ft_token.test.near]: Transfer 2000 from bid_contract.test.near to user1.test.nearcurrent_leader: user0.test.nearhighest_bid: 1000

解决方法:

如果合约的状态的转化需要依赖于外部合约的调用处理,则需要考虑外部合约调用可能失败的情形,防止合约的执行逻辑被阻塞而拒绝服务,即我们需要实现合理的错误处理手段。在本例子中,我们可以将无法退回的代币寄存于合约新增的lost_found用户组中,当后续用户满足条件refund条件时,再由用户本身来进一步取回代币(同样可以实现withdraw函数)。


3. Owner私钥丢失

去中性化智能合约项目中往往也存在部分中心化的现象:如存在合约的owner。部分合约函数的执行被设置为仅owner可以执行,用以对合约中某些关键系统变量值的进行设置更改。我们可以将此类函数称之为only_owner类型函数。

例如前文在“分红”合约中所定义的pub fn distribute_token, 该函数即为only_owner函数。当合约的owner无法履行职能(私钥丢失)时,资金将一直被锁定在合约之中,无法分发给其他用户。另有大多数的情况下,only_owner函数还可以用来暂停或者重启合约中的所有交易,可见owner正常履行其职能的重要性。

解决方法:

为避免上述owner个人“失能”情形的发生,我们可增设多位合约的owner共同治理合约,甚至可采用多签请求的方式来替换原有的合约权限控制方案,以此实现合约的去中心化治理效果。有关智能合约中多签请求功能的设计实现,将在后续的《智能合约养成日记》中展开详细的描述。