Kyrian Alex's Newsletter

Share this post

AkuDreams Exploit

kyrianalex.substack.com

AkuDreams Exploit

How $45m got locked in an Ethereum Smart Contract forever

Kyrian.sol 💧
Apr 28, 2022
3
Share this post

AkuDreams Exploit

kyrianalex.substack.com

Image

45 Million USD gone. Just like that. Locked in the contract forever. How?

On the 22nd of April 2022, AkuDreams launched a Dutch auction with a unique feature. This unique feature was one, that allowed the lowest bid to set the price for all minters. This meant that after the auction, any bid higher than the lowest bid will receive a refund that matches the net value of the lowest bid.

Example: Auction Starts at 5.0 ETH

Minter 1: Bids at 5.0 ETH

Minter 2: Bids at 3.0 ETH

The auction ends with the lowest bidder at 3.0 ETH

Minter 1: Receives 2.0 ETH in a refund to match the net value of 2.0 ETH

Let's take a look at the contract! https://etherscan.io/address/0xf42c318dbfbaab0eee040279c6a2588fa01a961d#code

Auction Initiation:

  1. _bid function is called as users place bids.

  2. bidIndex tracks bid index and associated users.

  3. DANGER: bidIndex increments by 1 resulting in total 3669 bidIndex.

  4. Instead of bidIndex++, it should be bidIndex+=amount (parameter amount variable.)

Image

Post-Auction Refund Initiation:

  1. processRefunds function is called to refund bidders.

  2. Since bidIndex is capped at 3669 due to the calculation error, refundProgress will also cap at 3669 due to this require.

Image

Failure to Withdraw:

  1. claimProjectFunds function is called to withdraw total mint funds.

  2. DANGER: require(refundProgress >= totalBids) will always be false since refundProgress will always be 3669 and totalBids is 5495.

  3. Unable to withdraw

    Image

Another exploit included malicious contracts being able to break their processRefunds() function. The perpetrator can set a fallback function to fail on .call() to break out of the loop causing the downstream counters to fail all validations and prevent withdrawal. Everything on the blockchain is atomic, meaning that either a whole transaction runs, or none of it does. The processRefunds() function to return the bids was supposed to iterate through the bids and return the funds to each one. The contract was poorly coded in many dimensions, but the key griefing vector comes in the processRefunds() function.

Image

So, people took bids. It stored their data. Then, they were eligible for refunds. By calling processRefunds(), a loop is made according to a refundProgress counter which then does the refund.

Image

This was the cause of the more-so-well-known exploit of a griever contract that can call the bid function (because they did not disable contract calling) which is a fallback that fails. In short, someone could have bid and broken the processRefunds() by bidding from a contract. If one of the bidders is able to make their return fail, then the whole return function fails. That was exactly what the hacker did. They bid from a contract that was set up so that when it received a transfer of Ether, would run an infinite loop that caused the function returning the funds to run out of gas. (Proof of concept below.) If the bidder is an EOA, this code works fine. But the bidder can also be a smart contract, one that reverts when it receives eth. An example had been posted on GitHub as proof of concept.

This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Show hidden characters
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;
contract RefundExploit {
bool blocked;
function bid() external payable {
require(msg.sender == 0x0000000000000000000000000000000000000001);
IAku aku = IAku(0xF42c318dbfBaab0EEE040279C6a2588Fa01a961d);
aku.bid{value: msg.value}(1);
blocked = true;
}
receive() external payable {
if (blocked) {
while (true) {}
} else {
(bool success, ) = 0x0000000000000000000000000000000000000001.call{
value: msg.value
}("");
require(success);
}
}
function setBlocked(bool _blocked) external {
require(msg.sender == 0x0000000000000000000000000000000000000001);
blocked = _blocked;
}
}
interface IAku {
function bid(uint8) external payable;
}
view raw exploit.sol hosted with ❤ by GitHub

Also, another person sent some on-chain messages to prove that this was the case and invest more in contract auditing.

Image

https://etherscan.io/tx/0x3ded3a94e1bfa97af8ca3ab72af6ba0e2ea37a2b6f9b013bb701667181f6c2f2

Since the auction contract processes refunds in a linear fashion, it can be permanently "stuck" once it reaches the malicious bidder. So, if someone Bid with a malicious contract that fails on fallback when receiving ETH. This makes the function fail and stops the "chain" of refunds from continuing.

An unknown individual managed to set up and execute a griefing exploit during the mint. The individual bid 2.5 Eth about 90 minutes into the auction. And as of now, $45mil USD is locked up in the AkuDreams contract. This money is for both the teams and the minters who are supposed to be refunded.

The exploiting wallet sent over their message in the data of this txn. Decode the data to UTF-8 to read it.

Image

Everyone who bid before this was safe (except for those who came right before), but everyone after would be forever locked out of their funds.

Image

Funds were getting stuck and refunds were failing.

