1.重入攻击

原理:类似递归调用,攻击者利用攻击合约的回退函数(在接受eth时自动执行的函数)递归调用目标合约的取款函数,让转账逻辑连续执行多次之后再回退执行余额调整逻辑实现资金盗窃。

攻击函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 发起攻击
function attack() external payable {
require(msg.value >= 1 ether);

// 先存一笔钱
bank.deposit{value: 1 ether}();

// 发起第一次 withdraw
bank.withdraw();
}

// fallback 函数:每次收到 ETH 时自动触发
fallback() external payable {
if (address(bank).balance >= 1 ether) {
attackCount++;
// ⚠️ 再次调用 withdraw,重入!
bank.withdraw();
}

目标合约取款函数:

1
2
3
4
5
6
7
8
9
10
  // ⚠️ 存在重入漏洞的取款函数
function withdraw() external {
require(balance[msg.sender] > 0, "No balance");
// 1. 直接转账(外部调用) —— 危险点
(bool success, ) = msg.sender.call{value: balance[msg.sender]}("");
require(success, "Transfer failed");

// 2. 更新余额 —— 来得太晚(bug)
balance[msg.sender] = 0;
}

这看起来就像:

1
2
3
4
5
6
withdraw()->转账
→ fallback()
→ withdraw()->转账
→ fallback()
→ withdraw()->转账
...

解决办法:

  • 1.检查-影响-交互模式(checks-effect-interaction):先检查再调整余额最后转账
  • 2.重入锁:重入锁是一种防止重入函数的修饰器(modifier),它包含一个默认为0的状态变量_status。被nonReentrant重入锁修饰的函数,在第一次调用时会检查_status是否为0,紧接着将_status的值改为1,调用结束后才会再改为0。这样,当攻击合约在调用结束前第二次的调用就会报错,重入攻击失败。
1
2
3
4
5
6
7
8
9
function withdraw() external {
uint256 balance = balanceOf[msg.sender];
require(balance > 0, "Insufficient balance");
// 检查-效果-交互模式(checks-effect-interaction):先更新余额变化,再发送ETH
// 重入攻击的时候,balanceOf[msg.sender]已经被更新为0了,不能通过上面的检查。
balanceOf[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");
}

2.选择器碰撞

漏洞合约

以下是一个有漏洞的合约例子。SelectorClash合约有1个状态变量 solved,初始化为false,攻击者需要将它改为true。合约主要有2个函数,函数名沿用自 Poly Network 漏洞合约。

  1. putCurEpochConPubKeyBytes() :攻击者调用这个函数后,就可以将solved改为true,完成攻击。但是这个函数检查msg.sender == address(this),因此调用者必须为合约本身,我们需要看下其他函数。
  2. executeCrossChainTx() :通过它可以调用合约内的函数,但是函数参数的类型和目标函数不太一样:目标函数的参数为(bytes),而这里调用的函数参数为(bytes,bytes,uint64)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract SelectorClash {
bool public solved; // 攻击是否成功

// 攻击者需要调用这个函数,但是调用者 msg.sender 必须是本合约。
function putCurEpochConPubKeyBytes(bytes memory _bytes) public {
require(msg.sender == address(this), "Not Owner");
solved = true;
}

// 有漏洞,攻击者可以通过改变 _method 变量碰撞函数选择器,调用目标函数并完成攻击。
function executeCrossChainTx(bytes memory _method, bytes memory _bytes, bytes memory _bytes1, uint64 _num) public returns(bool success){
(success, ) = address(this).call(abi.encodePacked(bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))), abi.encode(_bytes, _bytes1, _num)));
}
}

攻击方法

目标是利用executeCrossChainTx()函数调用合约中的putCurEpochConPubKeyBytes(),目标函数的选择器为:0x41973cd9。观察到executeCrossChainTx()中是利用_method参数和"(bytes,bytes,uint64)"作为函数签名计算的选择器。因此,我们只需要选择恰当的_method,让这里算出的选择器等于0x41973cd9,通过选择器碰撞调用目标函数。

Poly Network黑客事件中,黑客碰撞出的_methodf1121318093,即f1121318093(bytes,bytes,uint64)的哈希前4位也是0x41973cd9,可以成功的调用函数。接下来我们要做的就是将f1121318093转换为bytes类型:0x6631313231333138303933,然后作为参数输入到executeCrossChainTx()中。executeCrossChainTx()函数另3个参数不重要,填 0x, 0x, 0 就可以。

结论:

  1. 函数选择器很容易被碰撞,即使改变参数类型,依然能构造出具有相同选择器的函数。

  2. 管理好合约函数的权限,确保拥有特殊权限的合约的函数不能被用户调用。

3.整形溢出

原理:以太坊虚拟机(EVM)为整型设置了固定大小,因此它只能表示特定范围的数字。例如 uint8,只能表示 [0,255] 范围内的数字。如果给 uint8 类型变量的赋值 257,则会上溢(overflow)变为 1;如果给它赋值-1,则会下溢(underflow)变为255

完整推导图(带借位过程):注意和十进制不同的是借位得到的是2不是10。

如下展示“借不到 → 变成全 1”的过程:

1
2
3
4
  0000 0000
- 0000 0001
------------
1111 1111 ← 下溢后变成最大值

