All incidents

Pawnfi ApeStaking Debt Mismatch

Share
Jun 17, 2023 02:41 UTCAttackLoss: 102.31 ETH, 5,627.68 APEPending manual check1 exploit txWindow: Atomic
Estimated Impact
102.31 ETH, 5,627.68 APE
Label
Attack
Exploit Tx
1
Addresses
3
Attack Window
Atomic
Jun 17, 2023 02:41 UTC → Jun 17, 2023 02:41 UTC

Exploit Transactions

TX 1Ethereum
0x8d3036371ccf27579d3cb3d4b4b71e99334cae8d7e8088247517ec640c7a59a5
Jun 17, 2023 02:41 UTCExplorer

Victim Addresses

0x0b89032e2722b103386adccae18b2f5d4986afa0Ethereum
0x73625745ed66f0d4c68c91613086ece1fc5a0119Ethereum
0x37b614714e96227d81ffffbdbdc4489e46eace8cEthereum

Loss Breakdown

102.31ETH
5,627.68APE

Similar Incidents

Root Cause Analysis

Pawnfi ApeStaking Debt Mismatch

1. Incident Overview TL;DR

On 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.

2. Key Background

Pawnfi routes ApeCoin staking for BAYC, MAYC, and BAKC through a small group of public contracts:

  • ApeStaking proxy: 0x0B89032E2722b103386aDCcaE18B2F5D4986aFa0
  • ApeStaking implementation: 0x85018CF6F53c8bbD03c3137E71F4FCa226cDa92C
  • ApePool / sAPE market: 0x73625745eD66F0d4C68C91613086ECe1Fc5a0119
  • P-BAYC: 0x5f0A4a59C8B39CDdBCf0C683a6374655b4f5D76e
  • iP-BAYC: 0x9C1c49B595D5c25F0Ccc465099E6D9d0a1E5aB37
  • CEther: 0x37B614714e96227D81fFffBdbDc4489e46eAce8C

The key protocol behaviors relevant to the exploit are:

  • ApePool is a Compound-style lending market over APE staking exposure. borrowBehalf assigns debt to the specified borrower.
  • P-BAYC is a shared custody contract for BAYC-related ApeCoin staking. APE can sit on its balance before being forwarded into ApeCoinStaking.
  • ApeStaking is the orchestrator. Users call it to deposit NFTs, borrow APE from ApePool, stake APE through P-BAYC, withdraw staked APE, and collect rewards.
  • All relevant entrypoints used in the incident are public and permissionless. No privileged signer, whitelist, or private orderflow was required.

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.

3. Vulnerability Analysis & Root Cause Summary

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.

4. Detailed Root Cause Analysis

4.1 Code-Level Breakpoints

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.

4.2 On-Chain Exploit Sequence

The seed trace shows the exploit matching the code-level breakpoints.

First, the attacker used public liquidity to create borrowable collateral:

  • The sender EOA was 0x8f7370d5d461559f24b83ba675b4c7e2fdb514cc.
  • The main attacker contract was 0xb618d91fe014bfcb9c8d440468b6c78e9ada9da1.
  • A helper borrower contract was created in-transaction at 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:

  1. ApePool assigns 206227682165404022135955 APE debt to the helper borrower.
  2. The borrowed APE is transferred into P-BAYC.
  3. No BAYC or BAKC staking call follows for that helper invocation, because the stake arrays are empty.

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.

4.3 ACT Opportunity Definition

The relevant ACT pre-state is Ethereum mainnet immediately before transaction 0x8d3036371ccf27579d3cb3d4b4b71e99334cae8d7e8088247517ec640c7a59a5 in block 17496620. The necessary public conditions were:

  • Uniswap V3 APE/WETH held enough APE liquidity for a 200000 APE flash loan.
  • Pawnfi ApePool, ApeStaking, P-BAYC, iP-BAYC, isAPE, and CEther were live and publicly callable.
  • P-BAYC held publicly accessible BAYC inventory and could be borrowed through iP-BAYC.
  • ApeCoin staking cap-per-position for the BAYC pool was public, enabling the attacker to size the drain loop.

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.

4.4 Violated Security Principles

The exploit violates three explicit safety principles:

  • Borrowed assets must never be parked in a shared contract balance without a debtor-to-position binding.
  • Repayment must be keyed to the borrower that created the debt, not to whichever address later withdraws a pooled asset.
  • User-supplied borrow amounts and stake arrays must satisfy an atomic accounting relation before value leaves the lending pool.

5. Adversary Flow Analysis

The adversary flow is a single-transaction multi-stage attack:

  1. Flash-borrow 200000 APE from the public Uniswap V3 APE/WETH pool.
  2. Mint sAPE, then mint isAPE, and enter isAPE as collateral.
  3. Borrow 1005 P-BAYC through iP-BAYC and use randomTrade(1) to redeem BAYC #9829.
  4. Deploy a fresh helper borrower contract that calls depositAndBorrowApeAndStake with empty arrays and borrowAmount = SAPE.getCash(), parking 206227682165404022135955 APE in P-BAYC while assigning the debt to the helper.
  5. Set the attacker's collectRate to 1e18.
  6. Deposit BAYC #9829 into ApeStaking.
  7. Repeatedly call depositAndBorrowApeAndStake(..., borrowAmount = 0, nfts = [9829, capPerPosition]) followed by withdrawApeCoin(...) until the shared P-BAYC APE balance is drained.
  8. Borrow 102306731023453679484 wei from CEther against the collateral position.
  9. Repay the Uniswap flash loan and keep the ETH plus residual APE.

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.

6. Impact & Losses

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.

7. References

Primary evidence used for validation and this report:

  • Seed transaction: 0x8d3036371ccf27579d3cb3d4b4b71e99334cae8d7e8088247517ec640c7a59a5
  • Block: 17496620
  • Seed metadata artifact for tx identity and block context
  • Seed trace artifact for the borrow, transfer, withdraw, and borrow-balance calls
  • Seed balance diff artifact for ETH and APE losses
  • Verified ApeStaking implementation at 0x85018CF6F53c8bbD03c3137E71F4FCa226cDa92C
  • Verified PTokenBAYC source artifact collected for 0x9B88802823f49A213DD768719F0958C982786824
  • Collected CErc20/CToken source for Compound-style borrowBalanceCurrent semantics