HYDN recently participated in the second ethernautDAO CTF challenge and won it by hacking the smart contract…let’s take a deep dive into how we did it.
The challenge consisted of 3 contracts:
CarToken: An ERC-20 token that allows each unique address to mint 1 CarToken.
CarMarket: This offers users the ability to purchase a car (a locally stored structure data) for a fixed cost of 100k CarToken. Funds are transferred from the creator to the market contract. If the caller holds more than 100k CarTokens he can mint more than one car for the fixed cost price. The fallback function intends to delegate calls into the Factory (we will come back to this part later).
Initial state: the contract holds 100k CarToken.
CarFactory: This offers CarToken flash loans. It is required that the receiver of the flash loan must have purchased a car from the CarMarket. No fees apply to the flash loan and the user must return the exact amount borrowed.
Initial state: the contract holds 100k CarToken.
Now let’s take a look at the schema, because a schema is worth a thousand words…
Before going into the exploit, let’s first take a look at the CarMarket contract fallback:
This fallback intends to approve the CarToken transfer to the CarFactory contract for the amount of the actual CarMarket balance, and then delegate call into CarFactory. This allows any CarFactory method to call into the CarMarket context and storage.
We can imagine that it intends to externalize the flash loan logic from the CarMarket to gain security, separate logic and offer modularity, and upgradability. This flash loan logic can be upgraded without changing the CarMarket contract by updating the CarFactory address reference, as offered by the function ‘setCarFactory’, see below.
From a ‘real world perspective’, the legitimacy of the CarMarket fallback is not totally clear, but as you have probably guessed by now: this is the entry point of our attack vector.
Let’s first dive into the CarFactory. One function offers a flash loan:
The regular flash loan logic without fees here looks correct.
But when looking closely, something of interest is the CarFactory variable (used in line 43 and line 55).
Let’s check this initialization:
The problem is that CarFactory is never set (the default will be 0). This means that now, the purpose of the CarMarket becomes clearer. This CarFactory flashloan() method cannot be called directly due to the missing state initialization. It intends to only be called from the CarMarket delegate call, state storage match, and no clashing. Here we are.
Now that we have a clear vision of the smart contracts and their state, let’s move on to the exploit.
The target is to be able to purchase two cars.
The CarToken contract offers, for each unique address, the necessary CARCOST amount to buy one car. If we try to call it a second time, the _carCost method will require the caller to hold at least 100k CarToken. This is the exact balance of the CarFactory (which offers a flash loan) and also the CarMarket contracts. Looks good…
The flashloan CarFactory function intends to transfer CarTokens from itself to the flash loan receiver and then ensures it has been sent back. As we explained above, it’s impossible to directly call the CarFactory flash loan, but it can be called from the CarMarket fallback that delegate calls into it.
Now we’re getting to the core of the exploit: the flash loan function line 43 checks the initial balance of the CarFactory, sends it from itself, calls the flash loan receiver and then checks the balance of CarFactory again to make sure funds have been sent back.
The problem is that this logic, combined with the delegate call, will create a mixed hidden state. The balance check before/after the flash loan explicitly ensures the balance of the CarToken (where the transfer of funds is relative to the context).
This means that in case of the delegate call, the funds will be sent from the CarMarket, and then CarFactory will be checked. In our scenario, CarFactory will not change (before/after check condition will be satisfied) but the CarMarket balance will be drained to the supposed flash loan receiver.
At this stage, the sender will hold the necessary 100k CarToken to call the CarMarket purchaseCar method again.
Above we have detailed the solution to the second version of this challenge. As explained here, the first version didn’t end as the ethernautDAO team had intended. The first version was more complex to solve, but, again, HYDN figured out a solution. Let’s take a look…
The contracts and storage state are very similar to v2. Let’s take a look at the UML diagram:
As you can see, it’s very similar to v2, the only difference is in the CarFactory flash loan method signature. Here there is an added customer parameter that defines a flash loan receiver and borrower. This doesn’t have any consequence and we will not make use of it.
The key difference between the two versions is the context — the contract states are:
Thus, the difference here is that the 100k tokens which were in CarFactory are now held by CarToken itself.
This affects the exploit because the CarFactory flash loan balance check ensures that the amount is lower than the CarFactory balance (which is now 0), so the previous exploit will no longer work.
During the analysis of the first version, we felt that something wasn’t well defined because after finding the exploit, we could not figure out why the CarToken held its own token. After the second version was released, we concluded that this was a setup mistake by ethernautDAO.
The attack vector is similar, but the pre-request is more complicated.
We first need to top up the CarFactory in order to call the CarMarket fallback (that will delegate call into CarFactory and flash loan). The CarToken offers a mint function that can only be called once per address. This then allows us to write a ‘Minter’ smart contract that will be a batch of them.
After the CarFactory top up, we can then drain the marketplace funds. Because of the gas limitation, all of them cannot be drained in one transaction, and instead, it needs to be split into a sub-batch (We’ve run 20 loop peer transactions).
For example, let’s say CarFactory holds 100k tokens, each transaction will drain 20 * 10 = 2000 tokens. Thus, in order to drain all 100k from the CarMarketplace, the operation needs to be repeated 50 times.
First, the minter contract:
Now the exploit contract:
As described above, the process is:
Sadly during the real challenge, we only managed to collect 99991 tokens…yes we were 9 short of being able to mint the second car.
The first reason is that we locked (and forever lost) too many tokens into the CarFactory, and the CarToken has a hard cap of 210k tokens: 100k to CarMarketplace, 100k to itself, and 100 remain to the participant. Too many people participated in the challenge, which prevented anyone from fully accomplishing it.
To try for yourself you can find the full repo containing the exploits on the HYDN GitHub: https://github.com/hydnsec/ethernautDAO-CTF
I hope you enjoyed reading this write-up as much as we enjoyed participating in the ethernautDAO challenge.
To keep in touch with ethernautDAO, follow them on Twitter: https://twitter.com/EthernautDAO
For more information about HYDN, or to request a Smart Contract Audit, you can contact us in the following places: