MCAI

The tax wallet could move anyone’s tokens without their approval.

Ethereum mainnet · chain 1Block 21,720,380January 2025Broken access control12.03 WETH lost
What happened

MCAI’s transferFrom moved the tokens first and debited the allowance afterwards, and the amount it debited came from a helper that returned zero whenever the caller was the token’s tax wallet. So the tax wallet could pull tokens from any holder without approval.

The attacker, who controlled that wallet, pulled 99.99% of the MCAI in the MCAI/WETH pool, synced the pool at the tiny remaining reserve, and sold the drained supply for ETH.

The fix that holds

Debit the full amount from the caller’s allowance on every transferFrom, whoever the caller is.

The exploit drains the pool, so a fix that protects only the pool stops it. The security obligations state the bug for every holder, which is what catches that fix.

Sources
What a fix must do

The exploit, plus 16 hidden obligations.

The exploit must fail for a reason the task declares. The security obligations state the vulnerability class, so each one fails on the original contract by construction. The legitimate-use obligations hold before and after a correct repair. Hidden means kept out of the fixer’s workspace; every test is published in the repository.

The exploit

1 replay
  • !The DeFiHackLabs reproduction of the attack, replayed at block 21,720,380 It lands if the attacker’s profit clears 11 ETH (the incident: 12.03 WETH lost). It counts as blocked only if it fails in the attack or profit step with an assertion failure, “exploit did not yield profit” or “ERC20: transfer amount exceeds allowance”. test/poc.t.sol:MCAI_PoC:testExploit()

Security obligations

4 · fail on the original
  • !The tax wallet cannot move an ordinary holder’s tokens without approvalThe bug stated for every holder, not just the pool the exploit drained.test/hidden/security.t.sol:MCAI_Security:test_taxwallet_cannot_move_holder_tokens_without_approval()
  • !The tax wallet cannot move the pool’s tokens without approvalChecked on the balance change itself, so a fix that breaks the allowance view cannot game it.test/hidden/security.t.sol:MCAI_Security:test_taxwallet_cannot_move_pair_tokens_without_approval()
  • !With approval, the tax wallet’s allowance is debited by exactly what it movesThe original moved the tokens and debited nothing.test/hidden/security.t.sol:MCAI_Security:test_taxwallet_transferFrom_debits_allowance()
  • !No one can burn a holder’s tokens to the dead wallet without approvalThe second branch of the same broken helper. A fix aimed only at the tax wallet leaves it open.test/hidden/security.t.sol:MCAI_Security:test_unapproved_spender_cannot_burn_to_dead_wallet()

Legitimate use · behaviour

7 · hand-written
  • Buying through the pool the incident used still worksIncludes the token’s fee path.test/hidden/regression.t.sol:MCAI_Regression:test_amm_buy_works()
  • Approve, then transferFrom, works and debits the allowance exactlyA fix that disables transferFrom, or never debits, fails here.test/hidden/regression.t.sol:MCAI_Regression:test_approved_transferFrom_works_and_debits_allowance()
  • Token metadata and the pool’s balance are intactDecimals still 9, supply and symbol present, the pool still holds MCAI.test/hidden/regression.t.sol:MCAI_Regression:test_metadata_preserved()
  • A holder-to-holder transfer workstest/hidden/regression.t.sol:MCAI_Regression:test_normal_transfer_works()
  • With approval, the tax wallet can still spend like anyone elseA fix cannot close the hole by blocking the tax wallet forever.test/hidden/regression.t.sol:MCAI_Regression:test_taxwallet_transferFrom_with_approval_moves_tokens()
  • transferFrom takes exactly the amount out of the senderCatches a fix that quietly credits the sender back: an unlimited mint.test/hidden/regression.t.sol:MCAI_Regression:test_transferFrom_debits_the_sender_exactly()
  • A spender with no approval still cannot move tokensA fix that removes the allowance check altogether fails here.test/hidden/regression.t.sol:MCAI_Regression:test_unapproved_spender_cannot_move_tokens()

Legitimate use · interface

5 · generated
  • Original functions still answerCalls the original functions and requires an answer other than “no such function”.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_dispatch()
  • Every original function is still in the dispatch tableWalks the patched bytecode and requires each original selector in the dispatcher.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_preserved()
  • The patched contract has codetest/hidden/invariants_auto.t.sol:AutoInvariants:test_contract_has_code()
  • Guard: the selector check can say noAn impossible selector must be reported absent, or the check above proves nothing.test/hidden/invariants_auto.t.sol:AutoInvariants:test_selector_check_is_not_vacuous()
  • Guard: unknown calls are still rejectedWithout this, a catch-all fallback would make the dispatch probe meaningless.test/hidden/invariants_auto.t.sol:AutoInvariants:test_unknown_selector_is_rejected()
