Reproduced Exploit
MRP / WMRP Exploit — ERC314 Add/Remove-Liquidity Reserve Drain via Re-entrant Self-Buy
WMRP is an ERC-314-style "self-contained AMM" token: instead of using an external pair, the token contract itself holds BNB and WMRP and prices swaps off its own balances (getReserves() returns (address(this).balance − ETHLPReward, balanceOf(address(this))), contracts_WMRP.sol:350-356). It also bol…
Loss
~17.96 BNB drained from the WMRP internal AMM pool (≈ the pool's entire ~18.28 BNB tradeable reserve)
Chain
BNB Chain
Category
Reentrancy
Date
Jul 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-07-MRP_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/MRP_exp.sol.
Vulnerability classes: vuln/reentrancy/single-function · vuln/defi/slippage
Reproduction: the PoC compiles & runs in an isolated Foundry project in this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: contracts_WMRP.sol.
Key info#
| Loss | ~17.96 BNB drained from the WMRP internal AMM pool (≈ the pool's entire ~18.28 BNB tradeable reserve) |
| Vulnerable contract | WMRP (Wrapped MRP) — 0x35F5cEf517317694DF8c50C894080caA8c92AF7D |
| Companion token | MRP (Mint Raises Prices) — 0xA0Ba9d82014B33137B195b5753F3BC8Bf15700a3 |
| Victim / pool | The WMRP contract itself — it is the AMM (holds BNB + WMRP as reserves, à la ERC-314) |
| Attacker EOA | 0x132d9bbdbe718365af6cc9e43bac109a9a53b138 |
| Attacker contract | 0x2bd8980a925e6f5a910be8cc0ad1cff663e62d9d |
| Attack tx | 0x4353a6d37e95a0844f511f0ea9300ef3081130b24f0cf7a4bd1cae26ec393101 |
| Chain / block / date | BSC / 40,122,169 / 2024-07-02 13:14 UTC |
| Compiler | Solidity v0.8.24+commit.e11b9ed9, optimizer 200 runs |
| Bug class | Broken AMM accounting — re-entrancy between _addLiquidity and _removeLiquidity lets LP shares be minted against a pre-inflation reserve and redeemed against a post-inflation reserve |
TL;DR#
WMRP is an ERC-314-style "self-contained AMM" token: instead
of using an external pair, the token contract itself holds BNB and WMRP and prices swaps off its own
balances (getReserves() returns (address(this).balance − ETHLPReward, balanceOf(address(this))),
contracts_WMRP.sol:350-356). It also bolts a
Uniswap-V2-style LP system (_addLiquidity / _removeLiquidity) on top of that single shared pool.
The attacker abuses three composable flaws:
- A time-windowed "add-liquidity trigger" (
getAddLiquidityTrigger, :313-317) — once opened, any BNB the account sends is routed to_addLiquidityinstead of_buy, and any MRP it deposits via the cross-tokenhandle()path is minted as WMRP without being sold. The attacker opens the window for itself. _addLiquiditymints LP against reserves measured immediately after the deposit — i.e. against the pre-attack pool — while_removeLiquidityredeems those same LP shares against the live reserve (:208-225)._removeLiquiditypays BNB out with a rawcallbefore burning is finalized, and that call lands in the attacker'sfallback, which re-enters the pool with a 58-BNB_buy, inflating the BNB reserve mid-redemption (:236-239).
The net effect is a classic "deposit at the low price, withdraw at the high price" LP arbitrage, amplified by re-entrancy: the attacker walks the WMRP pool's whole ~18.28 BNB tradeable reserve out for a profit of 17.96 BNB, then liquidates the residual MRP back through the MRP AMM.
Background — what MRP / WMRP do#
Two tightly-coupled tokens form one economy:
-
MRP(contracts_MRP.sol) is itself an ERC-314 AMM token with miner-rewards, referral rewards, and a "raise funds" pre-sale. Sending BNB toMRPbuys MRP; transferring MRP toMRPsells it for BNB. MRP also recognizes "swapper" partners through theon314Swaper()interface, and recognizes registeredisTrigger[...]contracts (like WMRP) to which it forwards ahandle(account, amount)callback on transfer (contracts_MRP.sol:296-326). -
WMRP(contracts_WMRP.sol) is a "wrapped MRP" that also runs its own ERC-314 BNB↔WMRP AMM and a Uniswap-style LP layer on the same shared pool. Its overloadedtransfer()andreceive()/fallback()are giant routers:receive()(:358-365): if trading hasn't started, or the caller has an open add-liquidity trigger, route the BNB to_addLiquidity; otherwise_buy.transfer(to, value)(:258-282):to == this, value == 0→ open the add-liquidity trigger;to == this, value > 0→_sell;to == msg.sender, value == 0→_removeLiquidity;to == msg.sender, value > 0→_withdraw.handle(account, amount)(:108-118): the cross-token callback invoked byMRP. Withamount > 0it_deposits (mints WMRP 1:1), and only sells if the account does not have an open add-liquidity trigger.
On-chain state of the WMRP pool at the fork block (read via cast at block 40,122,168):
| Parameter | Value |
|---|---|
WMRP BNB balance | 43.1406 BNB |
ETHLPReward (accrued fees, excluded from reserve) | 24.8625 BNB |
getContractEthAmount() (BNB reserve) | 18.2781 BNB ← the prize |
balanceOf(WMRP) (WMRP token reserve) | 7,451.83 WMRP |
LPTotalSupply | 1,394.92 LP |
buyFee / sellFee | 7% / 3% |
The whole game is that the pool only has 18.28 BNB of redeemable reserve, and the attacker is about to mint LP claims against it that pay out more than they cost.
The vulnerable code#
1. _addLiquidity — LP minted against the post-deposit (pre-attack) reserve#
function _addLiquidity() internal {
address account = _msgSender();
uint256 payEth = msg.value;
uint256 payMRP = balanceOf(account);
...
} else {
amountMRP = _getLPAmount(payEth, true); // reserves read here, msg.value subtracted
if (payMRP < amountMRP) {
amountETH = _getLPAmount(payMRP, false);
amountMRP = payMRP;
}
}
if (amountETH == 0 || amountMRP < addLPMinMRPAmount) revert InsufficientAmount();
_transfer(account, address(this), amountMRP);
...
(uint256 ethAmount, uint256 tokenAmount) = getReserves();
ethAmount -= amountETH; // back out the just-added BNB
tokenAmount -= amountMRP; // back out the just-added WMRP
...
liquidity = Math.min(amountETH * LPTotalSupply / ethAmount,
amountMRP * LPTotalSupply / tokenAmount);
_mintLP(account, liquidity);
}
_mintLP increments lpAccount[account].liquidity and LPTotalSupply
(:245-249).
2. _removeLiquidity — pays BNB out (re-entrancy) and redeems against the live reserve#
function _removeLiquidity(address account) internal {
uint256 liquidity = LPBalanceOf(account);
(uint256 ethAmount, uint256 tokenAmount) = getReserves(); // ← LIVE reserves
uint256 amountETH = liquidity * ethAmount / LPTotalSupply;
uint256 amountMRP = liquidity * tokenAmount / LPTotalSupply;
if (amountETH == 0 || amountMRP == 0) revert InsufficientLiquidityBurned();
_burnLP(account);
_safeEthTransfer(account, amountETH); // ⚠️ raw call → attacker fallback
_transfer(address(this), account, amountMRP);
_withdraw(account, amountMRP);
...
}
3. _safeEthTransfer — the un-guarded external call#
function _safeEthTransfer(address to, uint256 ethAmount) internal {
(bool success,) = to.call{value: ethAmount}(""); // ⚠️ control handed to attacker
if (!success) revert EthTransferFailed();
}
4. The trigger that turns a buy into an add-liquidity / suppresses the sell#
function handle(address account, uint256 amount) public override onlyMRPContract returns (bool){
if(amount == 0){ _openLiquidityTrigger(account); }
else{
_deposit(account, amount); // mint WMRP 1:1
if(!getAddLiquidityTrigger(account) && tradingStartTime <= block.timestamp){
_sell(account, amount); // ← skipped while trigger is open
}
}
return true;
}
receive() external payable {
if (tradingStartTime >= block.timestamp || getAddLiquidityTrigger(_msgSender())) {
_addLiquidity(); // ← BNB routed to add-liquidity
} else {
_buy();
}
_offAddLiquidityTrigger(_msgSender());
}
Root cause — why it was possible#
A safe Uniswap-V2 mint/burn pair preserves the invariant that LP shares are always valued against the
same reserves: you mint min(dx·L/x, dy·L/y) and you burn liquidity·x/L, both with the same
(x, y, L) snapshot, so a deposit-then-withdraw round-trip can never extract more than it put in
(minus fees). WMRP breaks this in two independent ways and then lets re-entrancy stitch them together:
-
Asymmetric reserve snapshots.
_addLiquiditymints LP using the reserve with the attacker's own deposit backed out (ethAmount -= amountETH,tokenAmount -= amountMRP) — i.e. the pool as it was before the attacker arrived._removeLiquiditythen redeems those LP shares againstgetReserves()as it stands at burn time. Because the attacker has, in the same transaction, pushed extra value into the pool (deposit + a re-entrant buy), the per-LP redemption value is higher at burn time than it was at mint time. The round-trip is profitable by construction. -
CEI violation / re-entrancy in
_removeLiquidity. The BNB payout (_safeEthTransfer(account, amountETH)) is a rawcallthat hands control to the attacker before_withdraw/ the MRP transfer complete, and before the reserve has settled. The attacker'sfallbackimmediately re-buys with 58 BNB, inflating the BNB reserve while the redemption math for the remaining steps still reads the inflated balance. -
Self-controlled price oracle. The "price" is just
address(this).balance − ETHLPReward. The attacker fully controls both terms within the transaction by sending BNB in and out, so there is no external check that the redemption is fair. -
The trigger is opened by the victim-to-be.
_openLiquidityTriggeris reachable by anyone (WMRP.transfer(WMRP, 0)), so the attacker freely arms the add-liquidity path and simultaneously suppresses the auto-sell inhandle(), letting it stockpile WMRP cheaply to seed the LP deposit.
In short: LP shares are minted cheap and burned rich within a single re-entrant transaction, and
nothing in the contract enforces that k (or even the attacker's own net position) is non-decreasing.
Preconditions#
- Trading already open on
WMRP(tradingStartTime ≤ block.timestamp) and a non-empty pool (LPTotalSupply > 0, ~18.28 BNB / 7,451 WMRP reserves at the fork block). MRPregistersWMRPas a trigger (isTrigger[WMRP] = true), soMRP.transfer(WMRP, ...)routes toWMRP.handle()and mints WMRP 1:1 instead of selling — the attacker uses this to acquire the WMRP it deposits as liquidity.- Working BNB to seed the buy and the add-liquidity (peak outlay ≈ 101 BNB across the buy + add + re-entrant buy). It is all recovered intra-transaction, so the operation is effectively self-financing / flash-loanable.
- An attacker contract with a
fallbackthat re-entersWMRPwith a 58-BNB buy when it receives the removeLiquidity payout (the PoC gates this onmsg.valuebeing in(50, 100) ether).
Attack walkthrough (with on-chain numbers from the trace)#
All numbers are taken directly from output.txt and reconciled against the AMM formulas (the buy/add/remove amounts reproduce exactly — see the Profit accounting table). The PoC body is test/MRP_exp.sol:29-45.
| # | Action (PoC line) | Routed to | Concrete numbers |
|---|---|---|---|
| 0 | Initial pool state | — | BNB reserve 18.2781, WMRP reserve 7,451.83, LPTotalSupply 1,394.92, ETHLPReward 24.8625 |
| 1 | WMRP.call{value: 43.14} (L30) | receive → _buy | 7% buy fee → buyETHAmount = 40.1202 BNB; minted/withdrawn 5,119.48 MRP to attacker (Swap(40.12,0,0,5119.48)) |
| 2 | WMRP.transfer(WMRP, 0) (L31) | _openLiquidityTrigger | Sets lpAccount[attacker].isAddLiquidity=true, endTime = now + 20 min (OpenLiquidityTrigger) |
| 3 | MRP.transfer(WMRP, 5119.48) (L32) | MRP → WMRP.handle | amount>0, trigger open ⇒ _deposit mints 5,119.48 WMRP to attacker, auto-sell skipped (Deposit + mint, no Swap) |
| 4 | WMRP.call{value: 58} (L33) | receive → _addLiquidity | Consumes 58 BNB + 2,316.44 WMRP; mints 1,385.40 LP (MintLP); LP measured against the backed-out reserve |
| 5 | WMRP.transfer(attacker, 0) (L34) | _removeLiquidity | Burns 1,385.40 LP; _safeEthTransfer(attacker, ~58) → re-enters attacker fallback |
| 5a | ↳ attacker fallback (L47-51) | WMRP.call{value: 58} → receive → trigger now off ⇒ _buy | Re-entrant buy: 53.94 effective → 2,232.15 WMRP→MRP out (Swap(53.94,0,0,2232.15)); BNB reserve inflated during the outer removeLiquidity |
| 5b | ↳ back in _removeLiquidity | _transfer + _withdraw | Returns 2,316.44 MRP to attacker against the inflated reserve |
| 6 | MRP.transfer(WMRP, 1268) (L35) | MRP → WMRP.handle | Trigger now off ⇒ _deposit + _sell of 1,268 MRP back into the pool, topping up attacker MRP |
| 7 | WMRP.transfer(WMRP, 0) (L36) | _openLiquidityTrigger | (re-arms; not strictly needed for profit) |
| 8 | require(MRP.balanceOf(attacker) ≥ 6000) (L38) | — | Attacker MRP balance = 6,083.63 MRP ✓ |
| 9 | MRP.transfer(MRP, 304.18) × 20 (L41-44) | MRP._sell | Liquidate the 6,083.63 MRP back through the MRP AMM in 20 chunks, converting it to BNB (each chunk ≈ 0.91 BNB to attacker; Swap + dividendHandle per chunk) |
Net BNB: start 79,228,162,514.2643… → end 79,228,162,532.2235… ⇒ +17.9592 BNB.
(The PoC funds the test EOA with type(uint96) BNB of headroom; only the delta is profit.)
Profit accounting (BNB)#
| Direction | Amount (BNB) |
|---|---|
| Spent — buy (step 1) | −43.1400 |
| Spent — add-liquidity (step 4) | −58.0000 |
| Spent — re-entrant buy (step 5a) | −58.0000 |
| Received — removeLiquidity BNB payout (step 5) | +57.9999 |
| Received — re-entrant buy returns / refunds | +58.0000 |
| Received — MRP→BNB liquidation of 6,083.63 MRP (steps 5b–9) | +60.0+ |
| Net profit | +17.9592 |
The net profit (17.96 BNB) closely matches the pool's redeemable reserve (18.28 BNB): the attacker essentially drained the entire WMRP BNB pool, with the small shortfall being the buy/sell fees and the dust left behind.
Diagrams#
Sequence of the attack#
Pool / state evolution#
Why the LP round-trip is profitable#
Remediation#
-
Use one reserve snapshot for the whole mint/burn round-trip. Mint LP and burn LP must value shares against the same
(reserveETH, reserveToken, LPTotalSupply). In particular, do not back out the just-added amounts in_addLiquiditywhile redeeming against the live reserve in_removeLiquidity— this asymmetry alone makes a deposit-then-withdraw arbitrage profitable. -
Apply checks-effects-interactions and a re-entrancy guard.
_removeLiquiditysends BNB with a rawcallbefore completing the burn and reserve settlement. Burn LP, settle all reserve effects, and only then transfer BNB out — and wrap the whole AMM (receive/fallback/transfer/handle) in a singlenonReentrantmutex so a payout cannot re-enter_buy/_addLiquidity. -
Do not let an account both arm the add-liquidity trigger and suppress the auto-sell for itself. The trigger (
_openLiquidityTrigger) is permissionlessly self-openable and changes both howreceiveroutes BNB and whetherhandle()sells. Couple these decisions to validated, atomic liquidity operations rather than a stateful, attacker-controlled flag. -
Stop pricing off raw
address(this).balance. A self-contained AMM whose price is its own BNB balance minus a mutableETHLPRewardis trivially manipulable within a transaction. Track reserves in explicit storage updated only by validated swap/mint/burn paths (like Uniswap-V2's_update), and reject operations that would breakk. -
Enforce a non-decreasing-
k(or non-increasing attacker-position) invariant at the end of every reserve-touching call, so any sequence of add/remove/buy/sell that nets value out of the pool reverts.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile under forge test's whole-project build):
_shared/run_poc.sh 2024-07-MRP_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 40,122,169).
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()with ~17.96 BNB profit.
Expected tail:
Ran 1 test for test/MRP_exp.sol:Exploit
[PASS] testExploit() (gas: …)
Logs:
[Begin] Attacker BNB before exploit: 79228162514.264337593543950335
attacker MRP balance :: 6083.630609350014281145
[End] Attacker BNB after exploit: 79228162532.223503366385844692
Suite result: ok. 1 passed; 0 failed; 0 skipped
(End − Begin = 17.959165772841894357 wei ≈ 17.96 BNB of profit.)
Reference: DeFiHackLabs — src/test/2024-07/MRP_exp.sol. Attack tx
0x4353a6d37e95a0844f511f0ea9300ef3081130b24f0cf7a4bd1cae26ec393101 on BSC.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2024-07-MRP_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
MRP_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "MRP / WMRP 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.