Beating The Damn Vulnerable DeFi CTF (Part 2/2)

Jat
34 min readNov 24, 2022

--

“Ethereum hacker” image generated by Stable Diffusion

This article is the follow up to the previously published blogpost in which we gave solutions to the first 5 challenges of the Damn Vulnerable Defi CTF. This time, we are going to solve the second part of the CTF, containing harder exercises. Note that all of the proposed solutions here correspond to version 2.2 of Damn Vulnerable Defi, this is important especially for the last challenge, no 13, which is being heavily reworked for the upcoming v3 version, according to the CTF author.

6. Selfie

“Selfie” — Stable Diffusion

Here is the description of the Selfie challenge:

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

Reading the script file for this challenge we learn that we begin with the following setup:

  • The DVT token is deployed as a DamnValuableTokenSnapshot contract, which is essentially inheriting all its logic from the ERC20Snapshot OpenZeppelin contract. This is a standard ERC20 token but with the added capability to take a snapshot of all the owners and of total supply in a gas efficient manner when calling the _snapshot internal function (which is also exposed through the public function snapshot of the DamnValuableTokenSnapshot class). It has a total supply of 2M tokens.
  • The attacker begins with the 10K ETH distributed by default to all the default hardhat network signers but doesn’t own any DVT token.
  • A SimpleGovernance contract is deployed, this contract allows anyone with enough DVT tokens — more than half of the total supply at the time of last snapshot — to request a transaction to be done by the contract after some delay, at least 2 days (ACTION_DELAY_IN_SECONDS = 2 day) by calling queueAction function. When the minimum delay is ellapsed, the account who made the request could then make the SimpleGovernance execute it by calling executeAction with the corresponding actionId.
  • A SelfiePool contract which holds a total of 1.5M DVT tokens (75% of total supply). This pool allows any calling smart contract to borrow up to 1.5M DVT tokens (its actual balance) and reimburse it in a single atomic transaction through calling the flashLoan function. There is also another very interesting function called drainAllFunds which is protected by the onlyGovernance modifier. As the names suggest, this function could only be called by the SimpleGovernance contract and allows it to drain all the DVT tokens from the SelfiePool and to send them to any receiver address.

Here is a diagram summarizing the initial setup:

Solving this challenge is pretty straigthforward once we understand that the only solution to drain the funds from SelfiePool is to make the SimpleGovernance execute drainAllFunds(attacker.address) from SelfiePool.

The attack should be done in a 2 steps process : for the first step, the attacker deploys the following FlashLoanSelfie contract and directly calls its attack1 function to do the flash loan borrowing the 1.5M DVT tokens from SelfiePool:

During the flash loan, after receiving all the DVT tokens holded by the pool, the receiveTokens function of FlashLoanSelfie is automatically called, in which a snapshot of the DVT token is first taken, this allows then the FlashLoanSelfie contract to call the queueAction function from the SimpleGovernance contract (because at this point, FlashLoanSelfie had more than 50% of the total supply of DVT at last snapshot). Of course, it will request the drainAllFunds to be executed on the SelfiePool, with the attacker address as an argument. All the DVT tokens are reimbursed to SelfiePool at the end of the receiveTokens call, to make the flashloan transaction valid.

The second step will then be for the attacker, after the required waiting duration of 2 days, to call the attack2 function which will execute the previously queued action to make the governance contract drain the funds from the lending pool and send the funds to him. In code, the exploit is:

After typing in the CLI :

yarn selfie

The test passed : Mission Accomplished!

7. Compromised

“Compromised” — Stable Diffusion

We have the following description :

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.

To understand the initial setup, we read the first part of the test script file:

  • We have 3 EOAs (Externally Owned Accounts) which are acting as sources of price information for the oracle : their addresses are hardcoded in the sources list. Each of these sources initially own 2 ETH. This will be more than enough to pay the gas fees to let them update the price given by the oracle by calling the postPrice function on the TrustfulOracle contract (they are the only accounts authorized to call postPrice, thanks to the onlyTrustedSource modifier). These updates should be executed anytime that a price change of the NFT is detected.
  • The attacker account is initially provisioned with only 0.1 ETH.
  • An Exchange contract is deployed, with a balance of 9990 ETH. This exchange is selling newly minted DamnVulnerableNFTs (DVNFTs) for anyone calling the buyOne function while sending a sufficient amount of ETH: the price of each NFT is given by calling the TrustfulOracle contract, which we are about to describe. This contract is also able to buy and burn DVNFTs from any NFT owner calling the sellOne(tokenId) function (after approving the exchange). The ETH amount received by the seller is also given by TrustfulOracle.
  • A TrustfulOracle contract is deployed, it acts as a source of truth for the price of the DVNFTs. The getMedianPrice function of this oracle is called by the Exchange contract everytime someone is executing either a sellOne or a buyOne transaction. getMedianPrice is returning the median of the prices given by the registered trusted sources. Initially all of the 3 trusted sources are initialized to have set up a DVNFT price of 999 ETH. After initialization, the only way to modify the oracle price is if several sources (at least 2 sources, because we are dealing with the median price of 3 values and 2/3>50%) would call the postPrice function with a new price. Recall that the sources are the only accounts authorized to call postPrice, because of onlyTrustedSource modifier.

