BitDAO's $350 million was nearly stolen, and white hat hackers recount a thrilling rescue operation
On August 17, samczsun, a research partner at the blockchain investment firm Paradigm and a well-known white hat hacker, disclosed in a post that the smart contract for the Dutch auction conducted by BitDAO on the SushiSwap IDO platform MISO had security vulnerabilities. Several white hat hackers worked together to rescue 109,000 ETH (approximately $350 million) from the crowdfunding pool.
Original Title: "Two Rights Might Make A Wrong" (Two Rights Might Make A Wrong)
Written by: samczsun
Translated by: Kyle, 8btc
Table of Contents:
- Encounter
- Discovery
- Disclosure
- Preparation
- Rescue
- Reflection
A common misconception that often arises when building software is that if every component in a system is individually verified to be secure, then the system itself is secure. This notion is more pronounced in DeFi projects. In DeFi project development, composability is second nature to developers.
Unfortunately, while grouping two secure components together may be safe in most cases, it also means that a single vulnerability can cause significant economic losses to hundreds or even thousands of innocent users.
Today, I want to tell you how I discovered and helped patch a serious vulnerability that put over 109,000 ETH (approximately $350 million at today's exchange rate) at risk of being stolen.
1. Encounter
9:42 AM
While casually browsing the LobsterDAO group chat on Telegram, I noticed a discussion on Twitter between @ivangbi_ and @bantg about a new token sale project on the SushiSwap MISO platform. I usually try to avoid making dramatic moves in public, but I couldn't help but quickly Google to see what was going on.
The results I got weren't particularly interesting to me, but I continued to search because I felt that if I kept looking, I would find something intriguing.
The MISO platform supports two types of token auction models: Dutch auctions and batch auctions. The token sale being discussed today was conducted via a Dutch auction. Naturally, the first thing I did was open the contract address for the project on Etherscan.
9:44 AM
I quickly skimmed through the contract for this Dutch auction via the project's participation agreement and checked each interesting function. The commit functions (commitEth, commitTokens, and commitTokensFrom) seemed to be implemented correctly. The auction management functions (setDocument, setList, etc.) also had appropriate access controls.
However, near the bottom, I noticed that the initMarket function lacked access control, which was very concerning. Additionally, the initAuction function it called also did not include access control checks.
Still, I really didn't expect to find such a vulnerability, as I didn't think the Sushi team would make such an obvious mistake. Sure enough, the initAccessControls function verified that the contract had not yet been initialized.
However, at that moment, I had another discovery. While scrolling through all the files, I noticed the SafeTransfer and BoringBatchable libraries. I was familiar with both and was immediately shocked by the potential danger of the BoringBatchable library.
To explain, BoringBatchable is a hybrid library designed to easily introduce batch calls into any contract that imports it. It achieves this by executing a delegate call to the current contract for each pair of call data provided in the input.
function batch(bytes[] calldata calls, bool revertOnFail) external payable returns (bool[] memory successes, bytes[] memory results) {
successes = new bool[](calls.length);
results = new bytes[](calls.length);
for (uint256 i = 0; i < calls.length; i++) {
(bool success, bytes memory result) = address(this).delegatecall(calls[i]);
require(success || !revertOnFail, _getRevertMsg(result));
successes[i] = success;
results[i] = result;
}
}
Looking at the function above, it seemed to be implemented correctly. However, something in the back of my mind was reminding me. At that moment, I realized I had seen something very similar in the past.
2. Discovery
9:47 AM
More than a year ago, during a Zoom call with the Opyn team, I was trying to figure out how to recover and protect user funds after suffering a devastating hack.
The hack itself was simple yet clever: it used a single ETH payment to exercise multiple options, as the Opyn contract used the msg.value variable in a loop.
While handling token payments involved a separate transferFrom call for each loop iteration, handling ETH payments only checked if msg.value was sufficient. This allowed the attacker to reuse the same ETH multiple times.
Back to today, I realized that what I was seeing was two identical vulnerabilities, just in different forms. In the delegate call, msg.sender and msg.value were persisted. This meant I should be able to batch call commitEth and reuse my msg.value in each commitment, allowing me to bid for free in the auction.
9:52 AM
My intuition told me this was a real transaction, but I couldn't confirm it without actual verification. I quickly opened Remix and wrote a proof of concept.
To my frustration, my mainnet fork environment had been completely damaged not long ago. I must have accidentally broken it during the London hard fork. With so much money at risk and not enough time, I quickly pieced together a makeshift mainnet fork on the command line and tested my vulnerability. The results were as I had thought.
10:13 AM
Before publicly reporting this vulnerability, I called my colleague Georgios Konstantopoulos to have him take another look. While waiting for a response, I returned to the contract to find a way to determine the severity. In this case, being able to bid for free in the auction was one thing, but being able to steal all other participants' bids was another.
I noticed there was some refund logic that I had overlooked during my initial scan. Now, this had become a way to withdraw ETH from the contract. I quickly checked what conditions I needed to meet to get the contract to provide me with a refund.
To my surprise (and fear), I found that any ETH sent over the auction hard cap would be refunded. This applied even when the hard cap was reached, meaning the contract would not completely reject the transaction but simply refund all your ETH.
Suddenly, the vulnerability I discovered became enormous. I was not dealing with a vulnerability that allowed you to bid over other participants. I was looking at a $350 million vulnerability.
3. Disclosure
10:38 AM
After confirming the vulnerability with Georgios, I had him and Dan Robinson try to contact Sushi CTO Joseph Delong. A few minutes later, Joseph responded, and then I had a Zoom call with Georgios, Joseph, Mudit, Keno, and Omakase. I quickly reported the vulnerability to the other participants, and they began coordinating a response. The entire call lasted only a few minutes.
4. Preparation
11:26 AM
In the rescue operations room, Mudit, Keno, Georgios, and I were busy writing a simple rescue contract. We decided that the cleanest approach was to initiate a flash loan, directly purchase up to the hard cap, end the auction, and then use the proceeds from the auction itself to repay the flash loan. This method required no upfront capital and worked very well.
1:36 PM
As we finished the rescue contract, we discussed the next steps for the batch auction. Mudit pointed out that a points list could be set up even while the auction was ongoing, and it would be called during each ETH commitment. We immediately realized this could be the pause functionality we were looking for.
We brainstormed different ways to use this method. Immediate rollback was an obvious solution, but we wanted a better solution.
I considered adding a check that each source could only make one commitment per block, but we noted that the function was marked as view, meaning the Solidity compiler would use static call opcodes. Our approach did not allow for any state modifications.
After some thought, I realized we could use the points list to verify whether the auction contract had enough ETH to match the commitments made. In other words, if someone tried to exploit this vulnerability, then the commitment would exceed the ETH. We could easily detect this and revert the transaction. Mudit and Keno began writing tests for verification.
5. Rescue
2:01 PM
The communication breakout team merged with the rescue breakout team to synchronize progress. They had already contacted the team executing the auction (BitDAO), but that team wanted to complete the auction manually. We discussed the risks and felt that the likelihood of some automated bot noticing this transaction or being able to act on it was low.
2:44 PM
The team executing the auction completed the auction, eliminating the direct threat. We congratulated each other on the success and then dispersed. This batch auction would quietly conclude later that day. Those unaware would likely not know what a serious disaster had just been averted.
6. Reflection
4:03 PM
The past few hours felt hazy, as if time had stood still. It took me just over half an hour from encountering the project to discovering the vulnerability, 20 minutes for disclosure, another 30 minutes in the war room, and three hours to fix the vulnerability. In total, it took just five hours to protect $350 million from falling into the hands of bad actors.
Even without financial loss, I believe everyone involved would have preferred not to have gone through this process in the first place. I have two main takeaways for you regarding this incident.
First, using msg.value in complex systems is difficult. It is a global variable that you cannot change and remains constant in delegate calls. If you use msg.value to check if a payment has been received, you absolutely cannot place that logic in a loop.
As the complexity of the codebase increases, it is easy to forget where things happen and inadvertently loop something in the wrong place. While wrapping and releasing ETH is cumbersome and introduces additional steps, if you want to avoid such issues, a unified interface between WETH and other ERC20 tokens might be worth considering.
Second, combining two secure components can yield something insecure. I have previously stated this in the context of composability and DeFi protocols, but this incident shows that even secure contract-level components can mix in ways that produce insecure contract-level behavior. There are no all-encompassing recommendations like "check-effects-interactions," so you just need to be aware of the additional interactions introduced by new components.
I want to thank the contributors at Sushi, Joseph, Mudit, Keno, and Omakase for their quick response to this issue, as well as my colleagues Georgios, Dan, and Jim for their help throughout the process, including reviewing this article.