A Journey to Exploit the MOST Vulnerable Contracts in the DeFi Universe

Taken from the original Damn Vulnerable Defi repo

Damn Vulnerable Defi released its latest update early November, which features four new challenges and some slight modifications of previous challenges.

Below are my solutions.

======HEAVY LOAD OF SPOILER AHEAD======

Tips:

  • Always start the challenge with an end goal in mind and find the function that’s relevant to that particular outcome. For instance, if the goal is to steal all of the tokens, then starting your backward tracing with a function that includes code like this should set you on the right path:
token.transfer(msg.sender, token.balanceOf(address(this)));
  • When in doubt, use hardhat’s
console.log("Current contract's address", address(this));
  • Code solution is here. Some challenges require deploying an attacking contract. They all live in folders under each challenge’s name. If there’s no file named attacker.sol/attack.sol, it means the challenge can be solved solely using Javascript in the test file. You should locate the corresponding test file in the ./test folder to find the solution.

1. Unstoppable

ReceiverUnstoppable.sol
UnstoppableLender.sol

There’s a lending pool offering flash loans, and a player’s goal is to stop the pool. There are two contracts: UnstoppableLender and ReceiverUnstoppable. Unstoppable has two functions: 1) depositTokens() to allow users to increase the poolBalance; 2) flashLoan() to allow users to execute flash loans. ReceiverUnstoppable provides users functions needed to execute flash loans.

Upon reading the prompt, I thought about three possible scenarios where the contract could be exploited: 1) depositTokens() likely interacts with external contracts via ERC20's transfer() or transferFrom(), so a re-entrancy attack might be possible to drain the contract's entire liquidity; 2) flashLoan() functions often use msg.sender contract's function to return the loan borrowed, therefore its implementation could cause a problem; 3) flashLoan() function normally compares before and after contract balances to make sure the correct amount of loan was returned, thus if the token at issue is deflationary, there could potentially be an issue.

After reading the contracts, the first two guesses are wrong, because first, depositTokens uses a reentrancy guard, and second receiveTokens()'s implementation is simple and works exactly how it should work. My last guess might be wrong as well since there's no indication DVT token is deflationary and logic inside of required statements looks fine to me. But,

assert(poolBalance == balanceBefore);

looks suspicious. poolBalance is a state variable only modified inside of depositTokens(), but balanceBefore is a dynamic value that tracks the contract's token balance. If I can increase/decrease the contract's balance, I should be able to put poolBalance out of sync with balanceBefore. Conveniently, ERC20 token has the transfer() method that increases the contract balance while keeping poolBalance unchanged.

unstoppable.challenge.js

2. Naive Receiver

FlashLoanReceiver.sol
NaiveReceiverLenderPool.sol

There’s a lending pool offering expensive flash loans, and there’s another contract capable of interacting with the flash loan pool. The pool has 1000 ETH in balance, and the other contract has 10 ETH. The goal is to steal the 10ETH in the contract and transfer them to the pool.

By looking at the pool contract, I realized that the fee, which equals 1 ETH, is incredibly high. The brute force way to solve this challenge would be invoking the flashloan function 10 times. However, the challenge did encourage stealing the fund in one single transaction, therefore there must be better ways.

To achieve the same goal, I can deploy an attacker contract that invokes the flashLoan contract 10 times with the victim's address as the argument. Then, in the test, I could just deploy this contract and invoke the function that interacts with the flash loan contract.

attacker.sol

3. Truster

TrusterLenderPool.sol

This challenge asks the challenger to steal all of the balance from a lending pool that offers flash loans. The contract is quite simple as it only has one function flashLoan. The flashLoan is a stereotypical flash loan function that compares balance before and after, and invokes another contract's function. The interesting part is the way how it interacts with other contracts. It uses OpenZeppelin's functionCall method, which is a wrapper for Solidity's low-level call method. This opens the door for it to interact with any arbitrary functions on any contract. There are two ways to solve this challenge: 1) deploy an attacker contract that invokes flashLoan function with malicious logic or 2) fill in the calldata from the test case. I'll do both here.