Here is the diagram summarizing the initial setup:

If only we were in control of 2 of the sources… If we were able to find two private keys corresponding to two of the sources, we would become able to completely manipulate the DVNFT price given by the oracle. For example we could set the price to be 0 (median of [0,0,999] = 0) to buy a DVNFT for free, then set the price to 9990 (median of [9990,9990,999]) to sell this NFT back and drain all the funds from the Exchange, and finally reset the DVNFT price to the initial 999 price (last step is mandatory to pass the last success condition from test script). But where to find those 2 private keys?

Recall from the description of the challenge that we got an HTTP response with 2 long hexadecimal string composed each of 88 bytes : hence, they are too long to be private keys (which should be 32 bytes long). However, in a hardhat console, if we try to convert the hex string to ASCII by typing:

const array_bytes_1 = "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".split(" ")
const result_1 = array_bytes_1.map(hex => String.fromCharCode('0x'+hex))
console.log(result_1.join(""))

The following output is printed:

MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5

We notice that we only got simple ASCII characters, the sharp eye would recognize this string as a base64 encoded string (despite the missing “=”s for padding). We can decode it simply by using the atob builtin javascript function:

console.log(atob(result_1.join("")))

Which outputs :

‘0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9'

Finally, a 32 bytes long string, the correct format for an Ethereum private key. When checking for the corresponding address :

console.log(new ethers.Wallet(atob(result_1.join("")), ethers.provider).address)

We actually get the address of the oracle trusted source corresponding to sources[1] :

‘0xe92401A4d3af5E446d93D11EEc806b1462b39D15’

And when we repeat the same computations for the second hex string returned by the HTTP response:

const array_bytes_2 = "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".split(" ")
const result_2 = array_bytes_2.map(hex => String.fromCharCode('0x'+hex))
console.log(new ethers.Wallet(atob(result_2.join("")), ethers.provider).address)

We get the address of sources[2] :

‘0x81A5D6E50C214044bE44cA0CB057fe119097850c’

Bingo! We can now manipulate the oracle price as we please and execute the previously described attack plan. The exploit in the test script is:

Running this script, we are able to drain all the funds from the exchange!

8. Puppet

“Puppet” — Stable Diffusion

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.

The initial setup as described in the test script is :

  • The attacker begins with a balance of 25 ETH and 1000 DVT.

“Uniswap uses a “constant product” market making formula which sets the exchange rate based off of the relative size of the ETH and ERC20 reserves, and the amount with which an incoming trade shifts this ratio.”

  • Uniswap is a very popular Decentralized Exchange (DEX), its first version Uniswap v1 was launched in November 2018 and got huge adoption. See the documentation for more details on its mechanism. A Uniswap v1 pool is deployed for the DVT token with little liquidity provided : only 10 ETH and 10 DVT. Note that contrarily to Uniswap v2 pools, all the v1 liquidity pools must have ETH as one of the currencies of the pair (we can’t have for example a DAI/DVT pool).
  • A Lending pool following the PuppetPool contract is deployed, provided with 100000 DVTs. We can borrow DVTs from this pool under the condition that we deposit an amount of ETH equivalent to twice the price of the borrowed DVT amount. The goal is to drain all the DVTs holded by this pool.

We notice that the depositRequired amount is directly computed from the balances in ETH and DVT holded by the Uniswap v1 liquidity pool, i.e the Uniswap v1 liquidity pool acts as on-chain price oracle (given by the sport price of the DEX) :

Another important remark is that the liquidity size holded by the Uniswap v1 pool is very small compared to the attacker’s own balances, especially on the DVT side: the attacker holds 100 times the amount of DVT tokens holded by the exchange. If you have been following the news about some of the many DeFi hacks from last year or two, this should ring alarm bells. On-chain oracles are known to be one of the main origins of vulnerabilites in DeFi, because they can be easily manipulated when one have access to a lot of capital (either directly like in this challenge or through flash loans).

In this case the attack plan is simple: the attacker sells all his 1000 DVTs (minus 1 Wei, to pass the last condition of the test script) through the Uniswap v1 pool. This will make the price computed by the PuppetPool decrease drastically. Indeed, the initial price was 1ETH per 1DVT because the Uniswap exchange was holding an equal amount of ETH and DVTs. But after the trade, the number of DVT owned by the pool becomes 10010 while the amount of ETH decreased. Hence, the numerator in the _computeOraclePrice() function decreased while the numerator increased by a big factor. This allows the attacker afterwards to borrow all the DVTs from the lending pool by depositing for example 20 ETH, or a slightly lesser amount, approximately 19.67 ETH is actually enough according to the console logs in this attack script:

