01.VTVL-2022-09@Code4rena
2023-09-18 11:11:40
# 21.audit
VTVL-2022-09@Code4rena 项目意图 这是一个代币释放的项目 :项目方给用户创建一个Claim,用户到达某个时间点释放代币(cliff),或者线性释放代币(linear)。
审计报告链接
漏洞 1.权限控制
漏洞:任何admin都可以取消其他admin权限,这可能是有意或者无意的,取决于项目方
例子:A和B都是admin,那么都可以调用这个方法。A调用这个方法,address admin
参数传入的是B的地址,然后就将B的admin修改为false了
修复:_admins[admin] = isEnabled;
===> _admins[msg.sender] = isEnabled;
1 2 3 4 5 function setAdmin(address admin, bool isEnabled) public onlyAdmin { require(admin != address(0), "INVALID_ADDRESS"); _admins[admin] = isEnabled; emit AdminAccessSet(admin, isEnabled); }
2.创建Claim
漏洞1:没有和当前时间比较,如果开始时间和结束时间都小于当前时间,也是满足条件的,,通过所有检验然后创建Claim。然后就可以直接取完这笔钱,不用等待。
例子:当前时间是2017年,你设置的startTime为2013年,结束时间为2016年,那么创建成功之后就可以直接获取金额了
修复:创建Claim的时候,判断startTime必须大于当前时间
漏洞2:整个项目只有push,没有remove,随着时间推移,这个vestingRecipients数组会越来越大,然后有些方法在调用allVestingRecipients()
获取数据的时候,由于数据巨大,遍历下来消耗的gas非常多(比如价值10ETH的gas),就会造成DoS
例子:十年之后,这个合约还在运行,但是这个数组的大小已经变成了2^255,虽然还有空间,但是遍历使用找到你的位置的时候(比如你是在2^254位置),消耗了1000ETH,这就很离谱
修复:代码逻辑当中增加remove数组元素的逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 struct Claim { uint40 startTimestamp; uint40 endTimestamp; uint40 cliffReleaseTimestamp; // 到了某个时间节点,将cliffAmount全部给你 uint40 releaseIntervalSecs; // 计算每秒得到的amount数量 uint112 linearVestAmount; // 线性释放的总金额 uint112 cliffAmount; uint112 amountWithdrawn; // 已经取了的金额 bool isActive; // 为true才能取 } function _createClaimUnchecked(...) private hasNoClaim(_recipient) { ... // 漏洞1: require(_startTimestamp > 0, "INVALID_START_TIMESTAMP"); require(_startTimestamp < _endTimestamp, "INVALID_END_TIMESTAMP"); ... // 漏洞:2 vestingRecipients.push(_recipient); // add the vesting recipient to the list emit ClaimCreated(_recipient, _claim); // let everyone know }
漏洞3:revokeClaim()
只将isActive设置为false,而没有将startTimestamp设置为0。因此,如果一个用户之前的Claim被revoke,那么他无法再次创建新的Claim。
例子:一个用户离开公司被revoke,再回公司则无法再次创建
修复:用判断isAcitve代替startTimestamp,或者revoke的时候同时将startTimestamp设置为0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 modifier hasNoClaim(address _recipient) { Claim storage _claim = claims[_recipient]; // 漏洞3 require(_claim.startTimestamp == 0, "CLAIM_ALREADY_EXISTS"); _; } function revokeClaim(address _recipient) external onlyAdmin hasActiveClaim(_recipient) { Claim storage _claim = claims[_recipient]; uint112 finalVestAmt = finalVestedAmount(_recipient); require( _claim.amountWithdrawn < finalVestAmt, "NO_UNVESTED_AMOUNT"); uint112 amountRemaining = finalVestAmt - _claim.amountWithdrawn; _claim.isActive = false; numTokensReservedForVesting -= amountRemaining; emit ClaimRevoked(_recipient, amountRemaining, uint40(block.timestamp), _claim); }
3.核心计算
漏洞1:linearVestAmount
是根据时间比例来得到的(线性释放的金额 * 已过去的时间 / 总时间),但因为它是uint112,限制了最大的token数目。
例子:释放时间是1年,代币是ERC20,代币单位10e18,过了一年之后,linearVestAmount
的最大值就是2 ** 12 / 10e18 / 3600 * 24 * 365 ~= m
个,因此在释放时间设置为1年的情况下,token的数目最多设置为m,m可见是一个不大的数目。一旦设置的数目超过m,那么计算得到的linearVestAmount
就会超过uint112操作存储的大小,导致overflow revert
修复:将linearVestAmount
设置为uint256类型
漏洞2:计算向下取整导致结果为0,前几天无法领取金额,降低了用户的体验感
例子:ERC20代币10e6,一共10000个token,线性释放时间一共10年。_claim.linearVestAmount * truncatedCurrentVestingDurationSecs
必须大于等于finalVestingDurationSecs
才能取出钱,否则向下取整为0。经过计算,大概在12天之后,计算出来的结果才大于等于1,用户在12天之后才能调用函数领取金额
修复:释放时间和释放总金额应该相协调,否则会出现上面需要过一段时间之后才能领取的情况
漏洞3:vestedAmount()
查看可领金额不正确
例子:startTime=2020, end=2022, 现在是2019,那么调用vestedAmount()
查看我能领取多少金额的时候,会发现不是0而是传入的_referenceTs
,而正确逻辑应该是0
修复:如果尚未达到startTime就调用vestedAmount()
查看可领金额,返回0
收获:遇到乘法判断是否overflow,除法是否除数为0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 function _baseVestedAmount(Claim memory _claim, uint40 _referenceTs) internal pure returns (uint112) { uint112 vestAmt = 0; if(_claim.isActive) { if(_referenceTs > _claim.endTimestamp) { _referenceTs = _claim.endTimestamp; } // 如果endTimestamp是2017年,而cliffReleaseTimestamp是2018年, // 则用户永远也取不出cliffAmount,因为_referenceTs = _claim.endTimestamp被锁死? // 不存在这个问题,因为claim在创建的时候,已经做了比较,必须cliff在线性释放的前面 if(_referenceTs >= _claim.cliffReleaseTimestamp) { vestAmt += _claim.cliffAmount; } if(_referenceTs > _claim.startTimestamp) { uint40 currentVestingDurationSecs = _referenceTs - _claim.startTimestamp; uint40 truncatedCurrentVestingDurationSecs = (currentVestingDurationSecs / _claim.releaseIntervalSecs) * _claim.releaseIntervalSecs; uint40 finalVestingDurationSecs = _claim.endTimestamp - _claim.startTimestamp; // 漏洞1,漏洞2 uint112 linearVestAmount = _claim.linearVestAmount * truncatedCurrentVestingDurationSecs / finalVestingDurationSecs; vestAmt += linearVestAmount; } } return (vestAmt > _claim.amountWithdrawn) ? vestAmt : _claim.amountWithdrawn; } // 漏洞3 function vestedAmount(address _recipient, uint40 _referenceTs) public view returns (uint112) { Claim storage _claim = claims[_recipient]; return _baseVestedAmount(_claim, _referenceTs); }
4.撤销Claim
漏洞:项目方revoke用户的Claim的时候,如果用户还有尚未领取的金额,并没有将用户尚未领取的金额发送给用户。这有点像用户直接没收了用户可以领取的金额了。这应该不是项目方本意。
例子:我的Claim是4年,目前过了2年,我还没领取,你直接revoke,把我炒了,但是没给我两年的金额
修复:admin在revoke的时候,应该将用户目前可以领取的金额发送给用户
1 2 3 4 5 6 7 8 9 10 11 function revokeClaim(address _recipient) external onlyAdmin hasActiveClaim(_recipient) { Claim storage _claim = claims[_recipient]; uint112 finalVestAmt = finalVestedAmount(_recipient); require( _claim.amountWithdrawn < finalVestAmt, "NO_UNVESTED_AMOUNT"); uint112 amountRemaining = finalVestAmt - _claim.amountWithdrawn; _claim.isActive = false; numTokensReservedForVesting -= amountRemaining; emit ClaimRevoked(_recipient, amountRemaining, uint40(block.timestamp), _claim); }
5.mint增发
1 2 3 4 5 6 7 8 9 10 11 12 13 function mint(address account, uint256 amount) public onlyAdmin { require(account != address(0), "INVALID_ADDRESS"); // 漏洞:当mint的值到达最大值的时候,这个检测将被跳过,没有任何限制 // 例子:一开始mintableSupply=100,那么mint完100个之后, // 理论上不得再mint增发。但是治理是直接跳过了if判断没有进去 // 修改:> 改成 >= if(mintableSupply > 0) { require(amount <= mintableSupply, "INVALID_AMOUNT"); // We need to reduce the amount only if we're using the limit, if not just leave it be mintableSupply -= amount; } _mint(account, amount); }
6.balanceOf异常
漏洞:可变的余额导致资金被锁定或者损失
例子:某些 ERC20 代币的余额可能会发生变化,比如stETH。刚开始创建Claim的时候,价值是10,过一段时间,stETH价值降低变成5:
对于admin:再次调用_createClaimUnchecked()
,require就通过不了,因为stETH价值降低了,小于之前的numTokensReservedForVesting,造成DoS,解决这个的方式只有输入更多的钱到合约当中。
对于用户:调用withdraw()
取钱的时候,可能无法成功获取,因为stETH价值降低,小于了amountRemaining从而revert
修复:禁止这类价值可变的代币
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 例子:stETH function balanceOf(address who) external override view returns (uint256) { return _shareBalances[who].div(_sharesPerToken); } function _createClaimUnchecked() private hasNoClaim(_recipient) { .... // 漏洞 require(tokenAddress.balanceOf(address(this)) >= numTokensReservedForVesting + allocatedAmount, "INSUFFICIENT_BALANCE"); ... } function withdraw() hasActiveClaim(_msgSender()) external { .... // 漏洞 tokenAddress.safeTransfer(_msgSender(), amountRemaining); ... }
7.代理取钱
漏洞:原意是如果用户转错其他token到合约当中,可以取回。但是如果是token使用代理模式的情况下,会出现间接取款的问题
例子:有一个使用代理模式的token:Proxy数据存储合约和logic逻辑合约。我创建Claim的时候设置的tokenAddress是Proxy数据存储合约,并且设置100元。在Proxy和logic两个合约调用balanceOf()
和safeTransfer()
都可以得到100元的结果。因此我调用withdrawOtherToken()
的时候,参数设置为logic合约,通过require的检验,拿走这笔钱,此时合约的token余额为0,但是numTokensReservedForVesting记录的仍然是100。最后用户在调用withdraw的时候就会显示余额不足而revert。
修复:使用余额检验代替地址检验
1 2 3 4 5 6 function withdrawOtherToken(IERC20 _otherTokenAddress) external onlyAdmin { require(_otherTokenAddress != tokenAddress, "INVALID_TOKEN"); uint256 bal = _otherTokenAddress.balanceOf(address(this)); require(bal > 0, "INSUFFICIENT_BALANCE"); _otherTokenAddress.safeTransfer(_msgSender(), bal); }
8.重入攻击
漏洞:如果tokenAddress是类如ERC777等拥有钩子函数的,那么可以在代币修改余额之前,通过钩子函数再次回调withdrawAdmin()
跳过前面的require检验,从而重入攻击
例子:有一种ERC777的token,很多用户都选择了这种token创建Claim,此时合约中拥有1000个token。然后管理员发送100个token到合约当中,那么计算出来的amountRemaining就是10,那么调用withdrawAdmin()
,在调用代币safeTransfer()
的时候,回调到钩子函数,管理员在钩子函数中回调此方法10次,就取完了此合约中的所有ERC777token,把其他人的钱也拿走了
修复:添加nonReentrant
1 2 3 4 5 6 7 8 function withdrawAdmin(uint112 _amountRequested) public onlyAdmin { uint256 amountRemaining = tokenAddress.balanceOf(address(this)) - numTokensReservedForVesting; require(amountRemaining >= _amountRequested, "INSUFFICIENT_BALANCE"); // 漏洞 tokenAddress.safeTransfer(_msgSender(), _amountRequested); emit AdminWithdrawn(_msgSender(), _amountRequested); }