Incident Overview
On September 3, 2024, at 6:23 PM UTC, a sophisticated attacker exploited a reentrancy vulnerability in the Penpie protocol - a yield farming platform built on top of Pendle Finance. Over the course of three transactions across Ethereum and Arbitrum, the attacker drained approximately $27 million worth of crypto assets, including wstETH, sUSDe, agETH, rswETH, and various Pendle-related YT tokens.
Penpie functions as an intermediary layer that allows users to deposit Pendle LP tokens and earn boosted rewards. The platform integrates deeply with Pendle's market infrastructure, trusting Pendle-created markets as valid sources for reward distribution. This trust assumption became the foundation of the exploit.
The smart contract exploited in this attack was outside the scope of Zokyo's original audit. The Penpie team introduced "permissionless pool registration" approximately one year after Zokyo performed its audit. At that time, they hired a separate security firm (AstraSec) to audit only the new registration contracts. Since the exploit resulted from an interaction between contracts audited by two different teams at two different points in time, neither audit caught the cross-contract vulnerability.
Key Addresses and Transactions
The following on-chain identifiers are central to understanding this exploit:
- Attacker address:
0x7a2f4d625fb21f5e51562ce8dc2e722e12a61d1b - Victim contract (Ethereum):
0x6E799758CEE75DAe3d84e09D40dc416eCf713652 - Exploit contract:
0x4aF4C234B8CB6e060797e87AFB724cfb1d320Bb7
The attack was executed across three primary transactions:
// Transaction 1
0x56e09abb35ff12271fdb38ff8a23e4d4a7396844426a94c4d3af2e8b7a0a2813
// Transaction 2
0x42b2ec27c732100dd9037c76da415e10329ea41598de453bb0c0c9ea7ce0d8e5
// Transaction 3
0x7e7f9548f301d3dd863eac94e6190cb742ab6aa9d7730549ff743bf84cbd21d1
Root Cause Analysis
The exploit was made possible by the convergence of two independent design weaknesses:
- Missing reentrancy protection on the
batchHarvestMarketRewards()function in thePendleStakingBaseUpgcontract - Permissionless market registration in Pendle's factory contracts, which Penpie treated as inherently trustworthy
The Vulnerable Function
The core vulnerability resided in PendleStakingBaseUpg.sol. The batchHarvestMarketRewards() function was responsible for iterating through registered Pendle markets, claiming their accumulated rewards, and distributing those rewards to depositors. Critically, this function lacked a nonReentrant modifier.
function batchHarvestMarketRewards(
address[] calldata _markets,
uint256 _minEthToRecieve
) external {
// No nonReentrant modifier - VULNERABLE
for (uint256 i = 0; i < _markets.length; i++) {
address market = _markets[i];
// Snapshot balances BEFORE claiming
uint256[] memory amountsBefore = _getRewardBalances(market);
// External call to market's claimRewards()
// This is where the reentrancy occurs
IPendleMarket(market).redeemRewards(address(this));
// Snapshot balances AFTER claiming
uint256[] memory amountsAfter = _getRewardBalances(market);
// Difference is attributed as "rewards"
for (uint256 j = 0; j < amountsBefore.length; j++) {
uint256 reward = amountsAfter[j] - amountsBefore[j];
// Distribute reward to depositors...
}
}
}
The critical flaw is the pattern of snapshot-before, external-call, snapshot-after. The function measures the "reward" as the difference in token balances before and after calling redeemRewards() on the market. If an attacker can inject tokens into the contract during that external call, the balance delta will be inflated - and the contract will treat the injected tokens as legitimate rewards.
Permissionless Market Registration
The second enabling factor was Pendle's permissionless market creation system. Anyone can call createNewMarket() on the Pendle factory contract to deploy a new market. Penpie's registration helper then allows any Pendle-created market to be registered as a valid pool:
function registerPenpiePool(
address _market
) external {
// Only checks that _market was created by PendleMarketFactoryV3
// Does NOT verify the underlying SY token is legitimate
require(
IPendleMarketFactory(factory).isValidMarket(_market),
"Invalid market"
);
// Creates PenpieReceiptToken and BaseRewardPoolV2
// for the (potentially malicious) market
_registerPool(_market);
}
This meant the attacker could deploy a malicious SY (Standardized Yield) token, use it to create a Pendle market through the official factory, and then register that market with Penpie. The system would treat it as a legitimate pool because it came from Pendle's factory - even though the underlying SY contract was entirely attacker-controlled.
Step-by-Step Attack Flow
The attack unfolded in three distinct phases: setup, exploitation, and extraction.
Phase 1: Malicious Market Setup
- Deploy malicious SY contract: The attacker deployed a crafted SY (Standardized Yield) token contract designed to act as both a yield source and the reentrancy attack vector. This contract's
claimRewards()callback was weaponized to re-enter Penpie's staking contract. - Create fake Pendle Market: Using Pendle's
PendleYieldContractFactory.createYieldContract()andPendleMarketFactoryV3.createNewMarket(), the attacker created a legitimate-looking market backed by the malicious SY token. - Mint PT/YT tokens: The attacker called
PendleYieldToken.mintPY()to mint PT (Principal Token) and YT (Yield Token) for the fake market. - Register with Penpie: The attacker called
registerPenpiePool()to register the fake market with Penpie. The system created a correspondingPenpieReceiptToken(PRT) and aBaseRewardPoolV2rewarder for the malicious market.
// Step 1: Deploy malicious SY token
MaliciousSY maliciousSY = new MaliciousSY();
// Step 2: Create yield contract through Pendle factory
IPendleYieldContractFactory(yieldFactory).createYieldContract(
address(maliciousSY),
expiry,
doCacheIndex
);
// Step 3: Create market through Pendle market factory
address fakeMarket = IPendleMarketFactory(marketFactory).createNewMarket(
PT_address,
scalarRoot,
initialAnchor,
lnFeeRateRoot
);
// Step 4: Register the fake market in Penpie
IPendleMarketRegisterHelper(registerHelper).registerPenpiePool(
fakeMarket
);
Phase 2: Reentrancy Exploitation
- Flash loan: The attacker obtained flash loans from Balancer Vault, borrowing large quantities of agETH, rswETH, egETH, and wstETH.
- Deposit into fake SY: The borrowed tokens were deposited into the malicious SY contract, giving it control over significant token balances.
- Trigger
batchHarvestMarketRewards(): The attacker calledbatchHarvestMarketRewards()on the Penpie staking contract, passing the fake market as one of the target markets. - Reentrancy via
redeemRewards(): When the staking contract calledredeemRewards()on the fake market, the malicious SY contract's callback fired. Inside this callback, the attacker re-entered thedepositMarket()function. - Inflate balances: During the reentrant call, the attacker deposited flash-loaned tokens as liquidity into real Pendle markets, received genuine LP tokens, and deposited those LP tokens back into Penpie pools. The malicious SY contract's
getRewardTokens()function was crafted to return these LP tokens as "reward tokens." - Balance delta manipulation: When execution returned to
batchHarvestMarketRewards(), theamountsAftersnapshot reflected the inflated balances. The contract calculated a massive "reward" delta and attributed it entirely to the attacker (who was the sole depositor in the fake market).
contract MaliciousSY is IStandardizedYield {
IPendleStaking penpieStaking;
address[] rewardTokens;
bool reentered;
// Called by Penpie during batchHarvestMarketRewards
function claimRewards(
address /*user*/
) external returns (uint256[] memory) {
if (!reentered) {
reentered = true;
// Re-enter: deposit flash-loaned tokens
// into legitimate Pendle markets
_addLiquidityToRealMarkets();
// Deposit resulting LP tokens into Penpie
penpieStaking.depositMarket(
realMarket,
address(this),
lpAmount
);
}
return new uint256[](0);
}
// Returns LP tokens as "reward tokens"
function getRewardTokens()
external view returns (address[] memory)
{
return rewardTokens; // Points to real LP tokens
}
}
Phase 3: Fund Extraction
- Claim inflated rewards: The attacker called
multiclaim()on theMasterPenpiecontract to withdraw the "rewards" - which were actually real LP tokens that had been injected during the reentrancy. - Convert PRT to market tokens: The attacker called
withdrawMarket()to convert PenpieReceiptTokens back into the underlying market LP tokens. - Redeem and repay: LP tokens were redeemed from legitimate Pendle markets back into the original assets (wstETH, sUSDe, agETH, rswETH). Flash loans were repaid, and the attacker retained the profit.
The depositMarket() function had a nonReentrant modifier, but batchHarvestMarketRewards() did not. Because these functions used different reentrancy locks (or the harvesting function had none at all), the attacker could re-enter depositMarket() from within batchHarvestMarketRewards() without triggering the guard.
Stolen Assets Breakdown
The attacker drained approximately $27 million in assets across Ethereum and Arbitrum. The primary stolen tokens included:
- wstETH - Wrapped staked Ether (Lido)
- sUSDe - Staked USDe (Ethena)
- agETH - Kelp DAO restaked ETH
- rswETH - Restaked Swell ETH
- egETH - EigenLayer restaked ETH
- Various Pendle YT (Yield Token) positions
Following the exploit, approximately 10,110 ETH (worth roughly $24.7 million) was consolidated at address 0x2f2d...1C39. The attacker subsequently laundered the funds through Tornado Cash in batches of 100 ETH, completing the transfer of approximately 11,261 ETH ($27 million total) within five days of the exploit.
Why the Vulnerability Was Not Caught
This exploit illustrates a critical challenge in DeFi security: the gap between sequential audits and evolving codebases.
Audit Scope Fragmentation
The Penpie protocol underwent its initial audit with Zokyo, which covered the core staking and reward distribution logic at a time when pool registration was admin-controlled. Approximately one year later, the Penpie team introduced permissionless pool registration and engaged a separate firm (AstraSec) to audit only the new registration system.
The critical gap: neither audit examined the interaction between the new permissionless registration and the existing reward harvesting logic. The vulnerability did not exist in either codebase in isolation - it only manifested when the two systems interacted.
Timeline of Events:
[Year 1] Zokyo audits Penpie core contracts
- Pool registration: admin-only (registerPool)
- batchHarvestMarketRewards: safe (only trusted markets)
- No reentrancy risk from trusted admin-registered pools
[Year 2] Penpie introduces permissionless pool registration
- AstraSec audits ONLY the new registration contracts
- Existing staking/harvesting contracts NOT re-audited
- New trust assumption: any Pendle market is valid
[Exploit] Attacker combines both systems:
- Registers malicious market (new system)
- Exploits reward harvesting (old system)
- Neither audit scope covered this interaction
The Trust Boundary Shift
When pool registration was admin-controlled, the batchHarvestMarketRewards() function operated under a reasonable trust assumption: all registered markets were vetted by the team. The lack of a nonReentrant modifier was not a critical risk because the markets calling back into the contract were known entities.
When registration became permissionless, this trust boundary silently shifted. The harvesting function now interacted with arbitrary, potentially malicious markets - but its code was never updated to reflect this new threat model. The redeemRewards() call became an unguarded external call to an attacker-controlled contract.
The Fix
The immediate remediation is straightforward. Adding a nonReentrant modifier to batchHarvestMarketRewards() would have prevented the exploit entirely:
function batchHarvestMarketRewards(
address[] calldata _markets,
uint256 _minEthToRecieve
) external nonReentrant { // <-- Added reentrancy guard
for (uint256 i = 0; i < _markets.length; i++) {
// ... same logic, now protected
}
}
However, the deeper fix involves re-evaluating the trust model. Additional defensive measures include:
- Whitelisting markets: Only allow admin-approved markets to participate in reward harvesting, even if registration is permissionless
- Validating reward tokens: Verify that
getRewardTokens()returns expected tokens, not arbitrary addresses controlled by the market deployer - Global reentrancy locks: Use a single, contract-wide reentrancy guard rather than per-function modifiers, preventing cross-function reentrancy
- Checks-effects-interactions pattern: Complete all state updates before making external calls, regardless of reentrancy guards
Post-Incident Response
Immediately after the exploit was detected, the Pendle Finance team paused all protocol operations, successfully protecting approximately $70 million in additional assets that could have been at risk. The Penpie team engaged multiple security partners for incident response and fund tracking:
- Hypernative - Real-time threat detection
- Binance Security Team - Exchange-level fund tracking
- SlowMist - On-chain investigation
- Chainalysis - Transaction tracing and compliance
Despite these efforts, the attacker did not respond to on-chain messages requesting fund return. Within one week, the entirety of the stolen funds had been laundered through Tornado Cash in sequential 100 ETH batches. The PNP governance token dropped approximately 40% in the immediate aftermath.
Lessons Learned
This exploit carries several important takeaways for protocol teams and security auditors alike:
1. Re-audit When Trust Assumptions Change
Any modification that changes who can interact with a contract - such as moving from admin-only to permissionless access - constitutes a fundamental shift in the security model. The entire codebase that relies on the old trust assumption must be re-evaluated, not just the new code.
2. Cross-Contract Interactions Are the Highest-Risk Surface
The vulnerability did not exist within any single contract. It emerged from the interaction between the registration system and the reward harvesting system. Audits must explicitly examine cross-contract call chains, especially when contracts have been audited by different teams at different times.
3. Reentrancy Guards Should Be Applied Broadly
Any function that makes an external call to an address that could be attacker-controlled must have reentrancy protection. The fact that depositMarket() had a nonReentrant modifier but batchHarvestMarketRewards() did not created an asymmetric defense that the attacker exploited. A global reentrancy lock across all state-mutating functions would have prevented this.
4. Permissionless Does Not Mean Trustless
Allowing anyone to create markets is a valid design choice for composability. But the system that consumes those markets must treat them as untrusted input. The balance-delta pattern for reward calculation is inherently vulnerable when the "reward source" is attacker-controlled, because the attacker can inject arbitrary tokens between the before and after snapshots.
5. Flash Loans Amplify Every Vulnerability
Without flash loans, the attacker would have needed significant capital to inflate the reward balances. Flash loans reduced the attack cost to near zero while enabling $27 million in extraction. Any reentrancy or oracle manipulation vulnerability should be evaluated under the assumption that the attacker has access to effectively unlimited capital through flash loans.
Conclusion
The Penpie exploit is a textbook case of how individually secure components can create catastrophic vulnerabilities when composed together under changed assumptions. The original staking contract was safe under admin-only registration. The new registration system was safe in isolation. But when combined, the permissionless registration opened an attack vector in the reward harvesting logic that neither audit scope covered.
For protocol teams, the lesson is clear: security is not a point-in-time exercise. Every change to access control, trust boundaries, or external integrations requires a holistic re-evaluation of the entire system's security properties. For auditors, this incident reinforces the need to map complete trust chains across all contracts in scope, especially where external calls delegate control to potentially adversarial addresses.
At Zokyo, this incident has reinforced our commitment to comprehensive audit scoping that explicitly includes cross-contract interaction analysis, trust boundary documentation, and follow-up review recommendations whenever client codebases evolve post-audit.