We succeeded to manipulate the oracle price and to drain all the funds of the PuppetPool contract!

Note : to mitigate the impact of this kind of flash loan attacks, it is recommended to use a decentralized price oracle such as Chainlink instead of relying on a unique (centralized) on-chain oracle like Uniswap liquidity pools.

9. Puppet v2

“Puppet v2” — Stable Diffusion

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 ;)

The initial setup is quite similar to the previous challenge, as can be seen in the test script:

  • The attacker now has an initial balance of 20 ETH and 10K DVT.
  • The Uniswap v2 liquidity pool is initially provided with 10 WETH and 100 DVT. For increased capital efficiency, Uniswap v2 pairs, contrarily to the v1, can be made up of any pairs of ERC20 tokens, we do not need ETH to be one of the currencies (actually, in the v2 case, the ETH denominated pairs are using WETH in place of ETH). For more detailed informations about the Uniswap v2 protocol architecture, we redirect the reader to the official documentation.
  • The lending pool has a balance of 1M DVT. It is a deployed PuppetV2Pool contract. The attacker must drain all its funds to win the challenge.

This time, the attacker must first wrap part of his ETH to convert it to WETH : this is simple done by sending an amount of ETH to the WETH9 contract (the sender will automatically be credited an equivalent amount of WETH, as can be seen in the fallback function of the WETH9 contract). Then the attacker should approve the lending pool to use a desired amount of his WETH, and finally call the borrow function from the lending pool to deposit the required amount of WETH and borrow the corresponding amount DVTs in a single last transaction. But for this process to be successful, the very first step, before the wrapping phase, must be to make sure that we are allowed to borrow all the available DVTs. Could this be done again by first selling a big amount of DVTs on the Uniswap exchange to manipulate its price? This is indeed the case, and we will shortly see why!

We notice in the following snippet, taken from the PuppetV2Pool contract, that the value of required deposited amount of WETH must be the triple of the borrowed amount:

When digging into the UniswapV2Library and UniswapV2Pair codes, we see that the price computation done by the UniswapV2Library.quote function is actually very similar to the price computation which was done in the previous challenge! Indeed, it is yet again a cross-multiplication depending on the amount of DVTs that we want to borrow, which is multiplied by the ratio of reserves : reservesWETH/reservesToken. We must check how the getReserves function of UniswapV2Pair is updated and computed. Thankfully, at the end of each call to the swap function of the exchange, we notice that the reserves for both ERC20 tokens of the pair are well updated, as can be seen on lines 173, 174 and 185 of the UniswapV2Pair contract. However, this swap function is a low level function and should not be used directly. According to the Uniswap V2 documentation, if we wish to make a trade and sell DVTs for ETH (this is needed to manipulate the price oracle, like the previous challenge), it is recommended to call the swapExactTokensForETH function from the UniswapV2Router02 contract, as explained in the documentation.

In summary, we can use the following exploit :

We notice that after the swap, the attacker had a total balance of approximately 29.9 ETH, while the necessary collateral to be deposited in WETH in the lending pool to be able to borrow the whole 1M DVT dropped from the initial 300K WETH to only around 29.497 WETH < 29.9 WETH. This is why the attacker was able to pull off the attack and the test script is finally passing!

10. Free Rider

“Free Rider” — Stable Diffusion

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.

A buyer has shared with you a secret alpha: the marketplace is vulnerable and all tokens can be taken. Yet the buyer doesn’t know how to do it. So it’s offering a payout of 45 ETH for whoever is willing to take the NFTs out and send them their way.

You want to build some rep with this buyer, so you’ve agreed with the plan.

Sadly you only have 0.5 ETH in balance. If only there was a place where you could get free ETH, at least for an instant.

We find more details and some hints in the challenge test script :

  • First of all, we have access to a Uniswap V2 liquidity pool provisoned with 15000 DVT and 9000 WETH. This suggests, in conjunction with the last sentence from the challenge description, that in order to get access to additional “free ETH” we might have to do a flash loan by using the Flash Swap functionality available in the Uniswap V2 protocol, as described in the documentation here.
  • A FreeRiderNFTMarketplace contract has been deployed and a deployer account who owns 6 DVNFTs has listed all of his 6 DVNFTs on this marketplace in order to place an offer to sell them at a price of 15 ETH each (by first approving the marketplace on his DVNFTs and then calling the offermany function). Strangely, we notice that the marketplace has also been initially provisioned with 90 ETH, for no obvious reason — but this detail will be critical for the attack as we will see shortly (hint: also notice that 6*15=90).
  • Another account called buyer has deployed a FreeRiderBuyer contract and provisioned it with 45 ETH. This is a simple contract which can receive any number of DVNFTs, thanks to the implementation of the necessary onERC721Received hook which is called automatically after receiving any NFT transfer, as per the official EIP-721 specification. This hook has been customized and designed to make the FreeRiderBuyer contract accept only one NFT collection — the DVNFTs — and only if the sending transaction has been originated by the attacker’s account — whose address was set up as the partner variable. Also, this hook will let the FreeRiderBuyer contract to automatically send 45 ETH to the attacker once he was able to send it the 6 DVNFTs, as was promised by the buyer (see lines 46–47). The buyer could at anytime recover the DVNFTs which were sent to FreeRiderBuyer, thanks to the approval in the constructor.

