1. Ref Finance攻击事件摘要
2021年8月14日,UTC时间下午2点左右,Ref Finance的核心团队注意到了REF- NEAR 交易对中存在的不寻常行为。经过调查发现,在其最近部署的收益耕作项目(Ref Farming)中发现了一个漏洞。不幸的是,已有几位Ref Finance的用户利用了该漏洞,并导致了该交 易池 中近1,000,000个REF与580,000个NEAR受到了影响。
2. 背景知识
2.1 流动性交易池(The Liquidity Pools)
在DeFi中,流动性交易池是锁定在智能合约中的代币池,它促进了有效的资产交易,同时允许投资者赚取一定的收益回报。一个典型的流动性交易池通常包含两种或以上代币,例如 DAI /ETH就是 Uniswap 上一个较为流行的流动性池。在该流动性池中,这两个币种的总价值为1:1。比如总价值1000美元的DAI/ETH交易对的资金池中有1个ETH和500个DAI。此时我们可以得出一个ETH的价值是500美元,一个DAI的价值为1美元。任何时候流动性提供者(Liquidity Provider,简称LP)都可以向资金池中以1:1的价值放入两种代币,并获得合约所提供的LP代币作为流动性提供者拥有部分该资金池的证明。
当该资金池中产生了交易时,合约会收取一定的手续费(例如Uniswap中的0.3%),并最终作为回报分发给该资金池的流动性提供者。
如今流动性交易池已是去中心化金融中自动做市商(AMM)、借贷协议、收益耕种、链上保险、加密合成资产以及区块链游戏等项目的重要组成部分。
流动性交易池的价格机制
不同的交易池有不同的价格机制,如Uniswap使用如下机制:假设流动性交易池中有X个DAI和Y个ETH。合约保证了X * Y在交易前后不变(恒定乘积)。也就是当我们需要用Δy个ETH购买DAI的时候,合约算法的设定会给我们Δx个DAI,并使得如下等式成立:
X * Y = (X - Δx) * (Y + Δy)
2.2 Ref.Finance 项目
Ref Finance是一个基于NEAR协议的多用途去中心化金融(DeFi)平台。其中Ref_Exchange 是其主要合约,实现了流动性交易池。而该池子的代币即为REF, 也在本次攻击事件中受到影响。
Ref_Exchange项目参考了Uniswap 的相关设计并用Rust语言基于NEAR协议实现。如下是该合约 ref-exchange/src/simple_pool.rs 中SimplePool 的数据结构详细定义:
pub struct SimplePool { /// List of tokens in the pool. pub token_account_ids: Vec<AccountId>, /// How much NEAR this contract has. pub amounts: Vec<Balance>, /// Volumes accumulated by this pool. pub volumes: Vec<SwapVolume>, /// Fee charged for swap (gets divided by FEE_DIVISOR). pub total_fee: u32, /// Portion of the fee going to exchange. pub exchange_fee: u32, /// Portion of the fee going to referral. pub referral_fee: u32, /// Shares of the pool by liquidity providers. pub shares: LookupMap<AccountId, Balance>, /// Total number of shares. pub shares_total_supply: Balance,}
其中SimplePool.shares中所保存的数据可以跟踪该交易池中流动性提供者(LP)所占有的的份额。
合约可以通过调用如下两种方式来更改LP所提供的流动性份额:
add_liquidity
remove_liquidity (在本次攻击事件中被利用)
3 攻击事件资金流向分析
3.1 异常交易信息跟踪:
本次攻击事件,Ref Finance在其官方Twitter账号中罗列了部分存在可疑交易行为的用户账户。我们针对这些账户做了进一步分析:
2cb92762bb52ba8310a5571c3d766583f0db8534f500d44864ce2c3f45dccfb5该账户所有的交易都是从vuhatyphu.near接收NEAR,然后转发给另一个账户:7747991786f445efb658b69857eadc7a57b6b475beec26ed14da8bc35bb2b5b6
082bfe48b461dca9b9a290894cd01d24364db393e1cb25625f5f2a94df869cb8该账户所有的交易都是从phamteo2.near接收NEAR,然后转发给另一个账户:7747991786f445efb658b69857eadc7a57b6b475beec26ed14da8bc35bb2b5b6这些代币最初都来自于phamthien.near转账给phamteo2.near,总计19499.8NEAR
022558709020ee7243f010ac42b181af2481699a0e3878c0a603594707dffa8c该账户所有的交易都是从phamthien.near接收NEAR,然后转发给另一个账户:7747991786f445efb658b69857eadc7a57b6b475beec26ed14da8bc35bb2b5b6
b8cd68c1b4989ccde3ef7d6669fdb886d6c18a36293b9a5af2a468add96e6204该账户所有的交易(仅一笔)都是从okok1234.near接收NEAR,然后转发给另一个账户:601483a1b22699b636f1df800b9b709466eba4e1d5ce7c2e1e20317af8bbd1f3
fd04d5f11af3fb8c48ff053a6970ca9cce39d3a2e2b8b1a52cc129e93b8c59e1该账户所有的交易都是从phamteo3.near,或phamteo4.near接收NEAR,然后转发给另一个账户:601483a1b22699b636f1df800b9b709466eba4e1d5ce7c2e1e20317af8bbd1f3其中phamteo3.near或phamteo4.near最初所获得的NEAR代币均来自于phamthien.near
c083bd024f2a7f44325e647fd2ff3eb8bdf1a8f22b64a1b30e58dbf3c6372ac3该账户所有的交易都是从phamthien.near接收NEAR,然后转发给另一个账户:7747991786f445efb658b69857eadc7a57b6b475beec26ed14da8bc35bb2b5b6
0a420c5ccdd8681b4c934e0c87d04bad57e73af961498b1ebbc09594d861e0d5该账户所有的交易都是从phamthien.near接收NEAR,然后转发给另一个账户:7747991786f445efb658b69857eadc7a57b6b475beec26ed14da8bc35bb2b5b6
4e258682b967831a34a01395d3dccde7b2f7d0435c1010062163e4686ae25cf5该账户所有的交易都是从tuannguyen261090.near接收NEAR,然后转发给另一个账户:7747991786f445efb658b69857eadc7a57b6b475beec26ed14da8bc35bb2b5b6
以上交易中的NEAR账户最初所获得的NEAR代币均来自于如下用户账号,即本次攻击事件中的最初获利者:
vuhatyphu.near
tuannguyen261090.near
icanfixit.near
willpha.near
okok1234.near
phamthien.near
3.2 异常交易内容分析
phamthien.near作为本次攻击事件中的最初获利者之一,该用户最早的一笔攻击合约交易位于交易哈希:7AY55CZBU1jB9b8Nn6ftnVofiD6DiMgaxhWtUaM1Gxnt,该笔交易调用了swap()函数,实现了ref-exchange合约中的代币转换。
随后该用户多次调用remove_liquidity()合约函数,该函数的作用是减少流动性提供者(LP)在流动性交易池中所提供的流动性份额(Liquidity Share),赎回之前注入交易池中的资金。
例如最早的remove_liquidity交易位于交易哈希:6GdM4ApiVrLFkJZa3mdAG6rgxBzuCtRASiaygDprWebR
4. 攻击原因分析
此攻击事件源于Ref_Exchange项目合约中引入的一个不正确的热修复补丁(HotFix)。如下是针对该补丁内容的详细分析,以及攻击者利用该漏洞获利的方式说明。
4.1 关键合约函数的利用
上文中提到phamthien.near多次调用了合约函数remove_liquidity,该函数的作用为:在该资金池中减少某一流动性提供者LP所指定的流动性份额。其实现位于:ref-exchange/src/simple_pool.rs。细节如下:
/// 2020-09-20 /// Removes given number of shares from the pool and returns amounts to the parent. pub fn remove_liquidity( &mut self, sender_id: &AccountId, shares: Balance, min_amounts: Vec<Balance>, ) -> Vec<Balance> { let prev_shares_amount = self.shares.get(&sender_id).expect("ERR_NO_SHARES"); assert!(prev_shares_amount >= shares, "ERR_NOT_ENOUGH_SHARES"); let mut result = vec![]; for i in 0..self.token_account_ids.len() { let amount = (U256::from(self.amounts[i]) * U256::from(shares) / U256::from(self.shares_total_supply)) .as_u128(); assert!(amount >= min_amounts[i], "ERR_MIN_AMOUNT"); self.amounts[i] -= amount; result.push(amount); } if prev_shares_amount == shares { // [AUDIT_13] never unregister a LP when he remove liqudity. self.shares.insert(&sender_id, &0); } else { self.shares .insert(&sender_id, &(prev_shares_amount - shares)); } env::log( format!( "{} shares of liquidity removed: receive back {:?}", shares, result .iter() .zip(self.token_account_ids.iter()) .map(|(amount, token_id)| format!("{} {}", amount, token_id)) .collect::<Vec<String>>(), ) .as_bytes(), ); self.shares_total_supply -= shares; result }
函数首先从交易池的合约数据SimplePool.share中查询函数参数传入 send_id 所指定用户的流动性占比份额,并存入prev_shares_amount (line 9)
如果用户取出全部的流动性份额,原则上应该将SimplePool.share该账户的份额清零 (line 22)
如果用户取出部分的流动性份额,则将保存取出用户所指定份额后的剩余份额,即prev_shares_amount - shares (line 25)
从该交易池所提供的所有流动性统计中减去该用户取出的份额,即SimplePool.shares_total_supply -= shares (line 39)
4.1.2 攻击事件的直接原因分析
而在此次攻击事件发生前,存在一个不正确的HotFix:
ref-exchange/src/simple_pool.rs @@ -182,7 +182,8 @@ impl SimplePool { result.push(amount); } if prev_shares_amount == shares {- self.shares.remove(&sender_id);+ // HotFix_lp_unregister+ // self.shares.remove(&sender_id); } else { self.shares .insert(&sender_id, &(prev_shares_amount - shares));
该HotFix提交https://github.com/ref-finance/ref-contracts/issues/37,简称PR#37, 可以看到该HotFix在合约中临时取消了当LP用户想要从该流动性池中完全去除所有的流行性时,所需执行的SimplePool.shares.remove(&sender_id)的操作。
为此用户可以利用该合约的漏洞,在该合约数据SimplePool.share中所保存的该账户流动性份额始终无法清零的情况下,反复套利。
4.1.3 攻击事件的根本原因分析
之所以存在该HotFix的原因则来自于该项目的另一个更早的Pull Request(https://github.com/ref-finance/ref-contracts/issues/36)。
该Pull Request指出了Ref-exchange中存在的一个缺陷,该缺陷的具体内容描述为:
如下模拟用户Alice在Ref.Finance平台中的正常交易使用场景:
假设用户Alice首先往某个确定的流动性交易池中(例如编号pool#1429)添加了一定的流动性,并获得了1.0个LP代币;
随后Alice质押了其中的0.5个LP代币用于收益耕种。
在收益耕种的过程中,Alice还将剩下的0.5个LP代币从交易池取出,即remove_liquidity,此时Alice在该交易池中的流动性份额应当为0。
但是在PR#37提交之前,当此时Alice请求去除pool#1429流动性交易池中所有的流动性时,合约的具体实现将调用SimplePool.shares.remove(&sender_id)从流动性交易池所维护的LP名单中直接删除Alice用户。
因此,后续当Alice想要取回之前质押在收益耕种项目中LP代币时,由于该LP代币发行的流动性交易池中pool#1429已经没有Alice这一LP账户。因此会产生ERR13_LP_NOT_REGISTERED错误;
为了解决该缺陷,marco-sundsk暂时取消了调用SimplePool.shares.remove(&sender_id)删除LP用户这一行为,并在Ref-exchage的0.2.2版本中提交了该HotFix,并由此最终导致上述合约漏洞被利用事件的发生。
而正确做法应当把该LP用户的流动性份额归零,并保留该账户。具体的内容如下:
@@ -182,7 +183,8 @@ impl SimplePool { result.push(amount); } if prev_shares_amount == shares {- self.shares.remove(&sender_id);+ // [AUDIT_13] never unregister a LP when he remove liqudity.+ self.shares.insert(&sender_id, &0);