解决办法:

  1. Solidity 0.8.0 之前的版本,在合约中引用 Safemath 库,在整型溢出时报错。

  2. Solidity 0.8.0 之后的版本内置了 Safemath,因此几乎不存在这类问题。开发者有时会为了节省gas使用 unchecked 关键字在代码块中临时关闭整型溢出检测,这时要确保不存在整型溢出漏洞。

4.签名重放

原理:重复使用别人的签名获取权限

数字签名一般有两种常见的重放攻击:

  1. 普通重放:将本该使用一次的签名多次使用。NBA官方发布的《The Association》系列 NFT 因为这类攻击被免费铸造了上万枚。
  2. 跨链重放:将本该在一条链上使用的签名,在另一条链上重复使用。做市商 Wintermute 因为跨链重放攻击被盗2000万枚 $OP。

解决办法:
1.将使用过的签名记录下来,比如记录下已经铸造代币的地址 mintedAddress,防止签名反复使用:

1
2
3
4
5
6
7
8
9
10
11
mapping(address => bool) public mintedAddress;   // 记录已经mint的地址

function goodMint(address to, uint amount, bytes memory signature) public {
bytes32 _msgHash = toEthSignedMessageHash(getMessageHash(to, amount));
require(verify(_msgHash, signature), "Invalid Signer!");
// 检查该地址是否mint过
require(!mintedAddress[to], "Already minted");
// 记录mint过的地址
mintedAddress[to] = true;
_mint(to, amount);
}

2.将 nonce (数值随每次交易递增)和 chainid (链ID)包含在签名消息中,这样可以防止普通重放和跨链重放攻击:

1
2
3
4
5
6
7
8
uint nonce;

function nonceMint(address to, uint amount, bytes memory signature) public {
bytes32 _msgHash = toEthSignedMessageHash(keccak256(abi.encodePacked(to, amount, nonce, block.chainid)));
require(verify(_msgHash, signature), "Invalid Signer!");
_mint(to, amount);
nonce++;
}

3.对于由用户输入signature的场景,需要检验signature的长度,确保其长度为65bytes,否则也会产生签名重放问题。

原因:一个合法的 ECDSA 签名在以太坊上固定就是 65 字节如果长度不是 65,很可能是伪造的或者被截断、拼接、修改过,但某些 EVM 内置恢复函数仍可能恢复出一个地址,从而导致错误执行。

5.坏随机数

原理:很多以太坊上的应用都需要用到随机数,例如NFT随机抽取tokenId、抽盲盒、gamefi战斗中随机分胜负等等。但是由于以太坊上所有数据都是公开透明(public)且确定性(deterministic)的,它没有其他编程语言一样给开发者提供生成随机数的方法,例如random()。很多项目方不得不使用链上的伪随机数生成方法,例如 blockhash()keccak256() 方法。坏随机数漏洞:攻击者可以事先计算这些伪随机数的结果,从而达到他们想要的目的,例如铸造任何他们想要的稀有NFT而非随机抽取。

解决方法:我们通常使用预言机项目提供的链下随机数来预防这类漏洞,例如 Chainlink VRF。这类随机数从链下生成,然后上传到链上,从而保证随机数不可预测。

6.绕过合约长度检查

原理:很多 freemint 的项目为了限制科学家(程序员)会用到 isContract() 方法,希望将调用者 msg.sender 限制为外部账户(EOA),而非合约。这个函数利用 extcodesize 获取该地址所存储的 bytecode 长度(runtime),若大于0,则判断为合约,否则就是EOA(用户)。而合约的构造函数作为智能合约部署阶段的执行入口,此时合约的bytecode还没有初始化,而构造函数也不计入bytecode长度中,所以此时合约的bytecode长度为0,可以顺利通过检测从而实现攻击。
解释bytecode就是存储在evm中的code(字节码),如果是合约那代码长度就>0,如果是用户没有代码,那代码长度就=0,以此来判断账户是外部账户还是合约账户

1
2
3
4
5
6
7
8
9
10
// 利用 extcodesize 检查是否为合约
function isContract(address account) public view returns (bool) {
// extcodesize > 0 的地址一定是合约地址
// 但是合约在构造函数时候 extcodesize 为0
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}

攻击:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 利用构造函数的特点攻击
contract NotContract {
bool public isContract;
address public contractCheck;

// 当合约正在被创建时,extcodesize (代码长度) 为 0,因此不会被 isContract() 检测出。
constructor(address addr) {
contractCheck = addr;
isContract = ContractCheck(addr).isContract(address(this));
// This will work
for(uint i; i < 10; i++){
ContractCheck(addr).mint();
}
}

构造函数和普通函数的区别:

项目 构造函数 普通函数
什么时候执行? 只在部署时执行一次 部署后随时可以被外部调用
是否会再次执行? 绝不会,区块链历史中只有一次执行记录 可以重复调用
是否可在执行后修改? 不可再触发 可随时调用

构造函数不能被外部调用它没有 selector,也不会暴露在 ABI 中。因为它不存在于 runtime bytecode。

构造函数是“部署阶段的执行入口”;
普通函数是“运行阶段的执行入口”。

部署阶段与运行阶段是两个完全不同的世界。构造函数 = 合约出生时执行一次的“初始化脚本”,
它的存在阶段和普通函数完全不在同一个生命周期。这让它能做普通函数做不到的事,也造成一些攻击向量