attacker.sol

4. Side Entrance

SideEntranceLenderPool.sol

Again, this challenge presents a stereotypical flash loan contract that checks balances before and after the loan. However, what’s unique about this contract is that the lending contract it interacts with has to implement theIFlashLoanEtherReceiver interface. IFlashLoanEtherReceiver interface only has one function named execute(). It is up to us how we want to implement it. This opens the door to endless possibilities. For this particular challenge, we want to steal all of the funds on the SideEntranceLenderPool contract. By a close examination, I found that deposit and withdraw methods can be used together to create a fake balance out of thin air, in other words, one can increase one's balance using deposit the method without necessarily depositing anything and call withdraw method later to withdraw funds that were never deposited in the first place. To achieve this, I wrote the execute() method to call the deposit method using the value provided in the flashLoan() method. Since I'm not taking a loan out of the SideEntranceLenderPool but only use it to call the deposit() method, I really don't need to return the loan like one normally would. After that, simply withdraw the fund to our own account.

DON’T FORGET TO INCLUDE A FALLBACK/RECEIVER FUNCTION!!!

I spent almost half an hour to debug what went wrong and turned out that I didn’t have a receiver function in my attacker contract to receive the transfer.

attack.sol

5. Rewarder

Man, this one is messy…

We have four contracts in this challenge, but only two really matter for solving the task at hand, namely TheRewardPool and FlashLoanerPool. We have a reward pool for distributing the reward tokens to participants who firstly deposited the liquidityToken. There are four other participants who had engaged in the first round of deposits and had received rewardToken on a pro-rata basis for their contributions. The challenge wants the challenger to steal the reward token without participating in the first round of deposits. Since a flash loan contract is provided, we can definitely take advantage of it.

Below is my thought process:

  • Since I want to receive reward tokens, I need to find the function that’s responsible for reward token distribution.
  • Its name is distributeRewards in TheRewarderPool
  • Ok, the reward distribution is achieved through rewardToken.mint(msg.sender, rewards);, but before that, there are two conditions that need to be met:

amountDeposited > 0 && totalDeposits > 0

rewards > 0 && !_hasRetrievedReward(msg.sender)

  • For the first condition, we only need to make sure amountDeposited > 0 since totalDeposits is already larger than 0 because of the first two rounds of distributions
  • We can increase amountDeposited, aka accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards); by calling the deposit() method, which indirectly calls the distributeRewards() method.
  • Where does the deposit come from? We can use a flash loan provided by the TheFlashLoner contract. - What about lastSnapshotIdForRewards? It would've been updated by _recordSnapshot(). And by the time it was updated, we would have already had accToken under our name since accToken was minted to us before distributeRewards() was invoked.
  • Why _recordSnapshot() would be invoked? It won't be invoked by itself. We need to hard code it in the test file to force the time elapsed to be larger or equal to REWARDS_ROUND_MIN_DURATION .
  • For the second condition, it would automatically pass if we can manage to pass the first condition.
attacker.sol

6. Selfie

SimpleGovernance.sol
SelfiePool.sol

This challenge is somewhat similar to the last one, but there's one more layer to it. We need to deploy a contract that’s called the flash loan contract to interact with a third contract.

The challenge has two contracts: SelfiePool and SimpleGovernance. SelfiePool provides flash loans and SimpleGovernance provides stereotypical governance token mechanisms. The goal of this challenge is to steal all of the funds on the SelfiePool contract.

A quick search of two contracts should reveal that there’s a function called drainAllFunds() looks suspicious. Isn't this is exactly what we want? However, it is conditioned by a modifier named, which indicates that it can only be invoked by the SimpleGovernance contract. Then, the next step is to figure out how to take control of the government contract.

Function executeAction() provides a low-level call through OpenZeppelin's functionCallWithValue() method. However, the calldata portion of the method can only be provided by the function queueAction(). Therefore, we need to figure out how to invoke queueAction(). The good news is to invoke queueAction(), one only needs to pass _hasEnoughVote() method, which checks an account's token balance against half of the total supply. How are we going to get prefill the account with enough token? FlashLoan!