Image

Hope?

An anonymous contract deployer had unstuck the contract and processRefunds() began to work again. He even sent a little message to let people know it was a demonstration. https://etherscan.io/tx/0x2f667bb69cb0098472962a4b3ccb48a56b57ee69582600bb8d51d29547c1d496

Image

Crisis averted right???

Now, this next part is truly painful.

Process Refunds started working again and people were getting their ETH back. However, there was a second exploit in the code. Refunds work. Emergency withdrawals work. However, the team will never be able to withdraw their ETH. Ever.

Image

This is surely devastating news for any developer and founder out there.

A require of refundProgress >= totalBids was made. The assumption was that all refunds had to be processed first before withdrawing. The processRefunds() function is open to all to call (team hoping that community members will eat the gas cost?). All calls must be submitted with >5 million gas, otherwise, it will fail with an out-of-gas error.

Image

Misunderstanding this requirement has been the cause of several community members losing gas funds attempting to do a public good so far. However, if you call with a sufficient gasLimit, you should be able to process refunds for the first-hour minters.

Image

But once you hit the time period where the griefer entered a bid, things will begin to fail no matter what gasLimit you set. Examining the bytecode of the griefer contract shows that there is a toggle that can be turned on and off. This means the bricking is not permanent, the attacker can undo his griefing at any time.

Image

If you decompile the bytecode, they have a trigger on a fallback to either enable the stuck or disable it. require (uint8) is probably a trigger to enable or disable the failing of receiving ETH. Nice touch! That means it can be unstuck.

Image

As for a solidity readable, it was probably something similar to this →If block, fail the receivable fallback. Otherwise, let it succeed.

Image

Thus refunds AND withdrawals were stuck in the smart contract. No refunds were able to be made, and in addition to that, the claimProjectFunds() logic of the owner required that all refunds were made first before they can withdraw.

Image

The demonstration was written by @notchefbob who tried to notify the team of the issue.

Image

(He was a whitehat but the team dismissed him and called it fud. They claimed that malicious developers were calling their "FEATURE" an exploit...the team didn't understand their own code.)

It makes sense in their logic, and the crisis was averted. However...

Image

Let’s take a look at the _bid() function which takes in two arguments: uint8 amount and uint256 value

Image

Bids are stored in an index and that index is linked to the user. This is to store their bidding data for refunds. There are a total of 5495 items for auction thus index "should" increase accordingly, based on their withdraw logic.

Image

However, taking in an argument of uint8 amount but always incrementing the index by 1 (++) is the devil here. After the mint-out, the bidIndex only went up to 3669, this is because of multi-mints in a single TX.

Image

In processRefunds() code, there is a require statement that requires _refundProgress < _bidIndex this essentially means that _refundProgress can never be above 3669.

Image

Looking back to claimProjectFunds(), there is a require statement as well. require(refundProgress >= totalBids). This means as long as totalBids is higher than refundProgress, project cannot withdraw their funds.

Image

However, if you take a look at the value of totalBids... It is 5495. 3669 will never be higher than 5495, which means this function is stuck. Forever.

Image

$45 million permanently locked, or is it?

The attacker has demonstrated willingness to release the funds, contingent on the team taking responsibility for the exploit. https://etherscan.io/tx/0x2f667bb69cb0098472962a4b3ccb48a56b57ee69582600bb8d51d29547c1d496

Image

It seems like these funds are locked in the contract until the exploiting address decides to free them, they should be retrievable...if the person desires them to be. The arrogant team deliberately ignored people warning them of a poorly designed contract, all funds could've been locked forever, but the exploiter acted in good faith so it seems all money might end up where it should.

How could this have been avoided?

  • ALLOW PULL REFUNDS RATHER THAN PUSH: It was nice of them to process refunds on their end to cover gas! But they could have had a backup function that allowed bidders to process their individual withdrawal on their own.

  • REFUNDS BY BID ID: They should have a way to process refunds for specific bid ids. ie include an argument in the function for: - start and end index - array of bid ids That way, they could have skipped over the bid causing problems.

  • EOAs ONLY: Finally, they could have simply not allowed contracts to bid. If they had something like require(msg.sender == tx.origin) in the original bid, then only EOAs could have bid, and an exploit like this wouldn’t have been possible.

Obviously, each of these options has trade-offs, but this is an obvious issue and the team should have addressed it. Devs and founders should learn to have all contracts professionally audited by a third party to avoid these devastating mistakes.

TL;DR dev Bid count tracker calculated incorrectly. This caused downstream validation to fail permanently; preventing withdrawal of mint funds.

Share this post

AkuDreams Exploit

kyrianalex.substack.com
Comments
TopNewCommunity

No posts

Ready for more?

© 2023 Kyrian.sol 💧
Privacy ∙ Terms ∙ Collection notice
Start WritingGet the app
Substack is the home for great writing