Here is a diagram summarizing the initial setup :

According to the challenge description there is a vulnerability in the FreeRiderNFTMarketplace contract, so let’s take a closed look :

There are actually two logic errors : first of all, lines 77 and 80 should have been inverted, because in this version of the code we notice that the NFT being bought is first sent to the buyer on line 77 (in our case the buyer will be the attacker account, not the buyer account) so the owner of the corresponding tokenId becomes the buyer who will receive the due payment priceToPay instead of the original seller (who is the deployer account here). But even if those two lines were inverted, this would not totally fix the FreeRiderNFTMarketPlace contract, there is a second bug : indeed when calling buyMany we are calling the _buyOne function several times in a loop, but we are checking that msg.value is greater than the price of a single DVNFT (line 72) inside _buyOne. This is incorrect, because it means that the attacker could buy the 6 DVNFTs for only 15 ETH — the price of a single NFT, and this is possible because the marketplace is holding 90 ETH which is enough to pay for the 6 DVNFTs! To fix this second issue, we could have instead added a global check inside the buyMany function to make sure that msg.value is greater than the sum of the prices of all the DVNFTs that we wanted to buy.

The plan of the attack is then the following :

  • The attacker deploys a FreeRiderFlashSwap attack contract implementing the uniswapV2Call function which is necessary to borrow (and later repay) some WETH from the Uniswap V2 pool using the flash swap functionality — single token version. This contract will also implement the original onERC721Received hook function which is mandatory to let it accept NFT transfers, see EIP-721 specification.
  • The attacker will then call directly the low level swap function of the UniswapV2Pair contract — contrarily to previous challenge we don’t use the router contract as we are not making a usual swap.
swap(uint amount0Out, uint amount1Out, address to, bytes calldata data)
  • amount0Out should be equal to the amount of WETH we wish to borrow, in this case equal to 15 ETH. amount1Out will be null because we do not need to borrow any DVT. The to argument will be the address of the FreeRiderFlashSwap contract. The last data argument of the swap function will be a bytes type of non-null length as explained in the documentation or as can be seen directly on line 172 of UniswapV2Pair.
  • All the attack and flash loan repayment logic will be located in the uniswapV2Call function of FreeRiderFlashSwap. uniswapV2Call will be called by the UniswapV2Pair after the WETH has been sent to FreeRiderFlashSwap- see line 172. First step in uniswapV2Call will be to unwrap the 15 WETH to convert them to native ETH —hence FreeRiderFlashSwap should also implement a receive function (or alternatively a payable fallback). Then the buyMany function from the marketplace is called, to buy the 6 DVNFTs. This will give FreeRiderFlashSwap an additional 6*15–15 = 75 ETH (because of the previous logic errors). The attacker’s contract then wraps again some ETH to get back enough WETH and repays them to UniswapV2Pair to reimburse the 15 WETH + fees due to the Uniswap V2 pool. The total repaid amount (i.e 15 WETH + fees) should roughly be equal to borrowed_amount/0.997 = 15/0.997 WETH — see formula on line 182.
  • At the end of the uniswapV2Call call of FreeRiderFlashSwap, it sends the 6 DVNFTs it now owns to the FreeRiderPool, and the challenge is solved!
  • Optionally, the extra ETH received by the attack contract is finally sent to the attacker’s account.

The FreeRiderFlashSwap code which implements the attack plan is :

Finally, here is the exploit part of the test script which lets us succeed this challenge:

11. Backdoor

“Backdoor” — Stable Diffusion

To incentivize the creation of more secure wallets in their team, someone has deployed a registry of Gnosis Safe wallets. When someone in the team deploys and registers a wallet, they will earn 10 DVT tokens.

To make sure everything is safe and sound, the registry tightly integrates with the legitimate Gnosis Safe Proxy Factory, and has some additional safety checks.

Currently there are four people registered as beneficiaries: Alice, Bob, Charlie and David. The registry has 40 DVT tokens in balance to be distributed among them.

Your goal is to take all funds from the registry. In a single transaction.