To solve this challenge, we first write the receiveTokens() that will be called by the flashLoan() method. In receiveTokens(), we do three things:

  • invoke the DamnVulnerableTokenSnapshot’s snapshot() method to give lastSnapshot a value;
  • then, we call the queueAction method to fill calldata, which encodes a function call to drainAllFunds()
  • return the flashloan.

Eventually, we can invoke the executeAction() to drain the fund.

attacker.sol

7. Compromised

Exchange.sol
TrustfulOracleInitialized.sol
TrustfulOracle.sol

This was a hard one for me as I’ve don’t have any formal computer science training. Took me quite a while to figure out what those numbers were.

The challenge provides an exchange that one can use to buy/sell NFT, and NFT’s price is queried from an oracle contract. The goal is to steal all of the funds on the exchange contract. Additionally, the challenge also provides two hex data without explicit explanations.

Since the goal is to steal funds, let’s see how the exchange contract interacts with others. It has two functions: buyOne() and sellOne(). buyOne() queries the oracle contract to receive the buying price, and sellOne() does the same for getting the selling price. Other than the oracle contract, both also interact with msg.sender to receive and transfer funds. My guess is the solution should use one of them to siphon off the fund. sellOne() seems more fitting to our purpose as it transfers out the funds on the contract to the msg.sender. Dive deeper, I realized that the value sent to msg.sender is determined by the currentPriceinWei variable, which is the price feed queried from the oracle contract. Therefore, to change the value of currentPriceinWei, I need to manipulate the oracle contract.

Looking at the oracle contract’s functions that the exchange contract interacts with, function getMedianPrice() directly affect the value of currentPriceinWei. Flattening out the getMedianPrice()), it serves to provide the median value of three existing NFT prices. In the process, it utilizes the value provided by pricesBySource[source][symbol], which returns the price given a source(address of the TRUSTED_SOURCE_ROLE) and a token symbol(in our case, DVNFT). This is the value we want! The oracle contract convenient provides a method named postPrice() to modify the state of pricesBySource . However, postPrice() can only be called by any one of the TRUSTED_SOURCE_ROLE. In the current challenge, they are the three addresses provided in the test:

  • ‘0xA73209FB1a42495120166736362A1DfA9F95A105’,
  • ‘0xe92401A4d3af5E446d93D11EEc806b1462b39D15’,
  • ‘0x81A5D6E50C214044bE44cA0CB057fe119097850c’

My guess is, there’s gotta be a way to get access to these addresses. To sign off transactions on Ethereume, one needs an EOA’s private key to sign the keccak-256 hash of the RLP serialized tx data, of which the result is a signature:

  • signature = F(keccak256(message), privateKey), where F is the signing algorithm

*detail can be found here

Therefore, my guess is the hex data could be related to private keys. However, the private key is a 32-byte hexadecimal value, which is different from what’s given in the challenge. Putting the hex into google, many results indicate that it could be utf-8 encoded. Javascript provides a convenient method to decode it, and the results are:

  • MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5
  • MHgyMDgyNDJjNDBhY2RmYTllZDg4OWU2ODVjMjM1NDdhY2JlZDliZWZjNjAzNzFlOTg3NWZiY2Q3MzYzNDBiYjQ4

The next step took me a little while to crack. The above two strings are base64, which can be converted to hex data. The conversion gives us:

  • 0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9
  • 0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48

With the private key, we can use the public key recovery method to get the public key. We could hash the public and take the first 20 bytes, which will be the address associated with the private key. If the address matches one of the three TRUSTED_SOURCE_ROLES, that means they are indeed the private keys. If that's the case, we can sign off the transaction "on their behalf". It turns out they are the private keys of two of the TRUSTED_SOURCE_ROLES. Now, the hardest part is done.

compromised.challenge.js (I wrote a decoder under the same folder)

8. Puppet

Puppet.sol