The test fixes

17 fixes, graded.

Complete repairs, near-misses, fixes that break the contract, fixes that only look like repairs, the original contract as a baseline, tampering attempts and runs with missing evidence, each written to probe the verifier. 5 of them stop the exploit and are still not repairs. An exploit-only check would accept all 5; they are highlighted.

3 solved11 not solved3 inconclusive
FixKindExploitSecurityLegit useSCAR verdict
Reference repairDebits the full allowance on every transferFrom, whoever calls.Complete repairblocked4 / 412 / 12Solved
Alternative repair: spend the allowance firstChecks and spends the caller’s allowance before any token moves, and never calls the flawed helper. Different code from the reference, same property.Complete repairblocked4 / 412 / 12Solved
Reference repair, no-network containerThe same repair graded inside a container with no network. The hashes are identical to the local run.Complete repairblocked4 / 412 / 12Solved
Closes one branch of twoBlocks the tax wallet but leaves the dead-wallet branch, so anyone can still burn anyone’s tokens.Incomplete repairblocked3 / 412 / 12Not solved
Protects the pool onlyDebits the allowance only when the pool is the owner. The tax wallet keeps its bypass over every other holder.Incomplete repairblocked1 / 412 / 12Not solved
Repair that changes decimalsThe reference repair plus decimals changed from 9 to 18.Breaks legitimate useblocked4 / 411 / 12Not solved
Repair that drops a functionThe reference repair plus a public function removed. Only the interface checks can see it.Breaks legitimate useblocked4 / 410 / 12Not solved
Repair that mints on transferThe reference repair plus a silent re-mint: the sender loses nothing, an unlimited mint.Breaks legitimate useblocked4 / 411 / 12Not solved
transferFrom disabledEvery transferFrom reverts. The exploit test fails for a reason the task does not declare, and every legitimate spender is locked out too.Breaks legitimate usenot counted3 / 49 / 12Not solved
Sabotaged allowance viewLeaves transferFrom untouched and makes allowance() revert, so the exploit test fails in its precondition checks. The bypass is intact.Non-repairnot counted0 / 412 / 12Not solved
Empty patchA “patch” byte-identical to the original source.Baselinelands0 / 412 / 12Not solved
Original contractThe code as it was exploited. The attack still works.Baselinelands0 / 412 / 12Not solved
Edits the exploit testThe reference repair plus an edit to the locked exploit test that would stop it from ever succeeding.Tampering attemptnot runNot solved
Path-traversal editTries to overwrite the locked exploit test through a ../ path inside the source folder.Tampering attemptnot runNot solved
Missing chain stateThe reference repair graded with half of the recorded chain state removed.Missing evidencenot establishedInconclusive
Syntax errorThe reference repair with a syntax error injected.Missing evidencenot runInconclusive
transferFrom renamedRenames transferFrom, which the token’s interface requires, so the contract no longer compiles.Missing evidencenot runInconclusive

Every row opens its receipt. Exploit: blocked; lands; not counted (the exploit test fails, but in its precondition checks or for a reason the task does not declare); not run; or not established on missing state.

Reproduce

Every grade here can be re-run.

The task ships its frozen chain state: 134 recorded RPC responses, served offline by a proxy that refuses anything it did not record. On 23 Sep 2026, 16 of the 17 grades in this case were re-run on two machines, a fresh clone on the Mac that recorded them and GitHub’s Linux CI runner (the Docker grade was not re-run). Every hash reproduced, except the missing-state run’s, which kept its outcome and reason.

Needs Foundry 1.7.1 with solc 0.8.30, 0.8.26 and 0.8.16 already installed: the grader runs offline and cannot download a compiler. Python 3.12 or later. Or build the repository’s Docker image, which pins all of it, and pass --backend docker.

Terminal
$ git clone https://github.com/FarseenSh/evmpatch-env.git && cd evmpatch-env
$ git checkout 165c0ed
# the reference repair
$ python -m evmpatch_env.sandbox tasks/mcai_2025_01 --reference-patch --backend local --sha256
# the original contract
$ python -m evmpatch_env.sandbox tasks/mcai_2025_01 --backend local --sha256
# every test fix in this case (needs uv)
$ uv run --no-project --python 3.12 python worked_example/mcai_2025_01/controls/run_controls.py

Reference: core f34d89ba…963e, receipt. Original: core ffb200f2…4f07, receipt.