This challenge was harder than the previous ones, as the official documentation of Gnosis Safe was less detailed than the Uniswap documentation. We had to dive deep into the code of the Gnosis Safe contracts. First of all, let’s analyse the setup from the test script :

  • Additionally to the well-known DamnValuableToken (DVT) contract, 3 other main contracts are deployed : the 2 official contracts from Gnosis Safe — GnosisSafe (called masterCopy in the script) and GnosisSafeFactory (called walletFactory), as well as the challenge specific contract WalletRegistry.
  • WalletRegistry is provisioned with 40 DVTs. Looking at the code, we understand how it is supposed to work, especially when reading the documentation of the proxyCreated function in NatSpec format, just above its definition :

In other words, the deployer of WalletRegistry has registered 4 users: alice, bob, charlie and david to allow them to claim 10 DVTs each if they create a Gnosis Safe Wallet by calling the createProxyWithCallback function of GnosisSafeFactory and by setting the registry’s address as its callback argument. In summary, here is the diagram of the initial setup :

One thing to notice is that createProxyWithCallback is a public function which lets anyone, including the attacker, to initiate a transaction which will create a Safe wallet on behalf of any of the registered users. Let’s see why by looking closer at the code.

function createProxyWithCallback(
address _singleton,
bytes memory initializer,
uint256 saltNonce,
IProxyCreationCallback callback) public returns (GnosisSafeProxy proxy)

Firstly from the WalletRegistry contract, _singleton must be the address of the original GnosisSafe mastercopy, see line 74. Also, the initializer bytes argument should represent an ABI encoded call to the setup fonction of GnosisSafe, see line 77 of the registry contract. setup is responsible for initializing the deployed proxy of Gnosis Safe wallet and its _owners argument correspond to to the owners of the deployed wallet. This setup function is very interesting, we will explore it more in depth shortly.

Now, if we go back to GnosisSafeFactory to dive deep into its code and trace back all the instructions executed when someone calls the createProxyWithCallback function, we find that it first deploys a proxy of the original GnosisSafe (aka masterCopy) contract on line 52. Then the initializer bytes argument is used to call the setup function to initialize the proxy : this initializing phase must succeed because of the check line 70.

Actually, Gnosis Safe is using the clone factory pattern, i.e GnosisSafeFactory is creating “clone proxies” of a same GnosisSafe “master” contract. The clone proxy is a very simple and non-upgradable proxy: it simply forwards all the calls made to it to an implementation contract, via the DELEGATECALL opcode. This allows cheaper deployment costs for each newly created Gnosis Safe wallet. For details on this kind of simple kind of proxy, read OpenZeppelin’s blog : the described concept is very similar but implemented directly in bytecodes, while Gnosis Safe used Yul (Inline Assembly) for its Proxy implementation.

Short note on DELEGATECAL : When a contract A named, for instance,“proxy” is invoking DELEGATECAL on a contract B named “implementation” the delegatecall operation allows to call any function implemented by the “delegatecalled” implementation contract, but — contrarily to a regular CALL — we stay in the context of the proxy contract, i.e the used storage will be the proxy contract’s own storage and msg.sender and msg.value will remain the same as when the proxy was initially called. In other words, it is as if we were injecting the logic of the “delegatecalled” contract inside the calling contract.

Finally, after creation and initialization of the new wallet, the proxyCreated function from the registry will be called, see line 90.

Now that we have understood how the Gnosis Safe wallet creation works, we still have some great degree of freedom when choosing the way we initialize them. Let’s take a close look at the setup function from GnosisSafe. The NatSpec documentation is enlightening :

From the above NatSpec comments, we understand that to create the 4 wallets for the registered beneficiaries, _threshold should be equal to 1 and _owners must be an array of size 1, each time with the address of the beneficiary we want to create a Safe wallet for. The most interesting parameters are probably to and data : they allow to do an optional delegatecall according to the comments. More precisely, after setting up the multisig main parameters via the setupOwners call and after optionally setting the fallback handler, the setupModules(to,data) function is called. This function is inherited from the ModuleManager contract — lines 20–26. Upon closer look, it invokes the execute function on line 25 with the DelegateCall operation argument and ensures that it succeeds:

require(execute(to, 0, data, Enum.Operation.DelegateCall, gasleft()), "GS000");

This execute function is imported from the Executor contract and we verifiy that a delegatecall is indeed executed on the to contract on line 18:

success := delegatecall(txGas, to, add(data, 0x20), mload(data), 0, 0)

This intimidating assembly code is actually easy to understand, it is a standard way to do a delegatecall in Yul (see official documentation). Since data is of type bytes, a dynamic data structure, it has a varying size that is stored in the first word size (of 32 bytes) in data. As we want to extract just the actual data of data, we need to step over the first word size, and start at 0x20 (32 bytes) of data. This is why the starting pointer (3rd argument of delegatecall) for the data being sent during delegatecall is add(data,0x20) — in the inline assembly block, data actually refers to the memory pointer i.e the in memory address of the beginning of data variable. The size of the data to copy and send (4th argument) is then mload(data), i.e the first word of data because mload loads the first 32 bytes starting from its input pointer argument.