This challenge asks the challenger to steal all of the funds from a lending pool. The lending pool uses uniswap v1 contracts to facilitate liquidity. My original idea was to manipulate the Uniswap liquidity pool, but a quick glance of the contract revealed that there’s a flaw in the _computeOraclePirce() function: it returns 0 when the nominator is smaller than the denominator. The correct way to calculate the ratio should've been multiplying uniswap.balance by amount before division, but it failed to do so here. To exploit this vulnerability, the steps are:

  • swap token for ETH to bring down the ETH balance in the pool;
  • when the ETH balance is smaller than the token balance, call the borrow() function.
puppet.challenge.js

9. Puppet V2

PuppetV2Pool.sol

This is the continuation of the last challenge. The only difference is the pool gets its price oracle from a liquidity pool on Uniswap. To solve this challenge, I can implement my original guess for the last challenge — oracle manipulation. To do so, we can use swap our token to receive ETH to drive down the price of the token. After the swap, if the return value of calculateDepositOfWETHRequired(POOL_INITIAL_TOKEN_BALANCE) is smaller than the ETH balance of the attacker's contract, then we can convert ETH into WETH and burrow all of the tokens from the lending pool.

puppet-v2.challenge.js

10. Free rider

FreeRiderNFTMarkplace.sol
FlashRiderBuyer.sol

This challenge asks to steal NFTs for a buyer. The buyer would pay 45 ETH for whoever is willing to take the NFT out from an NFT marketplace. The challenger is seeded with only 0.5 ETH, and a Uniswap pool is provided. The challenge also hinted that it would be helpful to get free ETH, even for an instant.

To approach this, I thought about using flash loan, however, the two available contracts do not provide flash loan functionalities. A quick Google search revealed that Uniswap has its own version of flash loan — flash swap.

According to Uniswap’s Doc,

Uniswap flash swaps allow you to withdraw up to the full reserves of any ERC20 token on Uniswap and execute arbitrary logic at no upfront cost, provided that by the end of the transaction you either:- pay for the withdrawn ERC20 tokens with the corresponding pair tokens
- return the withdrawn ERC20 tokens along with a small fee

To use flash swap, Uniswap provides the function swap().

Now, we have the capital required, let’s dive into the challenge. The challenge has two contracts: FreeRiderBuyer and FreeRiderNFTMarketplace. FreeRiderBuyer does only one thing, which is to transfer the bounty from the buyer. Therefore, my guess is the exploitable code should live in FreeRiderNFTMarketplace. The FreeRiderNFTMarketplace does two things, buy and sell NFTs in batches. The buy functions are called in the initial setup of the test, where 6 DamnValuableNFT were put up for sale. To get the NFTs, I need to use the buyMany() function. A quick examination revealed a critical flaw in its design:

  • buyMany() function uses a helper function named _buyOne() to process individual purchases;
  • _buyOne() checks the msg.value when an external actor calls the buyMany() function, but it compares the msg.value against the unit price of an NFT. For instance, if one wants to buy all 6 of DamnValuableNFT, he/she needs to send 90ETH. But because _buyOne() only checks msg.value against the uni price of a NFT, one only needs to send 15ETH to receive all 6 of DmnValuableNFT;
  • Additionally, because _buyOne() also pays the seller from the balance of the FreeRiderNFTMarketplace contract, any purchase would decrease the balance of the contract. If one buys all 6 of the NFTs, it would reduce the contract balance to zero since FreeRiderNFTMarketplace was seeded with 90ETH in balance.

An attack vector could be carried out as such:

  • implement the IUniswapV2Callee interface for the attacking contract;
  • in the uniswapV2Call() function:
  • use flash swap to get 15 WETH;
  • unwrap WETH to ETH and call the buyMany() method;
  • wrap ETH back to WETH and return to the borrowed amount including the fees;
  • send the received NFT to the buyer.

uniswapV2Call() function belongs to the IUniswapV2Callee interface, which would be invoked indirectly by the swap() function.

Again, DON’T FORGET TO ADD receive() function in your attacking contract.

attacker.sol

11.Backdoor

WalletRetristry.sol

You can read @tinchoabbate’s detailed walkthrough of this vulnerability here

If you’ve read all of my previous challenge breakdowns and understood my approach, you should start this challenge by finding the transfer function since the goal is to take all of the fund from registry. Once you are able to find the transfer() function, you should work your way backward step by step. This should lead you to this line:

address payable walletAddress = payable(proxy);

To steal all of the fund, we need to be able to change the walletAddress to our attack contract's address. To do so we need to figure out what's proxy?

It’s the first argument of the proxyCreated() function, and an instance of GnosisSafeProxy. According to the comment, proxyCreated() will be executed when one creates a new wallet via the createProxyWithCallback() method. The next logical step is to find out what does createProxyWithCallback() do and how we can tinker around it to modify proxy. Here's its implementation:

/// @dev Allows to create new proxy contact, execute a message call to the new proxy and call a specified callback within one transaction
/// @param _singleton Address of singleton contract.
/// @param initializer Payload for message call sent to new proxy contract.
/// @param saltNonce Nonce that will be used to generate the salt to calculate the address of the new proxy contract.
/// @param callback Callback that will be invoced after the new proxy contract has been successfully deployed and initialized.
function createProxyWithCallback(
address _singleton,
bytes memory initializer,
uint256 saltNonce,
IProxyCreationCallback callback
) public returns (GnosisSafeProxy proxy) {
uint256 saltNonceWithCallback = uint256(keccak256(abi.encodePacked(saltNonce, callback)));
proxy = createProxyWithNonce(_singleton, initializer, saltNonceWithCallback);
if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonc);
}

proxy is the variable we need to modify. To do so, we need to trace up to the function where proxy was calculated. This should lead to the deployProxyWithNonce() function in the GnosisSafeProxyFactory contract. Here's its implementation:

function deployProxyWithNonce(
address _singleton,
bytes memory initializer,
uint256 saltNonce
) internal returns (GnosisSafeProxy proxy) {
// If the initializer changes the proxy address should change too. Hashing the initializer data is cheaper than just concatinating it
bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
bytes memory deploymentData = abi.encodePacked(type(GnosisSafeProxy).creationCode, uint256(uint160(_singleton)));
// solhint-disable-next-line no-inline-assembly
assembly {
proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
}
require(address(proxy) != address(0), "Create2 call failed");
}

We can see that proxy is created via the CREATE2 opcode. The difference between CREATE1 and CREATE2 is that when deploying a contract using CREATE2, it includes a hash of the bytecode being deployed and a random salt provided by the deployer. It looks like this:

keccak256(0xff ++ deployingAddress ++ salt ++ keccak256(bytecode))[12:]

where

  • 0xff is used to prevent hash collision with CREATE;
  • deployingAddress is the sender's address;
  • salt is the arbitrary value provided by the sender;
  • keccak256(bytecode) is the contract's bytecode;
  • [12:] first 12 bytes are removed.

By comparison, CREATE would look like this:

keccak256(rlp.encode(deployingAddress, nonce))[12:]

where

  • deployingAddress is the sender's address;
  • nonce a sequential number to keep a track of the number of contracts created.

CREATE2 allows the deployer to pre-compute the contract address. The benefit of it is that an address could be generated without deployment, which opens the possibility of scalability and a better user onboarding experience.

Back to the challenge. We can see that proxy variable depends on create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt). The for arguments are:

  • 0x0: the amount of wei sent to the new contract;
  • add(0x20, deploymentData), mload(deploymentData) location of the bytecode in memory;
  • salt an arbitrary 32 bytes value.

Here, since type(GnosisSafeProxy).creationCode and _singleton are fixed, we don't need to worry about deploymentData. Let's take a look at how salt is generated.

bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));

salt is generated by a hash of hashed initializer and saltNonce, where the initializer should be the setup() function in GnosisSafe contract

