Welcome back to the series, this is the second case study of the series where I explore and analyze some of the most spectacular bugs and exploits of the blockchain space. If you missed the first part, you can hop on this link and read the introductory part. With that out of the way, let's jump straight on today's subject.

What was the DAO?

The DAO, in full "decentralized autonomous organization," was one of the earliest entities built on Ethereum, it attracted most of the earliest possible forms of attention a project could possibly attract in the early days of Ethereum.

In only 3 weeks of its historical crowdfunding, the project was able to raise more than $150 million from more than 11,000 investors.

The DAO was supposed to be a fully decentralized, collectively owned organization by members where rules and proposals would be voted by all investors via an innovative on-chain governance model.

What really happened?

On the 18th of June 2016, a number of people started spotting that a significant number of Ether was being drained out of the contract. It was accounted that more than 3.5 million Ether got transferred that day (~$45 million ). The Ether price tanked from $20 to $13 in a matter of few hours.

The DAO hacker was able to exploit a non-trivial series of vulnerabilities, the most relevant of them was called reentrancy exploit.

In computing, a computer program or subroutine is called reentrant if multiple invocations can safely run concurrently on a single processor system, where a reentrant procedure can be interrupted in the middle of its execution and then safely be called again ("re-entered") before its previous invocations complete execution. Wikipedia

Here's a simplified form of a re-entrancy exploitable code that we will explain below how it can be exploited and how we can attempt to fix and prevent malicious attacks.

// INSECURE
mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    (bool success, ) = msg.sender.call.value(amountToWithdraw)(""); // At this point, the caller's code is executed, and can call withdrawBalance again
    require(success);
    userBalances[msg.sender] = 0;
}

Reentrancy Exploitable Function ( @Consensys Docs )

Let's analyze what's happening in the above Solidity code snippet :

  • We have a function called withdrawBalance() , supposedly expected to be called by an address willing to withdraw some funds accounted in the userBalances mapping declared above.
  • amountToWithdraw is supposed to be the caller (aka msg.sender 's current balance in the contract). Up to this point, everything seems to be fine.
  • On subsequence lines, a call is made to perform the withdrawal, and later on, the caller's balance is updated inside the userBalances which keeps track of current balances.

Have you spotted what might go wrong? Well, there's something particularly bad happening in the code snippet above: The userBalances mapping state update happening after the actual withdraw makes the function prone to reentrancy.

A malicious caller can repeatedly call the second line in an endless loop ( in a reentrant manner ), since the actual state update is happening at the end of the function the balance will keep showing the previous balance state even after the withdrawal has been done countless times. What will be happening is the malicious caller will actually be draining the funds from the contract.

How could have this bug be prevented?

The reentrancy exploit could have been avoiding by doing userBalances 's state update just before calling the external contract withdrawal function. That way even if the caller is trying to attack with a reentrant call the withdrawal function will see a 0 balance since the state update happened before.

How would a more secure code snippet look like?

mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    userBalances[msg.sender] = 0;
    (bool success, ) = msg.sender.call.value(amountToWithdraw)(""); // The user's balance is already 0, so future invocations won't withdraw anything
    require(success);
}

Reentrancy Exploitable Function ( @Consensys Docs )

We can see now that the state update is done before actually calling an external function. One thing to note is if there's another function in the contract that calls withdrawBalance and does another external contract call at the same time, it has to also be equipped with the same reentrancy checks as our current withdrawBalance.

What did we learn from this exploit?

The major costly lesson we learned here is to always be aware of reentrancy-related exploits when our contract is calling an external contract function. The state update should be done before and not after external contract calls. Doing that way both unexpected and happy flows are handled gracefully.

Always be aware of reentrancy-related exploits when our contract is calling an external contract function. 💡

I hope y'all learned something from this brief analysis of the DAO hack exploit, please stay tuned for more exploit analysis.

References