Pre-Mortem is a weekly series by Kostas Ferles, CTO of AuditHub. This one is about a reentrancy that a careful reader would struggle to spot, and the detector output that makes it obvious. New breakdown every week. Find Kostas at @KFerles.
Veridise’s Auditor’s Take has the full account of this hack. We strongly recommend reading it before this one. What follows is a short summary.
Penpie is a yield generating protocol built on top of Pendle. Pendle runs the markets, and each market is built around a Standardized Yield token, or SY, which standardizes the yield-bearing assets and holds them. Liquidity providers for a Pendle market deposit their LP tokens into Penpie. Penpie collects the rewards those markets pay for the LP tokens and routes them back to the depositors. Deposits for the same market are grouped into a pool, and Penpie’s staking contract holds the tokens for every pool. Collecting those rewards is called harvesting.
During a harvest, Penpie determines how much yield a market generated. It reads its own balance of the reward token, which the SY names, using balanceOf. It calls redeemRewards on the market to collect, then reads the balance again. Penpie treats the difference as that market’s rewards. Anyone can start the sequence by calling the external function batchHarvestMarketRewards.
From May 2024, Penpie offered permissionless market registration, which let anyone register a market with it. An attacker registered a market of their own with Pendle, built around an SY they had written. That market named as its reward token an LP token Penpie already accepted as a deposit in a different pool. When the harvest called out to collect, control eventually reached the attacker’s code in the SY. From there the attacker deposited that same LP token into Penpie, while Penpie was midway through measuring the difference. The deposit raised the balance being measured, so Penpie treated the inflated amount as rewards and paid it out. More than $27 million left across Ethereum and Arbitrum in September 2024.
TL;DR
- Penpie handed control outside the protocol to collect rewards, so reentrancy is expected
- More dataflows than anyone enumerates, and their impacts are harder to spot
- The hard part is seeing which reachable code can mess with the calculation
- Most reentrancy findings report the pattern; this one reports the whole picture
- An LLM derived the exploit from Vanguard’s finding, and not from the other
Why This Is Easy to Overlook
External calls are the bread and butter of on-chain code, so their presence is not a red flag on its own. Two things about them are. The first is a state update that lands after the call. The second is handing control to code nobody has vetted. A call into another function of the same protocol, or into a standard contract like an ERC20 token, raises neither.
The harvest makes both kinds of call. balanceOf goes to a reward token, which is ordinary. redeemRewards goes to the market, and the market forwards it down to an SY that Penpie does not control. That is the call that hands over control, and it is the only one that does.
So the reentrancy here is not an oversight. Penpie hands control outside itself deliberately, because collecting the rewards requires it. The question that matters is harder to answer by reading. Of everything attacker code can do while it holds control, is there anything that changes a value this calculation uses afterwards?
Answering that means reasoning about several things at once, which is difficult. Which paths transfer control to code an attacker controls? Which of those touch state the current calculation depends on? You also have to notice where that state lives. Here it is distributed. The balance being measured sits in a token contract, in a different file from the code doing the measuring.
Underneath all of it sits a trust assumption. When the code was written, Penpie only added markets it had approved, so it knew what would happen once it handed control over to collect. Nothing about the calculation is wrong under that assumption. When registration opened up more than a year later, the assumption stopped holding, and no line in the harvest changed to reflect it. The code stayed correct for a deployment that no longer existed. That is what makes this class of bug hard to identify by reviewing code manually.
What the Detector Reports
Many static analyzers have a reentrancy detector. The difference lies in what those detectors report.
A reviewer or QA team has to reconstruct all of the above by hand, which means reasoning across a broad range of things. A static analyzer should make that easier by showing the impact clearly, not only the pattern.
This is Vanguard’s finding on the vulnerable contract. Paths are shortened for readability, otherwise it is the raw output.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
⚠️ An attacker can use the external call to redeemRewards(address) @ PendleStakingBaseUpg._harvestBatchMarketRewards @ pendle/PendleStakingBaseUpg.sol:772:13 to trigger a reentrancy attack The attacker can call the following external functions to reach this call: - PendleStakingBaseUpg.batchHarvestMarketRewards Variables read before the external call: - ERC20._balances accessed from ERC20.balanceOf @ @openzeppelin/contracts/token/ERC20/ERC20.sol:102:16 - BaseRewardPoolV2.isRewardToken accessed from BaseRewardPoolV2.queueNewRewards @ rewards/BaseRewardPoolV2.sol:279:14 A reentrancy attack can call the following functions to modify contract state: - ERC20.transfer - PendleStakingBaseUpg.depositMarket - PendleStakingBaseUpg.harvestMarketReward Variables that are read after they are updated by a reentrant external call: - ERC20._balances accessed from ERC20.balanceOf @ @openzeppelin/contracts/token/ERC20/ERC20.sol:102:16 - PendleStakingBaseUpg.pools accessed from PendleStakingBaseUpg._harvestBatchMarketRewards @ pendle/PendleStakingBaseUpg.sol:794:21 |
A reviewer can take in the big picture in one snapshot. The details the finding gathers are spread across several contracts, which is exactly what makes them hard to hold in view at once. Where does control flow leave, and whose code does it reach? What was read before it left? What can that code call while it holds control? What gets read again afterwards, having changed in between?
The answers line up into the exploit. ERC20._balances appears in the read-before list and again in the read-after-it-was-updated list, so the calculation depends on a value the attacker could have manipulated. Between them sits the function that manipulation runs through, and depositMarket is named there.
The remaining entries are other flows caught in the same pass. You do not have to read them to see that this one is exploitable.
For anyone already familiar with the code, a finding like that shows the impact very clearly. What it adds is that a specific reachable function moves the specific value the reward calculation rests on.
Picking the Risky Flows Out of the Haystack
A protocol of any size has more dataflows running between its functions and contracts than anyone will enumerate. All of them should be intended, and the protocol should hold up against an attacker producing ones that are not. Looking at dataflows is already a significant part of any review. The difficulty is that some are hard to spot, and their impact harder still.
This detector reports the flows that matter: the ones where an attacker can manipulate state, and where later actions depend on what was manipulated. That keeps the report short enough to work through.
The difference that filtering makes is measurable. We ran the default reentrancy detector of a standard static analyzer against the same contract. We fed each full report to a language model in a clean session, with nothing but the source code and the report, and asked for a triage.
Here is the triage of the standard analyzer’s finding.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Reentrancy exposure in batch harvesting - pendle/PendleStakingBaseUpg.sol:747 - batchHarvestMarketRewards() is not nonReentrant and invokes external markets, converters, reward pools, and bribe managers. - The reported path updates lastHarvestTime before external calls, reducing duplicate-harvest risk, and most dependencies are configured/trusted. No direct theft was proven from the [standard analyzer] trace. - Recommendation: add nonReentrant to batchHarvestMarketRewards() and/or the internal execution path; keep state updates before external calls. |
And the triage of Vanguard’s.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
PendleStaking cross-contract reentrancy Location: pendle/PendleStakingBaseUpg.sol:330 batchHarvestMarketRewards() is externally callable and lacks nonReentrant, unlike harvestMarketReward(), depositMarket(), withdrawMarket(), and emergencyWithdraw(). It performs multiple external calls, including: - IPendleMarket(...).redeemRewards(...) - reward-token interactions - configured bribe/rewarder calls - ETHZapper.swapExactTokensToETH(...) A malicious or compromised configured market/reward token/rewarder could reenter during harvesting and invoke other staking functions. The active pool and helper configuration are owner-controlled, so this is not an immediately exploitable permissionless attack under trusted deployment assumptions, but the missing guard is real and should be fixed. Recommendation: add nonReentrant to batchHarvestMarketRewards(). |
Both reach the same first observation and both end on the same recommendation. They agree on that much, and it is worth saying plainly.
Everything between those two points diverges. The first triage settles on lastHarvestTime, notes that most dependencies are configured and trusted, and closes with no direct theft proven. That is a report that is easy to dismiss. It names a variable that had nothing to do with the hack and tells you the dependencies are fine.
The Vanguard triage clearly identifies the reward-token interactions and the configured rewarder calls, which is the path the attacker used. It states that a malicious or compromised market or reward token could reenter during harvesting and invoke other staking functions. It scopes its own caveat precisely too: not immediately exploitable under trusted deployment assumptions. Those assumptions are exactly what later stopped holding.
The source code was identical in both sessions. The difference was whether the finding carried enough structure to make the impact clear.
Why It Keeps Working After the First Run
Once a team has been through the flagged flows and confirmed which are real, later commits get measured against the same shape. A new integration, or a new token a function trusts without checking, can complete a flow that was previously harmless. That arrives as a new finding, rather than as something nobody thought to look for.
Penpie is the case in point, though not in the way you might assume. The harvest was reviewed and was correct against the assumptions in force at the time. What later broke those assumptions happened elsewhere, more than a year on.
The finding above does not depend on any of that. It fires on the harvest as written, whether market registration is closed or open, because it never granted the external call any trust to begin with. It would have been sitting in the report from the first run, well before anything changed.
One limit worth stating plainly. This runs on your repository through your GitHub or GitLab integration. It does not watch a dependency you do not own and tell you when its permissions change. That is continuous security over your own code, which is the part you control.
Want this caught before it ships?
The finding above took a reentrancy that spans several contracts and put the read, the call, and the function that changes the balance in one place. Vanguard produces it from source, with nothing deployed and nothing running. Connect your repository and it runs on every commit.