We will not need to setup a fallbackHandler (we use the null address for the corresponding argument) and neither use any of the last 3 setup arguments (paymentToken, payment and paymentReceiver) which would have been only useful if we wanted to use a relayer service to send the transaction to create a wallet on our behalf.

Now that we understand the nitty gritty details of how the proxy initialization is done during the setup call, we can foresee an attack vector through the use of the optional delegatecall. The attacker will first deploy the following BackDoorAttack contract :

When the attacker will call the attack function of the BackDoorAttack contract, it will create the 4 wallets on behalf of the 4 registered beneficiaries. The initializer bytes variable is carefully built, such as during the initialization of the Gnosis Safe wallets a delegatecall from each newly created Safe wallet to the BackDoorAttack’s setApproval function is made. This allows the wallets to pre-approve the 10 DVT tokens they are about to receive after initialization will be completed, when the callback proxyCreated function of the registry function will get executed. This is indeed possible thanks to the delegatecall sent by the wallets to the BackDoorAttack contract which maintains the caller’s context (i.e the token.approve(_backDoorAddress, 10 ether) inside the setApproval will be called by the wallet account, instead of by BackDoorAttack as in a regular call).

VERY IMPORTANT DETAIL : for the delegatecall to the setApprove function to work as expected, it is crucial to set the token variable in the BackDoorAttack contract as immutable (or constant) to ensure that it will not be part of the contract’s storage — recall that immutable and constant variables are directly inserted in the contract’s bytecode. Indeed, if we do not take care of this detail, because the delegatecall conserves the calling context, it will either fail or not work as expected, because the token variable will then point to another variable from the storage of the delegatecalling wallet account, but located at the same storage slot as the original token variable from BackDoorAttack.

After the walletFactory.createProxyWithCallback(…) call, the wallet of each beneficiary will have been awarded the 10 DVTs, but because the BackDoorAttack contract has been granted an approval to spend those DVTs on behalf of each wallet during the delegatecalls, it will directly transfers those 10 DVTs from each wallet to the attacker’s account, therefore the challenge is solved after executing this exploit script :

Note : An easy fix to the vulnerability of this challenge could be to add a “require(beneficiaries[tx.origin]==walletOwner)” at line 87 of the proxyCreated(…) function of the registry contract, as this would forbid an attacker to create a Gnosis Safe wallet on behalf of other registered accounts, while the registered EOAs would be only allowed to create a Safe Wallet on there own behalf”.

12. Climber

“Climber” — Stable Diffusion

There’s a secure vault contract guarding 10 million DVT tokens. The vault is upgradeable, following the UUPS pattern.

The owner of the vault, currently a timelock contract, can withdraw a very limited amount of tokens every 15 days.

On the vault there’s an additional role with powers to sweep all tokens in case of an emergency.

On the timelock, only an account with a “Proposer” role can schedule actions that can be executed 1 hour later.

Your goal is to empty the vault.

We describe the initial setup from the script file :

  • Our attacker account starts with a balance of only 0.1 ETH.
  • A deployer deploys an upgradable proxy for a ClimberVault contract. This poxy follows the UUPS pattern which is the recommended way nowadays to launch new upgradable contracts, according to OpenZeppelin. From line 79 we see that only the owner of this vault contract could upgrade it.
// By marking this internal function with `onlyOwner`, we only allow the owner account to authorize an upgrade
function _authorizeUpgrade(address newImplementation) internal onlyOwner override {}13. Safe Miners
  • We notice that during deployment, a ClimberTimelock contract is created and ownership of the ClimberVault is transfered to it. Also, the sweeper account is given the exclusive (access restricted via the onlySweeper modifier) right to call the sweepFunds function which would let him drain all the funds (any ERC20) from the vault. All the other external functions of the vault are actually access restricted to the owner, i.e the ClimberTimeLock contract : the withdraw function is protected by onlyOwner as well as the internal _authorizeUpgrade which adds a similar access control to the external upgradeTo inherited from UUPSUpgradeable (as an aside note, remember that the upgrade logic is indeed located inside the implementation contract in the UUPS pattern, contrarily to the Transparent Proxy pattern which puts it inside the proxy contract). This means that only the ClimberTimeLock contract can initiate an upgrade of the vault or call the withdraw function — anyways, this last function is not very interesting because it allows the owner to withdraw only 1 ether every 15 days from the vault. If we could get access to the upgradeTo we could ideally upgrade the vault to some other contract which would let us withdraw directly all the funds.
  • Because upgradeTo is restricted to the ClimberTimelock owner contract, let’s take a close look to its code.
  • This contracts inherit from OpenZeppelin’s AccessControl library, which allows scenarios where granular permissions are required, which can be implemented by defining multiple roles. Two roles are defined in this case. All the roles are assigned inside the constructor, i.e during the creation the timelock contract :
  • We see that the proposer account has been granted the PROPOSER_ROLE, while the deployer account and, interestingly, the ClimberTimelock contract itself, both possess the ADMIN_ROLE. The ADMIN_ROLE has more privileges than the PROPOSER_ROLE, because it is set as an admin for the PROPOSER_ROLE (and it is also its own admin). This means that the deployer and the timelock smart contrats have the power to grant or revoke the PROPOSER_ROLE (or ADMIN_ROLE) for other accounts. OpenZeppelin’s documentation is well written if you want to read more details.
  • The ClimberTimelock contract has 3 main external functions (this is of course in addition to the functions inherited from AccessControl). Two of them are actually access restricted : the schedule function is only callable by accounts with a PROPOSER_ROLE because of the onlyRole(PROPOSER_ROLE), and the updateDelay function is callable only by the timelock smart contract itself because of the require statement at the beginning of its body. The entry point for attacker must then certainly be via the third function, the only which is not protected, the execute function.