function setup(
address[] calldata _owners,
uint256 _threshold,
address to,
bytes calldata data,
address fallbackHandler,
address paymentToken,
uint256 payment,
address payable paymentReceiver
) external {
// setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
setupOwners(_owners, _threshold);
if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
// As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
setupModules(to, data);
if (payment > 0) {
// To avoid running into issues with EIP-170 we reuse the handlePayment function (to avoid adjusting code of that has been verified we do not adjust the method itself)
// baseGas = 0, gasPrice = 1 and gas = payment => amount = (payment + 0) * 1 = payment
handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
}
emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
}

and saltNonce should be

// saltUint256 is the address of users casted into an unit256 
uint256 saltNonce = uint256(keccak256(abi.encodePacked(saltUin256, callback)));

Therefore, in order to modify proxy, we need to modify initializer and saltNonce accordingly. Looking at the setup() function, there are a few parameters we could tweak to fit our need:

  • change _owners to existing owners, namely, Alice, Bob, Charlie, David;
  • change to to the deployed attacking contract';
  • change data to execute a delegatecall to whatever address is passed.

Because we want to transfer token to proxy, we should encode an ERC20 style approve() function approving attacking contract's address as the data parameter.

If you receive errors saying “Error: Transaction reverted without a reason string”, it’s very likely you ran out of gas because of this line in the GnosisSafeProxyFactory:

if eq(call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0) {
revert(0, 0)
}

If that’s the case, try declaring variables as immutable.

attack.sol

12.Climber

ClimberVault.sol
ClimberTimelock.sol

If you followed my previous approaches, you should be looking at sweepFunds() function in the ClimberValut contract. However, sweepFunds() can only be called by sweeper, which is a privileged role initialized in the initializer() function. Because ClimberVault uses a initializer() function and an empty constructor, we can tell that it follows the UUPS pattern. This means we might be able to upgrade the contract through a proxy contract.

There are many details that need to be followed when writing upgradeable contracts. For instance, the constructor should not be used since the code within will never be executed in the context of a proxy contract’s state. Additionally, field declaration should be avoided as this is equivalent to declaring them in a constructor unless they are defined as constant state variable.

For ClimberVault, we can see that it inherits from OpenZepplin's UUPSUpgradeable contract, which includes a virtual function named _authorizeUpgrade(). Here, ClimberVault also has a function named _authorizeUpgrade() that should be overriding the same function from UUPSUpgradeable. The only issue is, it has onlyOwner a modifier. The current owner is the ClimberTimelock contract. Therefore, we need to take control of ClimberTimelock. The good news is ClimberTimelock inherits AccessControl, and the constructor ClimberTimelock is a self-admin. In other words, we can call AccessControl.grantRole() to the attacking contract.

To interact with the upgraded contract, we can use the execute() method in the ClimberTimelock, which execute three arrays of sequential calls. These arrays are filled by calling the schedule() function. There's a hard coded delay to execute scheduled calls, but since we are the PROSER_ROLE now we can use the updateDelay to change it to zero. After that, we can change the sweepFunds() function to fit our need, namely deleting the modifier and changing the transfer address to ourselves.

To execute the exploit, in the attack contract, we need to do the following:

  • change the proser_role of the ClimberTimelock contract
  • change delay time
  • upgrade logic contract from proxy
  • schedule all the sequential calls
  • execute the sequential call
attack.sol

--

--

--

https://twitter.com/dabaojianghy

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

Walkthrough : VulnHub : The Planets: Earth

BulletProftLink: An Analysis of a Russian Cybercriminal’s Phishing As A Service Site

zkLend and Its Mission

Week 6: The Unseen Menace

European Legislation — The Future Of Cryptocurrency?

{UPDATE} Old MacDonald Had a Farm Song! Hack Free Resources Generator

Tracking Data to Stop Coronavirus

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
da | bao | jian

da | bao | jian

https://twitter.com/dabaojianghy

More from Medium

Damn Vulnerable Defi Walkthrough Part Two: Challenge 7–12.

Cryptography in Ethereum

How to Make the BlockChain Attack “Blockable”

Uniswap V2 Explained (Beginner Friendly)