前言
目前很多分析工具愈来愈发达了,我记得我第一次分析 bZx 的闪电贷攻击的时候,还不知道有优秀的分析工具如 ethtx.info, tenderly 等。那个时候由于刚接触闪电贷,什么都不会,没有这些分析工具,短时间分析这些攻击是真的很难的。后来慢慢知道更多的分析工具了,很多东西也开始慢慢变得驾轻就熟起来,但是自己本身也对工具越来越依赖了。有时候我就会想,是不是如果我没有这些分析工具的话,我就没办法进行分析了呢?这个疑问在昨晚我终于得到了答案。
昨晚大概11点的时候,我的 leader 打电话过来,说 Cronos 上有一个叫 VultureSwap 的项目被黑了,让我来分析看看是什么问题。我是很久没有在半夜接到这么急的分析了,上一次还是在1点的时候分析,那一次我记得我忙到了4点才有结果。但是和上次不同,这一次,Cronos 上是没有 ethtx.info 和 tenderly 这样的分析工具来支持的,我能有的唯一一个工具,就是我们自家的浏览器–https://cronos.crypto.org/explorer。我知道,这是一次严峻的挑战,但是我从来不是一个害怕挑战的人,说干就干,看看这次发生了什么事情。
开始分析
在一开始,我就得到了几笔指向攻击者的交易。
https://cronos.crypto.org/explorer/tx/0xe7bbf5c995fccdac316ad85528c7d8df4cbff3f0ce6fbd665ba93321bff603ef/token-transfers
https://cronos.crypto.org/explorer/tx/0x072cace6ae26f48da51ab22c3d6384593fca6a1c78c8def302163de1526dbd1e/token-transfers
https://cronos.crypto.org/explorer/tx/0x0a25cb185516bbba27dea922f4b36bc094afa24946b839ce7af246633c4177e5/token-transfers
从这几笔交易中,不难发现这几个交易都指向了同一个地址 0xcf45c7227db5577dbbefec665821f06981243b63
,那么基本可以确定这个地址就是攻击者了。由于没有其他更多的分析工具,所以我只能去看这个攻击者的一些代币转移,看看究竟是黑了什么资产
通过观察攻击者的代币转移情况,有几笔交易引起了我的注意,首先,这次被攻击的项目的名称是 VultureSwap
,那么代币转移中的这笔大额的 Vultr
代币转移,自然是我的重点关注对象,而且通过分析攻击者调用的函数,分别是 deposit
和 withdraw
函数,然后对应调用的合约叫 VultrSwapMasterChef
。这个时候大概就已经知道了被攻击合约的架构了。就是一个仿 SushiSwap
的 MasterChef
架构,一个充值 LP
获取奖励的模型,那么这就更加确定了这笔大额的 Vultr
转账是问题所在了。那么问题的范围就缩小在了攻击者攻击了一个 MasterChef
架构的合约,得到了大量的代币奖励。
那么回想 MasterChef
发生过的攻击有哪些呢?由于攻击者调用的只有简单的 deposit
& withdraw
函数,那么对应的结果也就只有可能的几个
-
通缩型代币攻击
-
deposit 过程中没有记录用户的
rewardDebt
-
MasterChef
中的用户充值余额可以转移,导致代币奖励错误计算
为了验证上面的想法,就需要对对应的 VultureSwapMasterChef
的 deposit
函数进行分析,下面是对应的代码
// Deposit LP tokens to MasterChef for VULTR allocation.
function deposit(uint256 _pid, uint256 _amount) external nonReentrant {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][msg.sender];
updatePool(_pid);
if (user.amount > 0) {
uint256 pending = user.amount.mul(pool.accVulturePerShare).div(1e18).sub(user.rewardDebt);
if (pending > 0) {
safeVultureTransfer(msg.sender, pending);
}
}
if (_amount > 0) {
uint256 balanceBefore = pool.lpToken.balanceOf(address(this));
pool.lpToken.safeTransferFrom(address(msg.sender), address(this), _amount);
_amount = pool.lpToken.balanceOf(address(this)) - balanceBefore;
user.amount = user.amount.add(_amount);
pool.lpSupply = pool.lpSupply.add(_amount);
}
user.rewardDebt = user.amount.mul(pool.accVulturePerShare).div(1e18);
emit Deposit(msg.sender, _pid, _amount);
}
在看完 deposit
代码之后是不是一头雾水,这不就是正常的 MasterChef
架构的 deposit
函数吗?好像什么问题都没有啊?我当时也是这个想法,同时,上文列出的几个问题在看完代码后也是不存在的。这既不是通缩问题,也不是份额转移问题,也不是 rewardDebt
的问题。那么是什么问题?顺着刚才的思路,能有这么大的代币奖励,肯定是因为 #7 行的奖励计算出问题了,那么出问题的原因很简单,无非是2个原因。
-
要么就是
pool.accVulturePerShare
算错了 -
要么就是本来应该在
deposit
时应该更新的user.rewardDebt
没有更新到,是一个0值,导致两者一减就变得非常大
然后为了验证这个想法,我就只能根据用户充值的 LP
数量和池当前的 accVulturePerShare
一起来算算奖励,看看是个什么情况。
通过查询合约的数据,可以看到攻击者充值的 8 号池的 accVulturePerShare
的值为 1811954401887916490940287093
而攻击者的充值金额是 6621620910547957
。那么根据公式,我们来算下,如果没有 rewardDebt
的话,奖励可以是多少?
得到的结果很出乎意料,刚好是攻击者提现的奖励代币的数值(这里算出来有一些偏差不是我算错了,是因为share数据已经不是昨晚的数据了,但是大致是差不多的,同时根据这么小的偏差,也不难推断出 rewardDebt 就是0。所以凑合看吧 😀 )
这个结果说明了什么呢?两个事情
-
pool.accVulturePerShare
非常大 -
user.rewardDebt
的值是 0
那么这两个结果同时也带来2个问题,为什么 pool.accVulturePerShare
这么大?肯定有个地方算错了。那么在 MasterChef
的架构里,有更新这个值的地方就只有在 updatePool
函数的代码里,那么就要看看对应的代码是怎么写的
function updatePool(uint256 _pid) public {
PoolInfo storage pool = poolInfo[_pid];
if (block.timestamp <= pool.lastRewardSecond) {
return;
}
if (pool.lpSupply == 0 || pool.allocPoint == 0) {
pool.lastRewardSecond = block.timestamp;
return;
}
uint256 multiplier = getMultiplier(pool.lastRewardSecond, block.timestamp);
uint256 vultureReward = multiplier.mul(VulturePerSecond).mul(pool.allocPoint).div(totalAllocPoint);
uint256 devReward = vultureReward.mul(12).div(100);
uint256 treasuryReward = vultureReward.mul(15).div(100);
uint256 totalSupply = vulture.totalSupply();
uint256 maxSupply = vulture.maxSupply();
uint256 totalRewards = (totalSupply.add(treasuryReward).add(devReward).add(vultureReward));
if (totalRewards >= maxSupply) {
try vulture.mint(devaddr, devReward) {
} catch (bytes memory reason) {
vultureReward = 0;
emit VultureMintError(reason);
}
try vulture.mint(treasuryaddr, treasuryReward) {
} catch (bytes memory reason) {
vultureReward = 0;
emit VultureMintError(reason);
}
} else {
// update vultureReward to difference
vultureReward = maxSupply.sub(totalSupply);
}
if (vultureReward != 0) {
try vulture.mint(address(this), vultureReward) {
}
catch (bytes memory reason) {
vultureReward = 0;
emit VultureMintError(reason);
}
pool.accVulturePerShare = pool.accVulturePerShare.add(vultureReward.mul(1e18).div(pool.lpSupply));
}
pool.lastRewardSecond = block.timestamp;
}
从 updatePool
函数中发现了一个很奇怪的点,区别于正常的 MasterChef
模型,这里的 #31 行有个很奇怪的逻辑,就是 vultrReward
最大可以到 maxSupply - totalSupply
的差值,然后结合 #10-#16行的逻辑,奖励都是 mint
出来的,那么很明显 totalSupply
是从0开始缓慢增长的,也就是说maxSupply - totalSupply
是可以变得很大的。所以肯定有某一个交易,走了这个 else
的逻辑,导致了这个结果,这个逻辑为什么这么设计不重要,重要的是它这样执行了。那么到这里,第一个疑问就解决了,由于这个不知道为什么的逻辑,导致了 vultureReward
变得很大,而 pool.accVulturePerShare
是依赖 vultureReward
来计算的,所以也会变得很大。
那么剩下的问题就是,为什么攻击者的 rewardDebt
会是0呢?难道是 rewardDebt
记录错了?回看 deposit
函数,就算你再看10遍,最后还是正常的记录了用户的 reardDebt
啊,为什么呢?说实话我想了很久都没想明白,直至看到攻击者的 withdraw
操作
这笔交易奇怪的地方在于 withdraw
操作的时候没有奖励发放,但是按代码来看,这里是必须要有奖励的,为什么会没有奖励呢?突然,我灵光一闪,有没有可能,就是说,攻击者在充值的时候,池的奖励就是没有开始呢?如果奖励没有开始的话,那么根据 updatePool
函数的逻辑,就会走到 #6 行的判断就return了,因为 allocPoint
为0,所以就不会有接下来的逻辑,所以 pool.accVulturePerShare
就是 0,然后由于 pool.accVulturePerShare
为0,所以攻击者的 rewardDebt
就是0。问题解决。。。(想了真的很久 :D)
那么既然奖励没有开始,为什么后面又有值了呢?这是因为后面奖励就开始了,在奖励开始之后,由于 updatePool
函数中 #31 行的这个奇怪的计算,导致 pool.accVulturePerShare
变得异常大,然后攻击者只要正常的领取奖励,就可以获得巨额的奖励。
总结下,这次的其实一共有2个问题
-
pool.accVulturePerShare
异常计算的问题 -
用户可以在奖励没开始之前就进池里,导致
rewardDebt
为0
关于第二个问题,其实在目前的所有 MasterChef
里,是一个正常的操作,但是这个特性放在了这个合约里,就炸了,如果用户在奖励开始前不允许充值的话,就会造成 rewardDebt
不可能为0,也就不会产生那么大的差值,那么就不会产生影响。所以最终,我把这个特性也定义成问题。
总结
事情就是这么简单,到这里就已经分析完了,但是反过来想,如果有辅助工具的话,我觉得我的分析会更快,起码在确定 rewardDebt
的问题上,会有一个更加快速的响应,也不用钻牛角尖想了很多种可能。尽管如此,这次分析还是给了我很大的收获,目前看来,是我的经验、灵光一现和对所有可能的极致探索帮助我解决了这次没有任何辅助工具的攻击分析,尽管这次是一次简单的攻击。起码证明了,无工具分析的可能性是存在的。
后续,在我分析完之后,有很多分析文章都出来了,都是不约而同的指向了了 updatePool
函数中的问题,但是却忽略了我提到的关于奖励没开始就可以冲的这个设定。说实话,我是不喜欢分析前看任何答案的,看答案在我看来会形成惯性思路,导致我少钻了很多牛角尖,思考也会变得不全面了。不禁想起了 samczsun 的那句话,文档定义的功能是什么不重要,重要的是它这样写了,毕竟,code never lies(这句我自己说的 xD )。
原文始发于微信公众号(蛋蛋的区块链笔记):没有工具,真的不能分析么?— 记一次无工具分析实战