Here is the diagram summarizing the initial setup :

Concretely, the schedule function allows any account with a PROPOSER_ROLE to propose and register an operation, i.e one or several (up to 256) calls to some selected functions (via the datalements argument) from some selected contracts (via the targets argument) to be initiated in a single transaction through a call to execute after some delay — 1 hour by default. When 1 hour passed after a proposal, anyone could call execute with the same arguments as the registered operation and the timelock contract will run the proposed calls sequentially in a single transaction. At first, it seems that this 1 hour delay is mandatory with the initial setup because of the require statement located at the penultimate line of the body of the execute function, line 108 :

require(getOperationState(id) == OperationState.ReadyForExecution);

By looking at the body of getOperationState we deduct that this check allows three things to be verified : first the existence of a proposal for the requested operation, and if it has not been executed yet and finally if the correct delay (of 1 hour) between proposal and execution has been respected. If this waiting period was indeed unavoidable, then there would be no way to drain the vault. But is it really the case?

Actually it is not the case, there is a logic error on line 56 of the timelock contract, this inequality is always true and should have been reversed i.e op.readyAtTimestamp <= block.timestamp instead would have been the correct check. But even if this vulnerability was fixed, we could always make the timelock contract first call updateDelay(0) via a contract deployed by the attacker which would call execute with updateDelay(0) as one of the requested function calls and then continue with the same attack steps that we are about to describe shortly.

The goal of the attacker is to deploy a contract called ClimberAttack which would call the execute function of the ClimberTimelock contract with a not yet registered operation/proposal, and because all the function calls inside of execute will be executed before the operation existence check of line 108, we should make sure that one of the calls inside this proposal will be responsible of its own creation (via calling schedule). Only PROPOSER_ROLEs can create proposal via calling the schedule function. Remember that the timelock account itself has an ADMIN_ROLE which can grant such a PROPOSER_ROLE and all the function calls inside of execute are of course made by the timelock account.

So the first step of the attacker’s contract proposal will be to grant itself the PROPOSER_ROLE, via a call grantRole(PROPOSER_ROLE,attackerContract).

Then the next call of the proposal will be a call to upgradeTo(newImplementation) on the address of the proxy representing the vault. Because this call will be again made by the timelock contract which is the owner of the vault, it will succeed. The newImplementation contract could be either another contract which would have been pre-deployed by the attacker or still the same attacking contract — we chose the second simpler option. The important thing is that the new implementation must contain a drainFunds function which would transfer all the DVT tokens to the attacker once called. Also, for the upgrade to succeed we must make sure that the new implementation inherits from UUPSUpgradable and overrides the mandatory _authorizeUpgrade function. Another thing to pay attention for, like in the previous challenge, is to avoid using new storage variables and use immutable variables instead in the newImplementation contract, to avoid any storage clashes when delegatecalling later from the proxy. Usually, when doing an upgrade the new implementation should also inherit from the old implementation (i.e the ClimberVault ) to preserve the original storage layout (this would have been necessary if we really wanted to add new variable storage), but as we are not really upgrading the contract to an improved logic but just exploiting it, we will not do this here to save some gas fees.

Then a 3rd call in the proposal will be to call the newly added drainFunds function from the upgraded vault contract, which will send the DVTs to the attacker.

Finally, the last step of the proposal is to create itself via a call to schedule to pass the final check of line 108. This can be done now by calling some createProposal function from the ClimberAttack contract which would itself call the schedule function of the timelock. Indeed, remember that this is possible because we have granted the PRPOSER_ROLE at the beginning of the attack to our ClimberTimelock contract. A neat thing with this approach is that we avoid some infinite loop problem by making the createProposal function not requiring any argument. An infinite loop issue would have arisen if, instead of granting ClimberAttack the PROPOSER_ROLE to make it later able to call indirectly schedule via a call to createProposal, we had decided naively to directly try to call the schedule function from the timelock contract (because then, the schedule function would have as one of its dataElements argument some call to schedule, which would have also a call to schedule, etc).

