Reproduced Exploit
BADCODE MEV Bot Exploit — Unauthenticated dYdX `callFunction` Callback Drained to a Max Approval
The BADCODE MEV bot implemented dYdX's ICallee.callFunction(address sender, Account.Info accountInfo, bytes data) hook so it could receive dYdX flash-style callbacks. dYdX's OperationImpl._call (SoloMargin.sol:5441-5456) will route a Call action to any otherAddress the caller names — it only
Loss
1101.359974579155257683 WETH (≈ $1.45M at the ~$1,320/ETH price of Sep 2022) drained from the BADCODE MEV bot
Chain
Ethereum
Category
Reentrancy
Date
Sep 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-09-MEVbadc0de_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/MEVbadc0de_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/dependency/unchecked-return-value
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile under
forge test, so this one was extracted standalone). Full verbose trace: output.txt. Verified vector source (dYdX SoloMargin): sources/SoloMargin_1E0447/SoloMargin.sol. Verified token source (WETH9): sources/WETH9_C02aaA/WETH9.sol.
Key info#
| Loss | 1101.359974579155257683 WETH (≈ $1.45M at the ~$1,320/ETH price of Sep 2022) drained from the BADCODE MEV bot |
| Vulnerable contract | The BADCODE MEV bot — proxy 0xbaDc0dEfAfCF6d4239BDF0b66da4D7Bd36fCF05A → logic 0xDd6Bd08c29fF3EF8780bF6A10D8b620A93AC5705 (its callFunction handler) |
| Attack vector / callback router | dYdX SoloMargin — proxy 0x1E0447b19BB6EcFdAe1e4AE1694b0C3659614e4e → impl OperationImpl 0x56e7d4520abfecf10b38368b00723d9bd3c21ee1 |
| Victim asset | WETH9 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 |
| Attacker (PoC EOA) | 0x4A130A95fB6EAdDFBaBB718D263cA0E4732d491E (= vm.addr(31337)) |
| Chain / block / date | Ethereum mainnet / fork block 15,625,424 / ~September 27, 2022 |
| Compiler | SoloMargin v0.5.7+commit.6da8b019 (opt 10000); WETH9 v0.4.19; PoC 0.8.10 |
| Bug class | Unauthenticated callback / missing msg.sender & account-owner validation on an ICallee.callFunction handler → arbitrary approve |
TL;DR#
The BADCODE MEV bot implemented dYdX's ICallee.callFunction(address sender, Account.Info accountInfo, bytes data)
hook so it could receive dYdX flash-style callbacks. dYdX's OperationImpl._call
(SoloMargin.sol:5441-5456)
will route a Call action to any otherAddress the caller names — it only
checks that the caller is an operator of the dYdX account being passed in, not
of the callee. So anyone can make dYdX invoke the bot's callFunction with
attacker-chosen data.
The bot's callFunction handler did not authenticate the call: it neither
checked that sender (the original dYdX caller) was the bot's owner, nor that
accountInfo.owner was trusted, nor that it was genuinely mid-operation. It
simply decoded the attacker-supplied data and executed it — performing
WETH.approve(attacker, 56_372_227.27e18) against itself.
Once the bot had granted a near-infinite WETH allowance to the attacker, the
attacker called WETH.transferFrom(bot, attackerEOA, bot's entire 1101.36 WETH balance) and walked away with the lot — in a single transaction, with zero
capital (no flash loan, no collateral; the dYdX Call action moves no funds).
Background — the two contracts involved#
There are two distinct pieces here, and it is important not to conflate them:
-
dYdX SoloMargin (the router, NOT the bug). SoloMargin is a margin/lending protocol whose single entry point
operate(Info[] accounts, ActionArgs[] actions)(SoloMargin.sol:4666-4699) processes a batch of actions. One action type isCall— "send arbitrary data to an address" (Actions enum :3643-3653). It exists so that integrators can run custom logic inside a dYdX operation (e.g. flash-loan-style atomic strategies). dYdX faithfully forwards the call. -
The BADCODE MEV bot (the actual victim). A privately-deployed MEV bot (proxy
0xbaDc0dE…, logic0xDd6Bd08c…) that integrated with dYdX by implementingcallFunction. Its source is not verified on Etherscan, but the on-chain trace shows exactly what its handler did when called: it parsed the inbounddataand executed an arbitraryWETH.approve. That missing authentication is the vulnerability. (DeFiHackLabs files this incident under "BADCODE" / the bot's0xbaDc0devanity address.)
dYdX's Call plumbing (faithful, by design)#
// contracts/protocol/interfaces/ICallee.sol (:3608-3625)
contract ICallee {
function callFunction(
address sender, // the msg.sender that called dYdX.operate
Account.Info memory accountInfo,
bytes memory data // arbitrary, attacker-controlled
) public;
}
// contracts/protocol/impl/OperationImpl.sol (:5441-5456)
function _call(Storage.State storage state, Actions.CallArgs memory args) private {
state.requireIsOperator(args.account, msg.sender); // ← only authorizes the dYdX *account*
ICallee(args.callee).callFunction( // ← args.callee = ANY address the caller named
msg.sender,
args.account,
args.data
);
Events.logCall(args);
}
The Call action's callee is taken verbatim from args.otherAddress
(parseCallArgs :4004-4018),
and requireIsOperator(args.account, msg.sender)
(Storage.requireIsOperator :2017-2036)
is trivially satisfied because msg.sender == account.owner — the attacker
simply passes their own dYdX account as accounts[0]. dYdX never checks any
relationship between the attacker and the callee. This is correct dYdX
behavior: it is the integrator's job to authenticate the callback.
In the PoC the attacker's Info is {owner: address(this), number: 1}
(test/MEVbadc0de_exp.sol:100-101) and the
callee (otherAddress) is the MEV bot
(:124-125).
The vulnerable code (the MEV bot's callFunction)#
The bot's logic is unverified, so we reconstruct its behavior from the trace. The relevant call chain in output.txt:23-42:
operate([{owner: attacker, number: 1}], [Call → otherAddress = 0xbaDc0de…(bot)])
└─ OperationImpl._call → ICallee(bot).callFunction(attacker, (attacker,1), data)
├─ bot proxy 0xbaDc0de… .callFunction(attacker, (attacker,1), data)
│ └─ delegatecall → bot logic 0xDd6Bd08c… .callFunction(attacker, (attacker,1), data)
│ ├─ WETH9.allowance(bot, attacker) → 0
│ ├─ WETH9.approve(attacker, 56372227272130782805279000) ← ⚠️ the bug
│ │ emit Approval(owner: bot, spender: attacker, value: 5.637e25)
│ └─ bot.fallback( 0x…4798ce5b… ) ← a benign no-op selector the decoder hit
The bot's handler effectively does (decompiled intent):
// BADCODE MEV bot logic 0xDd6Bd08c… — NO authentication
function callFunction(address sender, Account.Info memory accountInfo, bytes memory data) public {
// ❌ NO check that sender == owner
// ❌ NO check that accountInfo.owner is trusted
// ❌ NO check that we are inside a self-initiated dYdX operation
// It decodes `data` into (target, calldata) and EXECUTES it against `target`:
// target = WETH; calldata = approve(attacker, ~uint?) → bot grants attacker an allowance
...
}
The attacker-controlled data is hand-encoded in
test/MEVbadc0de_exp.sol:131-161. It carries
the WETH target (address(weth)), the spender (address(this) = attacker), and
an approve-shaped payload; the exact framing is whatever the bot's bespoke
decoder expected. The mechanical proof is the Approval event in the trace
(output.txt:31) and the resulting allowance read-back of
56372227272130782805279000 (output.txt:46-48).
WETH9 behaves exactly as a correct ERC-20 should#
WETH9 is not at fault. approve and transferFrom
(WETH9.sol — standard Allowance pattern) only
enforce that the owner of the funds authorized the spender. Here the owner
(the bot) "authorized" the attacker — because the bot's callback let the
attacker dictate that approval. WETH then correctly honored the transferFrom
(output.txt:51-57).
Root cause — why it was possible#
The bot exposed callFunction as a public, unauthenticated entry point that
executes attacker-supplied instructions against itself. dYdX's Call action
is a general-purpose, permissionless way to invoke callFunction(sender, accountInfo, data) on any address with any data. Combining the two:
Anyone can call
dYdX.operate([myAccount], [Call → bot, data=approve(me, max)]). dYdX dutifully callsbot.callFunction(me, (me,1), approve-payload). The bot trusts the payload and approves the attacker. The attacker thentransferFroms the bot's entire WETH balance.
Three decisions compose into the critical bug:
- No
sender/ owner check in the callback. AcallFunctionhandler MUST verify that the original caller (sender) and/oraccountInfo.owneris the bot's own owner/operator before acting. dYdX even documentssenderas "The msg.sender to Solo" precisely so integrators can authenticate it (ICallee :3612-3624). The bot ignored it. - The callback executes arbitrary, data-driven actions on the bot's own
assets. A safe handler would only run a fixed, pre-committed strategy; this
one let
datachoose the target and calldata (here:WETH.approve). - The privileged action it could be coerced into is a token approval. A
single
approve(attacker, huge)converts "I can make the bot run code" into "I own the bot's tokens," redeemable later viatransferFrom.
dYdX's contract is not the vulnerability; it is the (publicly callable)
delivery mechanism. The same class of bug bit several MEV bots and integrators
that wired up callFunction (and similar Aave/Uniswap/Balancer flash callbacks)
without authenticating the caller.
Preconditions#
- The victim implements
ICallee.callFunctionand performs state-changing, data-controlled actions in it (here: anapprove) without authenticatingsender/accountInfo.owner. ✓ (the BADCODE bot). - The victim holds value the action can hand over — here 1101.36 WETH (output.txt:6,20-21).
- dYdX SoloMargin's
Callaction is permissionless (it is). The attacker passes their own account asaccounts[0], satisfyingrequireIsOperator. - No capital required. The
Callaction moves no tokens; the attacker needs only gas. No flash loan, no collateral.
Step-by-step attack walkthrough (with ground-truth numbers from the trace)#
All values are taken directly from output.txt.
| # | Step | Call / event | Observed value | Effect |
|---|---|---|---|---|
| 0 | Baseline | WETH.balanceOf(bot) (:20-21) | 1101.359974579155257683 WETH | The prize sits in the bot. |
| 1 | Trigger the callback | dYdX.operate([{attacker,1}], [Call → bot, data]) (:23) | — | Permissionless; attacker is operator of their own account. |
| 2 | dYdX forwards | OperationImpl._call → bot.callFunction(attacker,(attacker,1),data) (:24-27) | — | Faithful dYdX forwarding (delegatecalled impl). |
| 3 | Bot reads its allowance | WETH.allowance(bot, attacker) (:28-29) | 0 | Pre-attack: attacker has no allowance. |
| 4 | ⚠️ Bot self-approves attacker | WETH.approve(attacker, …); emit Approval(bot→attacker) (:30-34) | 56,372,227.272130782805279 WETH | Unauthenticated callback grants a (vastly over-sized) allowance. |
| 5 | Decoder no-op | bot.fallback(0x…4798ce5b…) (:35-36) | — | Trailing bytes hit a benign selector / fallback; harmless. |
| 6 | operate returns | storage @12: 666711 → 666712 (:43-45) | — | dYdX bumps an internal counter; nothing else changed. |
| 7 | Confirm allowance | WETH.allowance(bot, attacker) (:46-48) | 56,372,227.27 WETH | Allowance now live. |
| 8 | Drain | WETH.transferFrom(bot, attackerEOA, balanceOf(bot)); emit Transfer(bot→attacker, …) (:49-57) | 1101.359974579155257683 WETH | Entire bot balance moved to attacker. |
| 9 | Verify | WETH.balanceOf(bot)=0, balanceOf(attacker)=1101.36, assertEq(0,0) (:58-67) | bot 0 / attacker 1101.36 | Bot fully drained. |
Why the allowance is 56.37M WETH but the loss is "only" 1101 WETH#
The bot approved 56372227272130782805279000 wei — an absurd figure dwarfing
its 1101.36 WETH balance. The cap is irrelevant; any allowance ≥ the balance
suffices. The actual theft is bounded by what the bot held, so the loss equals
its full 1101.36 WETH balance, transferred in step 8.
Profit / loss accounting#
| Item | WETH |
|---|---|
Attacker capital in (gas only; Call moves no funds) | 0 |
| Allowance the bot granted the attacker | 56,372,227.272130782805279 (cap, not realized) |
WETH transferFrom'd out of the bot | 1101.359974579155257683 |
| Bot WETH balance after | 0 |
| Attacker net profit | +1101.359974579155257683 WETH |
The PoC's closing assertions match the trace to the wei: MEV Bot WETH balance After exploit: 0, Exploiter WETH balance After exploit: 1101.359974579155257683 (output.txt:8-9, 60-63).
Diagrams#
Sequence of the attack#
Trust / control flow — where the check was missing#
State evolution of the bot's WETH#
Remediation#
The fix belongs entirely in the integrator's callback, not in dYdX or WETH.
- Authenticate every flash/
callFunctioncallback. IncallFunction, require that the call originated from a trusted operation:require(msg.sender == address(dYdXSoloMargin), "not dydx")— the immediate caller must be the expected protocol, andrequire(sender == owner() || isTrusted[sender], "untrusted initiator")— the originaloperatecaller (sender) must be the bot's own owner/operator, and/oraccountInfo.ownermust be whitelisted. The same rule applies toexecuteOperation(Aave),uniswapV2Call,receiveFlashLoan(Balancer), etc.
- Never let callback
datachoose arbitrary targets/calldata. A callback should run a fixed, pre-committed strategy. If the bot must execute encoded instructions, gate them behind owner authentication AND a target/selector allowlist; never permitapprove/transfer/transferFromof the bot's own funds to a caller-supplied address. - Use a transient "operation in progress" latch. Set a one-shot flag before
calling
dYdX.operatefrom the bot and require it insidecallFunction, so a callback that the bot did not itself initiate reverts. - Minimize standing approvals and asset custody. Don't leave large idle WETH balances in a contract that also exposes generic callbacks; sweep profits to a cold address.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella
DeFiHackLabs repo has many unrelated PoCs that fail the whole-project build under
forge test):
_shared/run_poc.sh 2022-09-MEVbadc0de_exp -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required (fork block
15,625,424, Sep 2022).
foundry.tomlpointsmainnetat an Infura endpoint; any archive node serving historical state at that block works. - Result:
[PASS] testExploit().
Expected tail:
Ran 1 test for test/MEVbadc0de_exp.sol:ContractTest
[PASS] testExploit() (gas: 168322)
Logs:
MEV Bot balance before exploit:: 1101.359974579155257683
Contract BADCODE WETH Allowance: 56372227.272130782805279000
MEV Bot WETH balance After exploit:: 0.000000000000000000
Exploiter WETH balance After exploit:: 1101.359974579155257683
Suite result: ok. 1 passed; 0 failed; 0 skipped
PoC credits: @kayaba2002 and @eugenioclrc (see test/MEVbadc0de_exp.sol:7-11). Reference: DeFiHackLabs / SlowMist Hacked — BADCODE MEV bot, Ethereum, Sep 2022.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-09-MEVbadc0de_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
MEVbadc0de_exp.sol.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "BADCODE MEV Bot 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.