This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0x8d3036371ccf27579d3cb3d4b4b71e99334cae8d7e8088247517ec640c7a59a50x0b89032e2722b103386adccae18b2f5d4986afa0Ethereum0x73625745ed66f0d4c68c91613086ece1fc5a0119Ethereum0x37b614714e96227d81ffffbdbdc4489e46eace8cEthereumOn Ethereum mainnet block 17496620, transaction 0x8d3036371ccf27579d3cb3d4b4b71e99334cae8d7e8088247517ec640c7a59a5 realized a deterministic ACT exploit against Pawnfi's Ape staking path. The attacker used public Uniswap V3 APE liquidity, public Pawnfi lending and NFT markets, and a freshly deployed helper contract to borrow 206227682165404022135955 APE into the shared P-BAYC balance without staking any NFT against that debt. The attacker then redeemed BAYC #9829, deposited it into ApeStaking, repeatedly withdrew cap-sized chunks of the parked APE through the BAYC staking path, borrowed 102306731023453679484 wei from CEther, repaid the flash loan, and kept both ETH and residual APE.
The root cause is an accounting mismatch inside Pawnfi's ApeStaking implementation. depositAndBorrowApeAndStake allows borrowed APE to leave ApePool and enter the shared P-BAYC balance before proving that any stake was created, while _repayAndClaim later repays only the debt of the address currently withdrawing. That disconnect lets one attacker-controlled address incur the ApePool debt and a different attacker-controlled address withdraw the parked APE with zero direct debt.
Pawnfi routes ApeCoin staking for BAYC, MAYC, and BAKC through a small group of public contracts:
0x0B89032E2722b103386aDCcaE18B2F5D4986aFa00x85018CF6F53c8bbD03c3137E71F4FCa226cDa92C0x73625745eD66F0d4C68C91613086ECe1Fc5a01190x5f0A4a59C8B39CDdBCf0C683a6374655b4f5D76e0x9C1c49B595D5c25F0Ccc465099E6D9d0a1E5aB370x37B614714e96227D81fFffBdbDc4489e46eAce8CThe key protocol behaviors relevant to the exploit are:
borrowBehalf assigns debt to the specified borrower.ApeCoinStaking.The exploit therefore fits the ACT model: a permissionless adversary can reconstruct the required pre-state from chain data, deploy fresh contracts, and call the same public interfaces on a mainnet fork.
This is an ATTACK root cause, not a pure MEV arbitrage. The invariant that should hold is straightforward: every unit of APE borrowed through ApeStaking must remain bound to the same user's staking position, and any later unstake or claim from that position must first reduce that same user's ApePool debt before residual APE becomes withdrawable.
Pawnfi breaks that invariant in two places. First, depositAndBorrowApeAndStake borrows APE on behalf of msg.sender and transfers the borrowed tokens into the shared P-BAYC balance before it proves that _nfts or _nftPairs are non-empty. Second, _repayAndClaim does not track the debtor that originally funded the shared P-BAYC balance; it calls borrowBalanceCurrent(userAddr) for the address currently withdrawing and only repays that address's debt. If the withdrawer has zero direct debt but the shared P-BAYC balance still contains APE funded by another borrower, the withdrawer can receive the entire amount according to their collectRate.
The exploit used exactly that gap. A helper borrower contract was created only to call the borrow path with empty stake arrays, leaving the large debt on the helper while the borrowed APE sat in P-BAYC. The main attacker contract later used BAYC #9829 to withdraw that parked APE even though borrowBalanceCurrent(attacker) returned zero throughout the drain loop.
The verified ApeStaking implementation shows the first breakpoint directly:
function depositAndBorrowApeAndStake(
DepositInfo memory depositInfo,
StakingInfo memory stakingInfo,
IApeCoinStaking.SingleNft[] calldata _nfts,
IApeCoinStaking.PairNftDepositWithAmount[] calldata _nftPairs
) external nonReentrant {
address userAddr = msg.sender;
address ptokenStaking = _getPTokenStaking(stakingInfo.nftAsset);
if (stakingInfo.borrowAmount > 0) {
uint256 borrowRate = IApePool(apePool).borrowRatePerBlock();
uint256 stakingRate = getRewardRatePerBlock(_nftInfo[stakingInfo.nftAsset].poolId, stakingInfo.borrowAmount);
require(borrowRate + stakingConfiguration.addMinStakingRate < stakingRate, "rate");
IApePool(apePool).borrowBehalf(userAddr, stakingInfo.borrowAmount);
IERC20Upgradeable(apeCoin).safeTransfer(ptokenStaking, stakingInfo.borrowAmount);
}
...
if (_nfts.length > 0) {
IPTokenApeStaking(ptokenStaking).depositApeCoin(nftAmount, _nfts);
}
if (_nftPairs.length > 0) {
IPTokenApeStaking(ptokenStaking).depositBAKC(nftPairAmount, _nftPairs);
}
}
This function does not require any NFT stake array to be non-empty before the borrow and transfer happen. If _nfts.length == 0 and _nftPairs.length == 0, borrowed APE is still moved into the shared P-BAYC balance and the function simply skips the staking calls.
The second breakpoint is the repayment path:
function _repayAndClaim(address userAddr, uint256 allAmount, uint256 allClaimAmount, RewardAction actionType) internal {
...
uint256 repayed = IApePool(apePool).borrowBalanceCurrent(userAddr);
if (repayed > 0) {
...
IApePool(apePool).repayBorrowBehalf(userAddr, totalAmount - (allAmount + allClaimAmount));
}
totalAmount = allAmount + allClaimAmount;
if (totalAmount > 0) {
uint256 claimAmount = totalAmount * _userInfo[userAddr].collectRate / BASE_PERCENTS;
_transferAsset(apeCoin, userAddr, claimAmount);
if (totalAmount > claimAmount) {
IApePool(apePool).mintBehalf(userAddr, totalAmount - claimAmount);
}
}
}
Repayment is keyed only to the current userAddr. There is no binding between the APE being withdrawn from P-BAYC and the borrower that originally sourced that APE from ApePool.
The shared-balance property of P-BAYC is also visible in its verified code:
function depositApeCoin(uint256 amount, IApeCoinStaking.SingleNft[] memory _nfts) external virtual onlyApeStaking {
_approveMax(amount);
IApeCoinStaking(getApeCoinStaking()).depositBAYC(_nfts);
}
function withdrawApeCoin(IApeCoinStaking.SingleNft[] calldata _nfts, address recipient) external virtual onlyApeStaking {
IApeCoinStaking(getApeCoinStaking()).withdrawBAYC(_nfts, recipient);
}
P-BAYC is a shared staging balance controlled by ApeStaking; it does not separately segregate borrowed APE by original debtor.
The seed trace shows the exploit matching the code-level breakpoints.
First, the attacker used public liquidity to create borrowable collateral:
0x8f7370d5d461559f24b83ba675b4c7e2fdb514cc.0xb618d91fe014bfcb9c8d440468b6c78e9ada9da1.0xbd02543126b0846d3acf8f49ff6c1aabceeb2662.The helper then executed the empty-array borrow path. The trace records:
ApePool::borrowBehalf(0xbD02543126B0846D3aCF8f49ff6C1AaBCeEB2662, 206227682165404022135955)
...
SimpleToken::transfer(BeaconProxy: [0x5f0A4a59C8B39CDdBCf0C683a6374655b4f5D76e], 206227682165404022135955)
That sequence is the concrete manifestation of the bug:
206227682165404022135955 APE debt to the helper borrower.Later, the main attacker contract redeemed BAYC #9829, deposited it into ApeStaking, set collectRate to 1e18, and repeatedly withdrew the parked APE. The trace for each loop shows:
ApeStaking::withdrawApeCoin(BoredApeYachtClub, [SingleNft({ tokenId: 9829, amount: 10094000000000000000000 })], [])
...
PTokenBAYC::withdrawApeCoin(...)
...
ApePool::borrowBalanceCurrent(0xB618D91Fe014BfCB9C8d440468b6C78e9adA9DA1) -> 0
...
SimpleToken::transfer(0xB618D91Fe014BfCB9C8d440468b6C78e9adA9DA1, 10094000000000000000000)
Those lines prove the withdrawal address is not the debtor being repaid. The helper borrower carried the debt, while the draining attacker address had zero direct ApePool debt and therefore received the withdrawn APE outright.
The loop continued until the parked P-BAYC APE was exhausted. The trace shows a final short withdrawal of 4347682165404022135955 APE, which aligns with the final residual parked balance.
The relevant ACT pre-state is Ethereum mainnet immediately before transaction 0x8d3036371ccf27579d3cb3d4b4b71e99334cae8d7e8088247517ec640c7a59a5 in block 17496620. The necessary public conditions were:
200000 APE flash loan.The transaction sequence required only one public Ethereum transaction from an unprivileged EOA. No attacker-owned historical contract addresses or private calldata were needed to realize the exploit from scratch.
The exploit violates three explicit safety principles:
The adversary flow is a single-transaction multi-stage attack:
200000 APE from the public Uniswap V3 APE/WETH pool.1005 P-BAYC through iP-BAYC and use randomTrade(1) to redeem BAYC #9829.depositAndBorrowApeAndStake with empty arrays and borrowAmount = SAPE.getCash(), parking 206227682165404022135955 APE in P-BAYC while assigning the debt to the helper.collectRate to 1e18.#9829 into ApeStaking.depositAndBorrowApeAndStake(..., borrowAmount = 0, nfts = [9829, capPerPosition]) followed by withdrawApeCoin(...) until the shared P-BAYC APE balance is drained.102306731023453679484 wei from CEther against the collateral position.The balance diff confirms the realized value transfer:
{
"native_balance_deltas": [
{
"address": "0x37b614714e96227d81ffffbdbdc4489e46eace8c",
"delta_wei": "-102306731023453679484"
},
{
"address": "0xb618d91fe014bfcb9c8d440468b6c78e9ada9da1",
"delta_wei": "102306731023453679484"
}
],
"erc20_balance_deltas": [
{
"token": "0x4d224452801aced8b2f0aebe155379bb5d594381",
"holder": "0xb618d91fe014bfcb9c8d440468b6c78e9ada9da1",
"delta": "5627682165404022135955"
}
]
}
The sender EOA lost only gas, the attacker contract gained the ETH, and the attacker contract also retained 5627682165404022135955 APE after flash-loan repayment.
The measurable protocol loss is:
102306731023453679484 wei (102.306731023453679484 ETH) drained from CEther.5627682165404022135955 wei-denominated APE (5627.682165404022135955 APE) retained by the attacker after repaying the flash loan.The economic effect is larger than a simple vault withdrawal. The exploit severed the intended linkage between ApePool debt and Ape staking positions: the helper borrower remained responsible for the large APE debt, while a different attacker-controlled address collected the parked APE and used the resulting collateral position to extract ETH from CEther. That is a direct accounting failure in Pawnfi's staking-and-borrow integration.
Primary evidence used for validation and this report:
0x8d3036371ccf27579d3cb3d4b4b71e99334cae8d7e8088247517ec640c7a59a5174966200x85018CF6F53c8bbD03c3137E71F4FCa226cDa92C0x9B88802823f49A213DD768719F0958C982786824borrowBalanceCurrent semantics