Reproduced Exploit
Aku-Auction (Akutar NFT) Exploit — Push-Payment Refund DoS & Permanently Locked Funds
AkuAuction is a descending-price ("Dutch"-style) NFT dutch auction for the Akutar collection. Users bid ETH at the current getPrice() and are tracked in an allBids[] array of bids{bidder, price, bidsPlaced, finalProcess} structs. Losing bidders are supposed to be refunded the difference between the…
Loss
Bidder ETH permanently locked in the Aku/Akutar auction contract (AkuAuction.balance). No ETH was stolen — th…
Chain
Ethereum
Category
Logic / State
Date
Apr 2022
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2022-04-AkutarNFT_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/AkutarNFT_exp.sol.
Vulnerability classes: vuln/dos/frozen-funds · vuln/dos/griefing
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: AkuAuction.sol.
Key info#
| Loss | Bidder ETH permanently locked in the Aku/Akutar auction contract (AkuAuction.balance). No ETH was stolen — the funds became unrecoverable in-protocol. Contemporary reporting placed the stranded amount in the tens of thousands of ETH; the exact figure is not recoverable from this PoC/trace (the PoC only simulates two local bids: 3.5 ETH + 3.75 ETH), so it is not stated here as a verified number. |
| Vulnerable contract | AkuAuction — 0xF42c318dbfBaab0EEE040279C6a2588Fa01a961d |
| Victim pool / vault | The auction contract itself (held all bid ETH pending refunds + project-fund withdrawal) |
| Attacker EOA | n/a — the attack is permissionless; the PoC simulates it with the test contract as the "malicious bidder". (A real attacker would be any EOA deploying a reverting bidder contract.) |
| Attacker contract | AkutarNFTExploit (PoC: 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 per the trace) — a bidder whose fallback() always reverts. |
| Attack tx hash | (Live trigger of processRefunds that reverted, blocking everyone) — PoC cites the real refund-processing tx 0x62d280abc60f8b604175ab24896c989e6092e496ac01f2f5399b2a62e9feaacf inside the test source. |
| Chain / block / date | Ethereum mainnet / fork block 14,636,844 / April 2022 |
| Compiler / optimizer | Solidity v0.8.13 (v0.8.13+commit.abaa5c0e); optimizer enabled ("optimizer":"1"), 200 runs; not a proxy (proxy:"0"). From sources/AkuAuction_F42c31/_meta.json. PoC test compiled with Solc 0.8.34 under evm_version = 'cancun'. |
| Bug class | (1) Push-payment refund DoS — processRefunds() loops the bidder array and .call{value:…}("")-pushes ETH to each bidder with no failure isolation, so one reverting contract bidder reverts the whole batch and blocks every honest bidder's refund. (2) Unit-mismatched require in claimProjectFunds() — compares the bidder-index cursor refundProgress against the NFT-count accumulator totalBids, so the owner can never satisfy the gate and the residual ETH is locked forever. |
TL;DR#
AkuAuction is a descending-price ("Dutch"-style) NFT dutch auction for the Akutar collection. Users bid ETH at the current getPrice() and are tracked in an allBids[] array of bids{bidder, price, bidsPlaced, finalProcess} structs. Losing bidders are supposed to be refunded the difference between their bid price and the final clearing price.
-
Refunds are a push-over-loop with no failure isolation.
processRefunds()(AkuAuction.sol:581-612) walksallBids[refundProgress … bidIndex]and, for each bidder, computes their refund and callsbidData.bidder.call{value: refund}("")followed byrequire(sent, "Failed to refund bidder"). Because the loop body is atomic with the outer transaction, a single bidder whosefallback()/receive()reverts bricks the entireprocessRefunds()call — therequire(sent, …)reverts, undoing every refund pushed earlier in the same loop, andrefundProgressis never written forward. The honest bidders who would have been refunded get nothing. -
There is no "bidder is not a contract" check.
bid()/_bid()(:493-550) accepts ETH from any address, including contracts. So an attacker can deploy a trivial contract that bids and thenrevert()s on every incoming ETH transfer. -
A second, independent logic error locks the residual ETH forever.
claimProjectFunds()(:614-621) — the owner's only way to sweep the contract — gates onrequire(refundProgress >= totalBids, "Refunds not yet processed"). ButrefundProgressis a bidder-index cursor (it iterates1 … bidIndex), whiletotalBidsis the count of NFT units bid (sum(bidsPlaced), capped attotalForAuction = 5495). These are different units; once more than one NFT is sold to a single bidder (bidsPlaced > 1),bidIndex < totalBidsand therequirecan never be satisfied. The owner cannot withdraw, so the ETH is stranded.
The PoC (testDOSAttack + testclaimProjectFunds) demonstrates both halves: a malicious contract bidder
(3.5 ETH) and an honest bidder (3.75 ETH) both bid; after the auction, processRefunds() reverts with
"Failed to refund bidder" (the honest bidder's balance stays 0), and claimProjectFunds() reverts with
"Refunds not yet processed" — locking the funds in-protocol.
Background — what Aku-Auction does#
AkuAuction (source) is an Ownable descending-price auction
contract that sold 5,495 Akutar NFT slots (AkuAuction.sol:443-444).
- Dutch price.
getPrice()(:485-491) linearly decays the price every 6 minutes:price = startingPrice - discountRate * (elapsed/6min), floored at the auctionexpiresAt. - Bidding.
bid(amount)/_bid(amount, value)(:493-550) chargesprice * amount, refunds prior over-payment inline, caps each address atmaxBids = 3NFTs (:465), and records each distinct bidder once inallBids[](bidIndexincrements per new bidder,totalBidsincrements byamount). - Refunds. After
expiresAt, anyone may callprocessRefunds()(:581-612), which gas-limits-iterates bidders, refunding(bidPrice - finalPrice) * bidsPlaced(plus amintPassDiscount), and marks eachfinalProcess = 1. - Project sweep.
claimProjectFunds()(:614-621) is the owner's only exit for the residual ETH, gated on refunds and the NFT airdrop both being "complete". - Emergency self-refund.
emergencyWithdraw()(:569-579) lets a bidder pull their own bid back afterexpiresAt + 3 days— but only iffinalProcess == 0, which the buggyprocessRefundsloop never consistently reaches for late bidders.
Key on-chain / source parameters (at the fork block 14,636,844):
| Parameter | Value | Source |
|---|---|---|
totalForAuction | 5,495 NFTs | :444 |
maxBids (per address) | 3 | :465 |
maxNFTs | 15,000 | :443 |
DURATION | 126 minutes | :453 |
mintPassDiscount | 0.5 ether | :459 |
refundProgress (start) | 1 | :466 |
gasUsed < 5_000_000 loop cap | 5,000,000 gas per processRefunds() call | :591 |
| PoC fork block | 14,636,844 | output.txt:1571 |
| PoC warp (refunds) | 1_650_674_809 (after expiresAt) | AkutarNFT_exp.sol:41 |
| PoC warp (claim) | 1_650_672_435 | AkutarNFT_exp.sol:56 |
The vulnerable code#
1. The push-payment refund loop (DoS vector)#
function processRefunds() external {
require(block.timestamp > expiresAt, "Auction still in progress");
uint256 _refundProgress = refundProgress;
uint256 _bidIndex = bidIndex;
require(_refundProgress < _bidIndex, "Refunds already processed");
uint256 gasUsed;
uint256 gasLeft = gasleft();
uint256 price = getPrice();
for (uint256 i=_refundProgress; gasUsed < 5000000 && i < _bidIndex; i++) {
bids memory bidData = allBids[i];
if (bidData.finalProcess == 0) {
uint256 refund = (bidData.price - price) * bidData.bidsPlaced;
uint256 passes = mintPassOwner[bidData.bidder];
if (passes > 0) {
refund += mintPassDiscount * (bidData.bidsPlaced < passes ? bidData.bidsPlaced : passes);
}
allBids[i].finalProcess = 1;
if (refund > 0) {
(bool sent, ) = bidData.bidder.call{value: refund}("");
require(sent, "Failed to refund bidder"); // ⚠️ reverts the WHOLE tx
}
}
gasUsed += gasLeft - gasleft();
gasLeft = gasleft();
_refundProgress++;
}
refundProgress = _refundProgress; // only written on success
}
The loop .call{value: refund}("")-pushes ETH to each bidder with no try/catch and no per-bidder pull
fallback. require(sent, …) inside the loop reverts the entire outer transaction: every earlier successful
transfer in the same call is rolled back, refundProgress is not persisted, and the malicious bidder stays at
its slot forever — every future retry hits the same reverting address and reverts again.
2. No contract-bidder check on bid() / _bid()#
function bid(uint8 amount) external payable {
_bid(amount, msg.value);
}
function _bid(uint8 amount, uint256 value) internal {
require(block.timestamp > startAt, "Auction not started yet");
require(block.timestamp < expiresAt, "Auction expired");
uint80 price = getPrice();
uint256 totalPrice = price * amount;
if (value < totalPrice) {
revert("Bid not high enough");
}
// ... no Address.isContract(msg.sender) check anywhere ...
A contract can bid just like an EOA. (Note: even adding require(!isContract(msg.sender)) would be a weak
defense — see Remediation; the real fix is pull-payments.)
3. The unit-mismatched require in claimProjectFunds() (permanent lock)#
function claimProjectFunds() external onlyOwner {
require(block.timestamp > expiresAt, "Auction still in progress");
require(refundProgress >= totalBids, "Refunds not yet processed"); // ⚠️ wrong unit
require(akuNFTs.airdropProgress() >= totalBids, "Airdrop not complete");
(bool sent, ) = project.call{value: address(this).balance}("");
require(sent, "Failed to withdraw");
}
refundProgress is a bidder-array index (it walks 1 … bidIndex); totalBids is the count of NFT
units sold (sum(bidsPlaced), capped at 5495). They are not the same quantity. Whenever any bidder takes
more than one NFT (bidsPlaced > 1), bidIndex < totalBids, so refundProgress (max bidIndex) can never
reach totalBids. The owner's only sweep path is dead, and — combined with bug #1 stalling processRefunds
entirely — the residual ETH is permanently locked.
4. The attacker's reverting bidder (PoC)#
contract AkutarNFTExploit is Test {
// ...
fallback() external {
revert("CAUSE REVERT !!!"); // ⚠️ bricks processRefunds() when reached
}
}
Root cause — why it was possible#
Two independent design defects, each sufficient to strand funds:
-
Push-over-loop without failure isolation. The contract chose to push refunds to bidders inside a
forloop instead of letting each bidder pull their own refund. In EVM, a single reverting recipient inside an atomic loop reverts the entire transaction. Combined with no contract-bidder check atbid()time, any attacker can pre-plant a reverting bidder anywhere in theallBids[]order and hold the whole refund process hostage. ThegasUsed < 5000000gas-limit resumption mechanism (:591) does not help: it only resumes forward fromrefundProgress; it cannot skip a reverting bidder, so the cursor is stuck forever at the bad index. -
Comparing an index to a count in
claimProjectFunds().refundProgressandtotalBidsmeasure different things (bidder-slot vs NFT-units). The>=is essentially never satisfiable for any real auction where bidders buy >1 NFT. This makes the owner's rescue path permanently unreachable, so even ifprocessRefundscould complete, the leftover ETH could not be extracted.
The two bugs compound: bug #1 prevents refundProgress from advancing, and bug #2 ensures that even a
"complete" refund state would not unlock the sweep. The result — on the real Akutar auction — was that
bidder ETH sat in AkuAuction indefinitely and had to be recovered out-of-band (socially / via a new
contract).
Preconditions#
- The auction is live (between
startAtandexpiresAt), so the attacker can place a bid. The PoC pranks the malicious bidder and callsakutarNft.bid{value: 3.5 ether}(1)(AkutarNFT_exp.sol:30-31). - The attacker's bidder is a contract whose
fallback()/receive()reverts on incoming ETH (AkutarNFT_exp.sol:65-67). - The attacker's bidder sits at an
allBids[]index before (or interleaved with) honest bidders, so the loop reaches it while honest refunds are still pending in the same atomic call. - For bug #2 to manifest: at least one bidder must have
bidsPlaced > 1, sototalBids > bidIndex. (In the real auction, thousands of bidders took 2–3 NFTs each, so this was always true.)
Attack walkthrough (with on-chain numbers from the trace)#
The fork is mainnet at block 14,636,844 (output.txt:1571). The "malicious user" is the test
contract itself (AkutarNFTExploit, 0x7FA9…1496), and the honest user is
0xca2eB45533a6D5E2657382B0d6Ec01E33a425BF4. The honest user starts with 4 ETH.
| # | Step | Effect | Trace ref |
|---|---|---|---|
| 0 | Initial state — fork block 14,636,844; refundProgress = 1, auction already populated with real mainnet bidders in allBids[1 … bidIndex-1]. Honest user balance: 4 ETH. | baseline | output.txt:1571, output.txt:1564 |
| 1 | Malicious bid — AkutarNFTExploit (contract bidder) calls bid{value: 3.5 ether}(1); 1 NFT at the current dutch price. Appended to allBids at some index i_attacker. Bid value: 3,500,000,000,000,000,000 wei (3.5 ETH). | attacker planted in the bidder array | output.txt:1578 (AkuAuction::bid{value: 3500000000000000000}(1)) |
| 2 | Honest bid — honest user calls bid{value: 3.75 ether}(1). Bid value: 3,750,000,000,000,000,000 wei (3.75 ETH). Contract refunds the small overpayment inline: 0.25 ETH pushed back to the honest user (fallback{value: 250000000000000000}). | honest bidder appended after attacker | output.txt:1590, output.txt:1591 (fallback{value: 250000000000000000}) |
| 3 | Honest balance logged after bid — honest user balance: 0 ETH (the 0.25 ETH inline refund left them with 0 integer ETH shown by the / 1 ether log). | confirms 3.75 ETH is now in AkuAuction | output.txt:1565, output.txt:1598 |
| 4 | Time warp — vm.warp(1_650_674_809) so block.timestamp > expiresAt, enabling processRefunds(). | auction "over" | output.txt:1600 (VM::warp(1650674809)) |
| 5 | processRefunds() begins — loops from refundProgress forward, pushing refunds to each prior mainnet bidder. The trace shows a sequence of fallback{value: …} calls to real bidder addresses with refunds of 2.1 / 4.2 / 6.3 ETH each (e.g. 6300000000000000000, 4200000000000000000, 2100000000000000000). | refunds flow until the attacker is reached | output.txt:1604 (AkuAuction::processRefunds()), output.txt:1605-1642 (the refund pushes) |
| 6 | Honest user refunded (transiently) — the loop reaches the honest user and pushes 4,200,000,000,000,000,000 wei (4.2 ETH) to 0xca2eB…5BF4. At this instant inside the call, the honest user has their refund. | honest refund delivered — but not yet committed | output.txt:1639 (0xca2eB45533…::fallback{value: 4200000000000000000}) |
| 7 | Attacker reached → revert — the loop next reaches AkutarNFTExploit::fallback{value: 2,100,000,000,000,000,000 wei (2.1 ETH)} and the attacker's fallback() reverts ([Revert] EvmError: Revert). The require(sent, "Failed to refund bidder") then fires and reverts the whole processRefunds() tx. | all refunds rolled back; refundProgress not persisted | output.txt:1643-1644 (AkutarNFTExploit::fallback{value: 2100000000000000000} → [Revert] EvmError: Revert), output.txt:1670 (← [Revert] Failed to refund bidder) |
| 8 | Caught by the PoC's try/catch — the test logs "processRefunds() REVERT : Failed to refund bidder". | confirms DoS | output.txt:1566, output.txt:1671 |
| 9 | Honest balance post-processRefunds — honest user balance: 0 ETH (the 4.2 ETH refund from step 6 was rolled back). The honest user permanently lost access to their 3.75 ETH refund. | DoS confirmed on-chain | output.txt:1567, output.txt:1673 |
| 10 | claimProjectFunds() (second test) — owner warps to 1_650_672_435 and calls claimProjectFunds(); it reverts with "Refunds not yet processed" because refundProgress (an index) can never reach totalBids (an NFT count). | owner sweep permanently blocked → ETH locked | output.txt:1692-1693 (AkuAuction::claimProjectFunds() → [Revert] Refunds not yet processed), output.txt:1679 |
Profit / loss accounting (ETH)#
This is a DoS / lock exploit, not a value extraction — the attacker gains nothing; the victims lose access. Accounting from the PoC:
| Item | Amount (ETH) | Notes |
|---|---|---|
| Honest user bid (locked) | 3.75 | paid into AkuAuction at step 2 (output.txt:1590) |
| Honest user inline refund at bid time | +0.25 | returned immediately (output.txt:1591) |
Honest user transient refund in processRefunds | +4.2 (then rolled back) | pushed at step 6 (output.txt:1639), reverted at step 7 (output.txt:1670) |
| Honest user net change | −3.75 ETH (refund unreachable) | balance 4 → 0 (output.txt:1564, output.txt:1567) |
| Attacker net change | 0 | the attacker's 3.5 ETH bid is also locked, but the attacker's goal (griefing) is achieved |
AkuAuction residual ETH | all bids locked | claimProjectFunds() unreachable; in the real incident this was the entire residual bidder ETH, which contemporary reporting placed in the tens of thousands of ETH (exact figure not verifiable from this PoC/trace) |
The PoC does not assert a numeric profit (there is none); it asserts the DoS outcome: honest balance before
= 4 ETH, after bid = 0 ETH, post-processRefunds = 0 ETH, and claimProjectFunds() reverts.
Diagrams#
Sequence of the DoS#
State evolution of refundProgress vs the bidder array#
The flaw inside processRefunds()#
Why the owner's sweep is unreachable: index vs count#
Why each magic number#
3.5 ether(malicious bid): a single-NFT bid at the fork-block dutch price, just enough to register the reverting contract as a bidder inallBids[]. The exact amount is not load-bearing — any bid that gets the contract into the array works.3.75 ether(honest bid): a 1-NFT bid from the honest user, slightly above the malicious bid so it lands at a higherallBids[]index than the attacker — guaranteeing the honest refund is still pending when the loop reaches the attacker and reverts.4 ETH(honest starting balance): seeded by the fork state; large enough to place the 3.75 ETH bid (the contract refunds the 0.25 ETH overpayment inline at bid time).1_650_674_809(warp forprocessRefunds): a real-world UNIX timestamp after the auction'sexpiresAt, taken from the live incident timeline (the PoC links the actual refund-processing tx0x62d28…eacf). It satisfiesblock.timestamp > expiresAtsoprocessRefunds()is callable.1_650_672_435(warp forclaimProjectFunds): a timestamp afterexpiresAtused in the second test to demonstrate the owner-sweep revert; the exact value only needs to clear the expiry gate.5,000,000gas (loop cap): the per-call gas budget inprocessRefunds(); it limits how many bidders one call can process but does not allow skipping a reverting bidder — so it cannot rescue the DoS.refundProgress = 1(initial): the loop starts at index 1 (index 0 is unused), so the very first bidder is processed first; the attacker's position in the array determines how many honest refunds succeed before the revert.
Remediation#
- Use pull-payments (checks-effects-interactions). Replace the push loop with a per-bidder
withdrawRefund()that lets each bidder pull their own refund. A reverting contract then only blocks itself, never the batch. This single change eliminates bug #1 entirely. OpenZeppelin'sReentrancyGuard+ a pull pattern is the canonical fix. - Never
.call{value}("")to arbitrary addresses inside a loop withrequire(sent). If push is unavoidable, isolate failures: usetry/catch, or track per-bidderowedbalances and let failed pushes be retried individually, so one bad recipient cannot revert the whole batch. - Fix the
claimProjectFunds()require. Compare like units: gate onrefundProgress >= bidIndex(or track a dedicatedrefundsCompletedbool) rather than againsttotalBids(an NFT count). Better: make the owner sweep callable afterexpiresAt + graceregardless, with accounting that any unclaimed refunds remain withdrawable by their bidders viaemergencyWithdraw(). - Make
emergencyWithdraw()robust. It currently trustsfinalProcess == 0, which the buggyprocessRefundsmay leave stuck. A bidder should always be able to pull their own refund based onallBids[personalBids[msg.sender]]alone, independent of the batch loop's progress. - Optionally reject contract bidders at
bid()time (require(msg.sender.code.length == 0)), but treat this as defense-in-depth only — it is insufficient on its own (it can be bypassed from a constructor, it breaks smart-wallet composability, and it does nothing for bug #2). Pull-payments remain the real fix.
How to reproduce#
The PoC runs offline via the shared harness, replaying mainnet state from the local
anvil_state.json (the test's createSelectFork("http://127.0.0.1:8545", 14_636_844) is served by a local
anvil instance aliasing "mainnet"; no public RPC is required):
_shared/run_poc.sh 2022-04-AkutarNFT_exp -vvvvv
Both tests run together (no --mt filter needed, since the directory has exactly the two exploit tests):
testDOSAttack()— plants a reverting contract bidder, then showsprocessRefunds()revert and the honest user's refund rolled back.testclaimProjectFunds()— warps past expiry and shows the owner sweep reverting with"Refunds not yet processed".
Expected tail (from output.txt:1561-1698):
Ran 2 tests for test/AkutarNFT_exp.sol:AkutarNFTExploit
[PASS] testDOSAttack() (gas: 501482)
Logs:
honestUser Balance before Bid: 4
honestUser Balance after Bid: 0
processRefunds() REVERT : Failed to refund bidder
honestUser Balance post processRefunds: 0
[PASS] testclaimProjectFunds() (gas: 24345)
Logs:
claimProjectFunds() ERROR : Refunds not yet processed
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 17.97s (17.79s CPU time)
- EVM:
evm_version = 'cancun'infoundry.toml; fork block 14,636,844 (Ethereum mainnet, April 2022). - To run a single test, add
--mt testDOSAttackor--mt testclaimProjectFunds.
Reference: Akutar NFT / Aku-Auction refund DoS + permanently locked bidder ETH, April 2022 — DeFiHackLabs PoC
(see the @Analysis link and tx 0x62d280abc60f8b604175ab24896c989e6092e496ac01f2f5399b2a62e9feaacf cited in
test/AkutarNFT_exp.sol).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-04-AkutarNFT_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
AkutarNFT_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Aku-Auction (Akutar NFT) Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.