Reproduced Exploit
Chainge Finance Exploit — Arbitrary External Call in `MinterProxyV2.swap()` Drains Approvals
MinterProxyV2 is the cross-chain bridge "minter/vault" contract for Chainge Finance. Its swap() function is meant to take a user's input token, route it through an aggregator/DEX target chosen by the relayer, and forward the proceeds to a receiver. To do this it makes a fully attacker-controlled ex…
Loss
~$200K across 12 tokens (USDT, SOL, AVAX, BabyDoge, FLOKI, ATOM, TLOS, IOTX, 1INCH, LINK, BTCB, ETH) drained…
Chain
BNB Chain
Category
Upgradeable / Proxy
Date
Apr 2024
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: 2024-04-ChaingeFinance_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/ChaingeFinance_exp.sol.
Vulnerability classes: vuln/dependency/unsafe-external-call · vuln/access-control/missing-validation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo does not whole-compile, so this PoC was extracted standalone). Full verbose trace: output.txt. Verified vulnerable source: contracts_MinterProxyV2.sol.
Key info#
| Loss | ~$200K across 12 tokens (USDT, SOL, AVAX, BabyDoge, FLOKI, ATOM, TLOS, IOTX, 1INCH, LINK, BTCB, ETH) drained from a single approving user |
| Vulnerable contract | MinterProxyV2 — 0x80a0D7A6FD2A22982Ce282933b384568E5c852bF |
| Victim (the approver) | 0x8A4AA176007196D48d39C89402d3753c39AE64c1 (held unlimited approvals to the proxy) |
| Attacker EOA | 0x6eec0F4c017AfE3dfADF32B51339C37e9Fd59dfb |
| Attacker contract | 0x791C6542bC52efe4f20DF0eE672b88579Ae3fd9A |
| Attack tx (one of three) | 0x051276afa96f2a2bd2ac224339793d82f6076f76ffa8d1b9e6febd49a4ec11b2 |
| Chain / block / date | BSC / 37,880,387 / April 15, 2024 |
| Compiler | Solidity v0.8.17, optimizer 1000 runs |
| Bug class | Arbitrary external call with attacker-controlled target + calldata (CALL injection), abusing the proxy's standing token allowances |
TL;DR#
MinterProxyV2 is the cross-chain bridge "minter/vault" contract for Chainge Finance. Its swap()
function is meant to take a user's input token, route it through an aggregator/DEX target chosen by
the relayer, and forward the proceeds to a receiver. To do this it makes a fully attacker-controlled
external call: target.functionCall(callData)
(contracts_MinterProxyV2.sol:720),
where both target and callData come straight from the caller's arguments with no allow-list.
Because the contract is a long-lived bridge, many users had granted it unlimited ERC20 approvals.
The attacker simply pointed swap() at a real token contract and passed
callData = transferFrom(victim, attacker, amount). The proxy — which is the approved spender —
dutifully executed realToken.transferFrom(victim, attacker, amount), pulling the victim's tokens
straight to the attacker.
The "did I actually receive output?" sanity check (new_balance > old_balance) was trivially defeated:
the attacker passed their own attack contract as both tokenAddr and receiveToken, so the proxy
queried balanceOf on a contract the attacker controls, which returns whatever the attacker wants.
In the reproduced transaction the attacker looped this over 12 different tokens the victim had approved, draining each one for a combined ≈ $200K.
Background — what Chainge / MinterProxyV2 does#
Chainge Finance is a cross-chain DEX/bridge. MinterProxyV2
(source) is the on-chain vault that
holds and routes funds for the bridge:
vaultOut/vaultIn— the canonical bridge in/out paths.vaultInisonlyController(:756) (relayer-gated), and pulls from / pushes to the liquidity pool.swap— a public, permissionless helper (:683-748) intended to let a user atomically converttokenAddr→receiveTokenby calling an external DEX/aggregatortargetwith relayer-suppliedcallData, then receive the difference.vaultInAndCall/_callAndTransfer— the controller-only equivalent that also makes an arbitrary call (:805-961), but is gated behindonlyController.
Because it is a bridge vault, the design assumes users approve the proxy so that swap()/vaultOut
can pull their tokens. At the fork block, the victim
0x8A4AA176...64c1 had granted type(uint256).max allowances to the proxy on at least 12 tokens
(every allowance(victim, proxy) in the trace returns 1.157e77). That standing approval is the
entire prize.
The vulnerable code#
swap() — arbitrary target + callData, permissionless#
function swap(
address tokenAddr,
uint256 amount,
address target, // ← attacker-controlled
address receiveToken, // ← attacker-controlled
address receiver, // ← attacker-controlled
uint256 minAmount,
bytes calldata callData,// ← attacker-controlled
bytes calldata order
) external payable nonReentrant whenNotPaused {
_checkVaultOut(tokenAddr, amount, order);
require(
target != address(this) && target != address(0),
"MP: target is invalid"
);
require(callData.length > 0, "MP: calldata is empty");
require(receiveToken != address(0), "MP: receiveToken is empty");
require(receiver != address(0), "MP: receiver is empty");
require(minAmount > 0, "MP: minAmount is empty");
uint256 old_balance = _balanceOfSelf(receiveToken);
if (tokenAddr == NATIVE) {
...
} else {
IERC20(tokenAddr).safeTransferFrom(_msgSender(), address(this), amount);
if (IERC20(tokenAddr).allowance(address(this), target) < amount) {
IERC20(tokenAddr).safeApprove(target, MAX_UINT256);
}
target.functionCall(callData, "MP: FunctionCall failed"); // ⚠️ ARBITRARY CALL
}
uint256 new_balance = _balanceOfSelf(receiveToken);
require(new_balance > old_balance, "MP: receive amount should above zero"); // ⚠️ on attacker token
uint256 _amountOut = new_balance - old_balance;
require(_amountOut >= minAmount, "MP: receive amount not enough");
IERC20(receiveToken).safeTransfer(receiver, _amountOut); // harmless: sends attacker's own token
...
}
contracts_MinterProxyV2.sol:683-748
The only validation on target is target != address(this) && target != address(0)
(:694-697). There is no
allow-list of routers/aggregators, and callData is forwarded verbatim. MinterProxyV2 is the
spender users approved, so a call of the form realToken.transferFrom(victim, attacker, amount)
executed by the proxy succeeds against the victim's allowance.
The output check is on an attacker-chosen token#
_balanceOfSelf(receiveToken) reads IERC20(receiveToken).balanceOf(address(this))
(:614-624). The attacker sets
receiveToken = address(attackContract), whose balanceOf simply returns an attacker-controlled
counter that increments on every transferFrom. So new_balance > old_balance always holds and the
final safeTransfer(receiver, _amountOut) just moves the attacker's own fake token to the attacker —
a no-op for value, but it satisfies the contract's bookkeeping.
Root cause — why it was possible#
A contract that holds standing token allowances must treat the ability to call transferFrom on those
tokens as a privileged capability. MinterProxyV2.swap() hands that capability to anyone:
It performs
target.call(callData)withtargetandcallDatataken directly from the caller, and the proxy is the approved spender for every user. Settingtarget = USDT,callData = transferFrom(victim, attacker, victimBalance)turns the proxy into a confused deputy that steals from anyone who ever approved it.
Three independent failures compose into the critical:
- Unrestricted call target. No allow-list / registry of permitted aggregators. The only check is
target != this && target != 0, which does nothing to stoptargetbeing a token contract. - Unrestricted calldata.
callDatais forwarded byte-for-byte, so any function ontargetcan be invoked — includingtransferFromagainst another user's approval. - Forgeable output accounting. The
new_balance > old_balanceguard is evaluated onreceiveToken, which the caller also chooses; pointing it at a self-controlled contract makes the guard vacuous. (Even a real guard would not help — the value was already stolen by the time it runs.)
The swap() path being permissionless (no onlyController) is what makes this exploitable by an
external attacker; the controller-only vaultInAndCall/_callAndTransfer make the same arbitrary call
but are gated behind a trusted relayer.
Preconditions#
- A victim has an outstanding ERC20 allowance to
MinterProxyV2(here:type(uint256).maxon 12 tokens). This is the natural state for bridge users. - The proxy is not paused (
whenNotPaused). - The attacker passes a self-controlled contract as
tokenAddr/receiveTokenso the incidentalsafeTransferFrom(attacker, proxy, amount)and the output-balance check both succeed harmlessly. - No capital required — the attack consumes only the victim's approval; the attacker spends only gas. (Flash loans are irrelevant here; nothing is borrowed.)
Attack walkthrough (with on-chain numbers from the trace)#
The PoC (test/ChaingeFinance_exp.sol) iterates 12 tokens. For each
token it computes amount = min(victimBalance, victimAllowance), builds
transferFrom(victim, attacker, amount), and calls
swap(attackContract, 1, realToken, attackContract, attackContract, 1, callData, hex"00").
The proxy then, per call (output.txt):
| # | Sub-step (inside swap) | What happens |
|---|---|---|
| 1 | safeTransferFrom(attacker, proxy, 1) on tokenAddr = attackContract | No-op; attacker's fake token bumps an internal counter 0 → 1. |
| 2 | allowance(proxy, target) check, then safeApprove(target, MAX) if low | Proxy approves the real token to itself as needed (irrelevant to theft). |
| 3 | target.functionCall(callData) = realToken.transferFrom(victim, attacker, amount) | ⚠️ The theft. Proxy uses the victim's standing approval; tokens move victim → attacker. |
| 4 | _balanceOfSelf(receiveToken) on receiveToken = attackContract | Reads attacker's fake balanceOf (now > old) — guard passes. |
| 5 | safeTransfer(receiver = attacker, _amountOut) of the fake token | Harmless self-transfer; emits LogVaultOut. |
Per-token drain (ground truth from the trace)#
Amounts are the attacker contract's post-attack balanceOf for each token (the profit logs):
| # | Token | Address | Drained (token units) |
|---|---|---|---|
| 1 | Tether USD (USDT) | 0x55d398...7955 | 20,606.73 |
| 2 | SOLANA (SOL) | 0x570A5D...43dF | 621.10 |
| 3 | Avalanche (AVAX) | 0x1CE0c2...4041 | 395.59 |
| 4 | Baby Doge Coin | 0xc74867...e8de | 131,364,626,198,991.17 |
| 5 | FLOKI | 0xfb5B83...D37E | 21,952,469.54 |
| 6 | Cosmos Token (ATOM) | 0x0Eb3a7...F335 | 389.54 |
| 7 | pTokens TLOS | 0xb6C534...717c | 10,763.84 |
| 8 | IoTeX Network (IOTX) | 0x9678E4...64E5 | 34,350.38 |
| 9 | 1INCH Token | 0x111111...C302 | 3,114.42 |
| 10 | ChainLink Token (LINK) | 0xF8A0BF...51bD | 1,600.18 |
| 11 | BTCB Token | 0x7130d2...ad9c | 0.7612 |
| 12 | Ethereum Token (ETH) | 0x2170Ed...933F8 | 44.6952 |
The first transferFrom (USDT) moved 20,580.187820908676022964 USDT directly from the victim to the
attacker (trace output.txt, Transfer(from: 0x8A4A…64c1, to: attacker, value: 2.058e22)).
By dollar value at the time, USDT ($20.6K), BTCB ($48K), ETH ($140), LINK, AVAX, SOL, ATOM and the
others combine to the reported **$200K** total loss.
Profit / loss accounting#
| Party | Effect |
|---|---|
Victim 0x8A4A…64c1 | Lost the full approved balance of all 12 tokens (≈ $200K). |
| Attacker | Gained those tokens for the cost of gas; 0 capital risked. |
MinterProxyV2 | No funds of its own lost — it was used as a confused deputy against its approvers. |
Diagrams#
Sequence of one token drain (USDT)#
Confused-deputy data flow#
Allowance state evolution (per token, e.g. USDT)#
Remediation#
- Allow-list call targets.
swap()(and any function that doestarget.call(callData)) must restricttargetto a vetted registry of routers/aggregators. An arbitrarytargetthat can be a token contract is the whole bug. - Never let user calldata invoke
transferFromon the vault's own approvals. If the contract holds standing allowances, it must guard thetransferFromcapability. Decode/validate the selector and arguments ofcallData, or use a dedicated routing interface instead of rawcall. - Pull funds from
msg.senderonly via the canonical path. The proxy should only ever pull tokens that the caller is moving (their own deposit), not act on a third party's approval inside a user-controlled call. - Don't trust caller-chosen
receiveTokenfor accounting. The pre/post balance check must be on the real input/output token through a trusted code path; readingbalanceOfon a caller-supplied address makes the check forgeable. (Even so, the value is already gone by the time the check runs — the fix is preventing the arbitrary call, not improving the guard.) - Mitigation for users: revoke approvals to the vulnerable proxy
(
0x80a0D7A6FD2A22982Ce282933b384568E5c852bF) immediately.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has several
unrelated PoCs that fail forge test's whole-project build):
_shared/run_poc.sh 2024-04-ChaingeFinance_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 37,880,387 is from April 2024).
foundry.tomluseshttps://bsc-mainnet.public.blastapi.io, which serves historical state at that block; most public BSC RPCs prune it and fail withheader not found/missing trie node. - Result:
[PASS] testExploit()— all 12 tokens are drained and logged with theirprofitamounts.
Expected tail:
targetToken: BTCB Token
profit: 0.761239692987924742
targetToken: Ethereum Token
profit: 44.695214852737827402
Suite result: ok. 1 passed; 0 failed; 0 skipped
Ran 1 test suite: 1 tests passed, 0 failed, 0 skipped (1 total tests)
References: CertiK — https://x.com/CertiKAlert/status/1779863821122691519 · ChainAegis — https://x.com/ChainAegis/status/1780064080512143429 · Autosaida analysis — https://github.com/Autosaida/DeFiHackAnalysis/blob/master/analysis/240415_ChaingeFinance.md
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2024-04-ChaingeFinance_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
ChaingeFinance_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Chainge Finance 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.