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增发

  • 漏洞:项目方原意是token总量是恒定的,但是检验被跳过,可以无限增发代币
  • 例子:一开始mintableSupply=100,那么mint完100个之后,mintableSupply=0,不会进入if语句,跳过require检测,从而可以_mint()
  • 修复:> 改成 >=。鼓励使用>=,因为solidity操作码没有>,只有>=>需要额外的操作码去判断

  • 收获:遇到if的时候如果没有else,需要警惕,因为边界情况下可能被跳过。也可能是项目方留的后门,rugpull。

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);
}
Prev
2023-09-18 11:11:40 # 21.audit
Next