Reproduced Exploit
Pino C-01 — `Curve::withdraw` strands users' native ETH
Chain
Other
Category
untagged
Date
Jan 1970
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 27249-c-01-calling-curvewithdraw-will-likely-result-in-users-losin. Standalone Foundry PoC and full write-up: 27249-c-01-calling-curvewithdraw-will-likely-result-in-users-losin_exp in the
evm-hack-registrymirror.
Calling the Pino router's Curve.withdraw to remove liquidity from a Curve pool that pays out native ETH leaves that ETH permanently stuck in the router. The withdrawn ETH becomes recoverable only by the contract owner, so the withdrawing user loses the ETH value of their position.
Real source#
- Repo:
nitolabs/pino-contract(private today; the audited tree survives inmatinkaboli/pino-contract-v1and as Etherscan-verified deployments). - Audited commit:
e11214c8eb52fd967d496888999b0327a8f28a93(Pashov review commit). - Vulnerable file:
contracts/protocols/v2/Curve.sol—Curve.withdraw(uint256,uint256[2],ICurvePool). - Report: Pashov 2023-09-01 Pino, AuditVault #27249.
The real Curve.sol, BaseProtocolProxy.sol, Multicall.sol, Permit.sol, and the ICurve/ICurvePool/IWETH9/IPermit2 interfaces are vendored unmodified under src/pino/ and deployed in the PoC. The only mock is src/mocks/MockVenue.sol — a minimal Curve-style ETH pool (the opaque external venue) plus a minimal WETH9 and a stETH-like ERC20.
Root cause#
Curve.withdraw simply forwards to remove_liquidity:
function withdraw(uint256 _amount, uint256[2] calldata _minAmounts, ICurvePool _pool) external payable {
_pool.remove_liquidity(_amount, _minAmounts); // returns NATIVE ETH to this router for ETH pools
emit Withdraw(msg.sender, address(_pool)); // ... but the ETH is never wrapped or forwarded
}
For a Curve pool that holds ETH (e.g. the stETH pool), remove_liquidity sends native ETH to the caller — the Pino router. withdraw never wraps it to WETH and never forwards it to the user. The router's only user-facing exits are:
sweepToken(IERC20,address)— moves an ERC20 balance, not native ETH;unwrapWETH9(address)— unwraps the router's WETH balance, which is0here.
There is no sweepETH. So the ETH sits as the router's raw balance, retrievable only by the owner via withdrawAdmin(address). The sibling functions withdrawOneCoinI/U handle this correctly — they wrap the received ETH (weth.deposit{value: balanceAfter - balanceBefore}()), which is exactly the fix the report recommends for withdraw.
Exploit walkthrough (concrete numbers)#
- A user supplies
1 ETHof liquidity through the real router (deposit->pool.add_liquidity{value: 1 ETH}); the pool mints LP to the router — the withdrawal precondition. - The user calls
withdraw(1e18, [0,0], pool).remove_liquidityreturns1 ETHnatively to the router.withdrawdoes not wrap it -> the router's ETH balance is1 ETH, its WETH balance is0. - The user calls
unwrapWETH9(user)— the router holds0WETH, so nothing is sent. The user has no other ETH exit. - The
ownercallswithdrawAdmin(attacker)and pockets the stranded1 ETH.
Net settlement: the user deposited 1 ETH, recovered 0, and the owner/attacker captured 1 ETH. The contrast test proves the same pool ETH is fully recoverable through withdrawOneCoinU (it wraps to WETH, then unwrapWETH9 returns the full 1 ETH).
Reproduce#
_shared/run-poc/run_poc.sh 27249-c-01-calling-curvewithdraw-will-likely-result-in-users-losin_exp -vvvvv
Both tests pass: test_27249_withdraw_strands_user_eth (the loss) and test_27249_withdrawOneCoin_wraps_and_is_recoverable (the contrast).
Fix#
Wrap the returned ETH to WETH inside withdraw, mirroring withdrawOneCoinI/U, so the user can unwrapWETH9 (or otherwise sweep) it back to their wallet.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 27249-c-01-calling-curvewithdraw-will-likely-result-in-users-losin_exp (evm-hack-registry mirror).
- AuditVault finding: 27249-c-01-calling-curvewithdraw-will-likely-result-in-users-losin.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Pino C-01".
- 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.