All incidents

Palm PLP AUM Inflation

Share
Jul 24, 2023 17:23 UTCAttackLoss: 904,155.92 USDTPending manual check1 exploit txWindow: Atomic
Estimated Impact
904,155.92 USDT
Label
Attack
Exploit Tx
1
Addresses
3
Attack Window
Atomic
Jul 24, 2023 17:23 UTC → Jul 24, 2023 17:23 UTC

Exploit Transactions

TX 1BSC
0x62dba55054fa628845fecded658ff5b1ec1c5823f1a5e0118601aa455a30eac9
Jul 24, 2023 17:23 UTCExplorer

Victim Addresses

0x806f709558cdbba39699fbf323c8fda4e364ac7aBSC
0x6876b9804719d8d9f5aeb6ad1322270458fa99e0BSC
0xd990094a611c3de34664dd3664ebf979a1230fc1BSC

Loss Breakdown

904,155.92USDT

Similar Incidents

Root Cause Analysis

Palm PLP AUM Inflation

1. Incident Overview TL;DR

Palm Protocol on BNB Smart Chain exposed a permissionless accounting exploit at the public state immediately after block 30248637. In transaction 0x62dba55054fa628845fecded658ff5b1ec1c5823f1a5e0118601aa455a30eac9 at block 30248638, the adversary used a 3,000,000 USDT flash loan, bought PLP through Palm's LiquidityEvent, then sent another 2,000,000 USDT directly into Palm's Vault.buyUSDP path. That direct USDP mint increased the PLP AUM used for redemption without minting proportional PLP, so the attacker redeemed the already-purchased PLP against an overstated share price, sold additional USDP, repaid the flash loan, and kept 901455921547703167737871 USDT while the helper contract also retained 40108136663147844828665 USDP.

The root cause is a net-asset accounting failure in Palm's historical PLP pricing logic. Vault.buyUSDP increased both poolAmount and outstanding USDP, but PlpManager.getAum() added the increased poolAmount into PLP AUM without subtracting the matching USDP liability. That let any unprivileged actor who bought PLP before the direct USDP mint redeem the PLP at an inflated price.

2. Key Background

Palm's LP flow spans three public components:

  • LiquidityEvent proxy 0xd990094A611c3De34664dd3664ebf979A1230FC1
  • PlpManager proxy 0x6876B9804719d8D9F5AEb6ad1322270458fA99E0
  • Vault proxy 0x806f709558CDBBa39699FBf323C8fDA4e364Ac7A

At block 30248637, the proxies pointed to these historical implementations:

  • Vault implementation 0xea625e24a40b07f5094b390257f772580e757055
  • PlpManager implementation 0xa68f4b2c69c7f991c3237ba9b678d75368ccff8f
  • LiquidityEvent implementation 0x02a4b53926bad267df700ff8e9f5d4a2516bb1fc

Independent on-chain reads at block 30248637 confirm the exploit preconditions used in the analysis:

  • Vault.inManagerMode() = false
  • PlpManager.inPrivateMode() = true
  • PlpManager.cooldownDuration() = 0
  • LiquidityEvent.endWhitelistTime() = 1689350400, which was already before the transaction timestamp
  • PlpManager.getAumInUsdp(true) = 1060591303897471446528544

Palm routes LP entry and exit through LiquidityEvent.purchasePlp() and LiquidityEvent.unstakeAndRedeemPlp(), but PLP redemption is ultimately priced inside PlpManager._removeLiquidity() with:

uint256 aumInUsdp = getAumInUsdp(false);
uint256 plpSupply = IERC20Upgradeable(plp).totalSupply();
uint256 usdpAmount = (_plpAmount * aumInUsdp) / plpSupply;

That formula is safe only if aumInUsdp reflects net assets actually backing PLP. Palm's historical implementation did not satisfy that requirement.

3. Vulnerability Analysis & Root Cause Summary

This incident is an ATTACK-class ACT opportunity, not a privileged abuse. Palm left the direct buyUSDP and sellUSDP paths public whenever inManagerMode was disabled, and at the exploit block that flag was disabled. The PLP pricing path in PlpManager.getAum() counted vault.poolAmount() as PLP backing, but it did not subtract the outstanding USDP debt created by Vault.buyUSDP(). As a result, a direct USDP mint increased the AUM used in PLP redemption while leaving PLP supply unchanged. The attacker exploited that mismatch by buying PLP before the direct mint, then redeeming the PLP after the mint had inflated AUM. The same public transaction also sold most of the freshly minted USDP back into the vault, turning the accounting distortion into realized USDT profit.

4. Detailed Root Cause Analysis

The code breakpoint is visible in Palm's historical Vault and PlpManager implementations.

Historical Palm Vault implementation, behind the Vault proxy at block 30248637:

function buyUSDP(address _receiver) external override nonReentrant returns (uint256) {
    _validateManager();
    address _collateralToken = collateralToken;
    uint256 tokenAmount = _transferIn(_collateralToken);
    uint256 price = getMinPrice(_collateralToken);
    uint256 _usdpAmount = (tokenAmount * price) / PRICE_PRECISION;
    _usdpAmount = adjustForDecimals(_usdpAmount, _collateralToken, usdp);
    uint256 feeBasisPoints = vaultUtils.getBuyUsdpFeeBasisPoints(_collateralToken, _usdpAmount);
    uint256 amountAfterFees = _collectSwapFees(_collateralToken, tokenAmount, feeBasisPoints);
    uint256 mintAmount = (amountAfterFees * price) / PRICE_PRECISION;
    mintAmount = adjustForDecimals(mintAmount, _collateralToken, usdp);

    _increaseUsdpAmount(mintAmount);
    _increasePoolAmount(tokenAmount);
    IUSDP(usdp).mint(_receiver, mintAmount);
}

Historical Palm Vault manager gate:

function _validateManager() private view {
    if (inManagerMode) {
        _validate(isManager[msg.sender], 45);
    }
}

Historical Palm PlpManager implementation:

function getAum(bool maximise) public view returns (uint256) {
    uint256 aum = aumAddition;
    uint256 collateralTokenPrice = maximise ? _vault.getMaxPrice(collateralToken) : _vault.getMinPrice(collateralToken);
    uint256 collateralDecimals = _vault.tokenDecimals(collateralToken);

    uint256 currentAmmDeduction = (vault.permanentPoolAmount() * collateralTokenPrice) / (10**collateralDecimals);
    aum += (vault.poolAmount() * collateralTokenPrice) / (10**collateralDecimals);
    ...
    aum = currentAmmDeduction > aum ? 0 : aum - currentAmmDeduction;
    return aumDeduction > aum ? 0 : aum - aumDeduction;
}

function _removeLiquidity(address _account, uint256 _plpAmount, uint256 _minOut, address _receiver)
    private
    returns (uint256)
{
    uint256 aumInUsdp = getAumInUsdp(false);
    uint256 plpSupply = IERC20Upgradeable(plp).totalSupply();
    uint256 usdpAmount = (_plpAmount * aumInUsdp) / plpSupply;
    ...
    IERC20Upgradeable(usdp).safeTransfer(address(vault), usdpAmount);
    uint256 amountOut = vault.sellUSDP(_receiver);
}

The invariant is straightforward: PLP should be priced from Palm's net assets, not from gross collateral alone. Vault.buyUSDP() mints a new USDP liability and also increases poolAmount. Because PlpManager.getAum() adds poolAmount but never subtracts the matching USDP liability, a direct USDP mint makes PLP appear more valuable even though Palm's net equity did not improve.

The exploit required the same concrete conditions listed in the validated root-cause record:

  • Vault.inManagerMode had to remain false so an unprivileged caller could invoke buyUSDP() and sellUSDP() directly.
  • PlpManager.cooldownDuration had to remain 0 so PLP could be bought and redeemed in the same transaction.
  • The public LiquidityEvent route had to remain callable by an unprivileged address after the whitelist window.
  • The attacker had to source enough collateral, here via a public flash loan, to buy PLP first and then inflate AUM with a direct USDP mint.

The security principles violated were likewise concrete:

  • Net-asset accounting: Palm omitted the USDP liability from PLP backing.
  • Share-issuance equivalence: a non-PLP collateral path changed PLP redemption value without minting proportional PLP.
  • Permission minimization: a value-moving stablecoin mint and burn path remained public even though PLP pricing depended on it.

The exploit path in the seed transaction is fully visible in the collected trace:

0xd50Cf00b6e600Dd036Ba8eF475677d816d6c4281::flashLoan(... [3000000000000000000000000], ...)
LiquidityEvent::purchasePlp(1000000000000000000000000, 0, 0)
emit BuyUSDP(account: 0x6876B9804719d8D9F5AEb6ad1322270458fA99E0, tokenAmount: 1000000000000000000000000, usdpAmount: 996769453720000000000000, feeBasisPoints: 30)
emit AddLiquidity(..., 1000000000000000000000000, 1060591303897471446528544, 1060117814431969258921599, 996769453720000000000000, 996324456826157454206069)
emit BuyUSDP(account: 0x55252A6D50BFAd0E5F1009541284c783686F7f25, tokenAmount: 2000000000000000000000000, usdpAmount: 1993538907440000000000000, feeBasisPoints: 30)
LiquidityEvent::unstakeAndRedeemPlp(996311162765970954648048, 0, 0x55252A6D50BFAd0E5F1009541284c783686F7f25)
emit RemoveLiquidity(..., 996311162765970954648048, 4050654353532854041808248, 2056442271258126713127668, 1962472861668186127414294, 1956585443083181569032051)
emit SellUSDP(account: 0x55252A6D50BFAd0E5F1009541284c783686F7f25, usdpAmount: 1953430770776852155171335, tokenAmount: 1947570478464521598705820, feeBasisPoints: 30)

Those numbers show the exploit mechanics directly:

  1. The attacker borrowed 3,000,000 USDT from the public lending pool.
  2. 1,000,000 USDT went through purchasePlp, which minted 996324456826157454206069 PLP exposure before the AUM inflation step.
  3. The remaining 2,000,000 USDT was sent directly to the Vault and passed into buyUSDP, minting 1993538907440000000000000 USDP while leaving PLP supply unchanged.
  4. PlpManager._removeLiquidity() then used the inflated AUM value 4050654353532854041808248 against PLP supply 2056442271258126713127668, so the attacker redeemed the previously acquired PLP for 1962472861668186127414294 USDP worth of claims and withdrew 1956585443083181569032051 USDT.
  5. A second direct sellUSDP converted another 1953430770776852155171335 USDP into 1947570478464521598705820 USDT.

The LiquidityEvent public-access condition was also concrete at the exploit block. Palm's historical LiquidityEvent implementation only enforced a whitelist while block.timestamp <= endWhitelistTime:

function _checkEligible(address account) internal view {
    if (endWhitelistTime == 0) return;
    else if (block.timestamp <= endWhitelistTime) {
        require(isWhitelisted[account], "!whitelist");
    }
}

By block 30248637, endWhitelistTime had already elapsed, so purchasePlp() remained available to any unprivileged caller.

5. Adversary Flow Analysis

The adversary cluster contained EOA 0xf84efa8a9f7e68855cf17eaac9c2f97a9d131366 and helper contract 0x55252a6d50bfad0e5f1009541284c783686f7f25. The EOA submitted the seed transaction and ultimately received the realized USDT profit. The helper contract received the flash loan, executed the Palm call sequence, repaid the loan plus fee, then forwarded surplus USDT to the EOA while retaining leftover USDP.

Operationally, the sequence was:

  1. Borrow 3,000,000 USDT with a public flash loan.
  2. Buy PLP first, while Palm AUM still reflected the lower pre-inflation state.
  3. Inflate AUM with a direct public Vault.buyUSDP() call that increased poolAmount and minted USDP without minting PLP.
  4. Redeem the already-acquired PLP through LiquidityEvent.unstakeAndRedeemPlp(), which calls PlpManager._removeLiquidity() and prices redemption from the inflated AUM.
  5. Sell most of the directly minted USDP through Vault.sellUSDP().
  6. Repay the flash loan plus 2700000000000000000000 USDT premium and keep the remaining value.

The final balance deltas confirm that the adversary realized value instead of merely moving accounting entries:

  • Attacker EOA 0xf84efa8a9f7e68855cf17eaac9c2f97a9d131366: +901455921547703167737871 USDT
  • Helper contract 0x55252a6d50bfad0e5f1009541284c783686f7f25: +40108136663147844828665 USDP
  • Vault 0x806f709558cdbba39699fbf323c8fda4e364ac7a: -904155921547703167737871 USDT

6. Impact & Losses

Palm's vault lost 904155921547703167737871 USDT in a single transaction. The attacker EOA realized 901455921547703167737871 USDT after the flash loan was repaid, and the attacker-controlled helper still retained a positive USDP balance at transaction end. Gas was paid separately in BNB, with the EOA's native balance delta showing -3550824356598799500 wei.

The incident demonstrates that Palm's historical PLP pricing was exploitable by any unprivileged actor whenever the same public conditions held: direct buyUSDP access, zero PLP cooldown, a callable LiquidityEvent path, enough flash liquidity to buy PLP first, and enough remaining collateral to inflate AUM before redeeming.

7. References

  • Seed transaction: 0x62dba55054fa628845fecded658ff5b1ec1c5823f1a5e0118601aa455a30eac9
  • Seed metadata: /workspace/session/artifacts/collector/seed/56/0x62dba55054fa628845fecded658ff5b1ec1c5823f1a5e0118601aa455a30eac9/metadata.json
  • Seed trace: /workspace/session/artifacts/collector/seed/56/0x62dba55054fa628845fecded658ff5b1ec1c5823f1a5e0118601aa455a30eac9/trace.cast.log
  • Seed balance diff: /workspace/session/artifacts/collector/seed/56/0x62dba55054fa628845fecded658ff5b1ec1c5823f1a5e0118601aa455a30eac9/balance_diff.json
  • Historical Palm Vault source: https://bscscan.com/address/0xea625e24a40b07f5094b390257f772580e757055#code
  • Historical Palm PlpManager source: https://bscscan.com/address/0xa68f4b2c69c7f991c3237ba9b678d75368ccff8f#code
  • Historical Palm LiquidityEvent source: https://bscscan.com/address/0x02a4b53926bad267df700ff8e9f5d4a2516bb1fc#code
  • Palm Vault proxy: 0x806f709558CDBBa39699FBf323C8fDA4e364Ac7A
  • Palm PlpManager proxy: 0x6876B9804719d8D9F5AEb6ad1322270458fA99E0
  • Palm LiquidityEvent proxy: 0xd990094A611c3De34664dd3664ebf979A1230FC1