In summary, the attacker will deploy the following ClimberAttack contract and then call its attack function :

This test script is now passing and we solved the challenge.

Note : to fix this vulnerability, we should fix the logic error spotted on line 56 and move the require statement from line 108 to the top of body of the execute function, for example to line 101. This is similar to the Check-Effects-Interaction pattern recommended to avoid reentrancy attacks.

13. Safe Miners

“Safe Miners” — Stable Diffusion

Watchout! This challenge is being heavily reworked for the upcoming v3 version of Damn Vulnerable DeFi. You’d better wait for the new version before solving it.

Somebody has sent +2 million DVT tokens to 0x79658d35aB5c38B6b988C23D02e0410A380B8D5c. But the address is empty, isn't it?

To pass this challenge, you have to take all tokens out.

You may need to use prior knowledge, safely.

This challenge was less obvious than the previous ones because there is no smart contract available this time. No real information is provided besides the short description above, even when looking at the test script. Anyways, according to the warning, it will be “heavily reworked” for the upcoming v3 of the CTF, we will not spend too much time on its solution as we will probably need to revisit it in the future (the CTF version at the time of writing is still v2.2.0).

We can try to get some hints from the title “Safe Miners” and the last sentence “You may need to use prior knowledge, safely”. The first idea that we had was to revisit the Backdoor challenge in which we deployed several Gnosis Safe wallets. Maybe we could somehow succeed to deploy a Gnosis Safe Wallet which would have the address of the challenge and then use it to withdraw the DVT tokens? Unfortunately, mining such wallet addresses, despite being easy to compute, is not an unequivocal process because we notice on line 52 of the GnosisSafeProxyFactory contract that during the wallet creation, the CREATE2 opcode is used, which, contrarily to the more classical CREATE opcode, requires a salt argument to be provided — this parameter depends on a saltNonce argument that we randomly chose to be equal to 42 in the Backdoor challenge. We first tried to reproduce a setup similar to the Backdoor challenge by deploying several Gnosis Safe Proxy wallets from some GnosisSafeProxyFactory contract deployed by the attacker with simple saltNonce values (from 1 to 1000), but failed to retrieve the deposited address.

Note that hardhat is using a default set of signers, so the attacker (and deployer, etc) account(s) is(are) known and identical for anyone using the test script provided in all the challenges. This also implies that the smart contracts directly deployed by those default accounts have known and identical addresses, as they depend only on the deploying EOA’s addresses and their nonces (also initially null by default) — address computation is actually identical to the CREATE opcode in this case. Thus, it makes some sense to try to mine specific contract addresses deployed either directly by the attacker or from another contract which was itself deployed by the attacker (like the initial GnosisSafeProxy contract).

In retrospect, this is not surprising because the salt given to CREATE2 in GnosisSafeProxy not only depends on saltNonce but also on the hash of the initializer bytes variable — see line 48, which itself depends on all the arguments which we want to pass to the setup function of GnosisSafe. There is still too many degrees of freedom, for example because of the arguments related to the optional delegatecall (the to address and the abi-encoded data), or the 3 last optional payment related arguments. Of course, we tried to ignore all those optional arguments by giving them default null values. But as this failed, there was no hope that our approach would lead us to some “canonical way” to mine the desired address.

If only there was a way to replace the too flexible CREATE2 opcode of the GnosisSafeProxyFactory contract with the more unequivocal CREATE opcode… But we realized that if this was the case, then the addresses of the contracts created by the factory would be totally deterministic, in the sense that they would not depend on any salt and neither on the creation code of the contracts. So if we were able to mine the deposit address through some smart contract deployment, the created contract could be any contract able to transfer the DVT tokens to the attacker to solve the challenge. As explained above, contracts deployed via CREATE have addresses that depend only on the address of the creating contract and its nonce, similarly to when an EOA sends a transaction to the null address in order to create a new contract. Two more things to remember : the nonce of an EOA is incremented every time it sends a new transaction, while the nonce of a smart contract account is incremented every time it creates a new smart contract.

This is why we tried to deploy the following WithdrawTokenContractFactory contract up to 100 times with the attacker’s account. Each of those 100 factory once deployed would directly create 100 WithdrawTokenContract contracts and if by chance one of the deployed WithdrawTokenContracts would happen to own the deposit address, it would transfer the 2M+ tokens that it holds to the attacker.

And here is the successful exploit script :

We notice that it was actually sufficient for the attacker to create only 2 factories, and that the good contract was deployed when the value of the nonce of the second factory was 67 (so when deploying the 66th WithdrawTokenContract).

Finally, we were able to solve all the challenges of the Damn Vulnerable DeFi CTF (v2.2.0). We are looking forward to solve the new challenges of the upcoming version.

If you are looking to hire a solidity developer who is passionate about Web3 security, do not hesitate to contact us via twitter.

--

--