深入解析Ethernaut CTF挑战:智能合约漏洞实战

本文详细解析了Ethernaut CTF中的六个智能合约挑战,包括Fallback函数漏洞、构造函数命名错误、整数下溢、delegatecall风险、强制转账和重入攻击,通过具体代码示例和解决方案展示以太坊智能合约安全的关键问题。

深入解析Ethernaut CTF挑战:智能合约漏洞实战

上周Zeppelin发布了他们的以太坊CTF项目Ethernaut。这个CTF是了解如何与区块链交互和学习智能合约漏洞基础的良好入门。CTF托管在ropsten区块链上,你可以获得免费以太币。浏览器开发者控制台和metamask插件用于与CTF交互。

我有幸成为第一个完成所有挑战的人。以下是我的解决方案。

1. Fallback

这个挑战是第一个,我认为它更像是一个介绍,确保每个人都能使用API。让我们详细看看Solidity智能合约的样子。

挑战描述

合约由一个构造函数和四个函数组成。目标是成为合约的所有者并提取所有资金。

第一个函数是合约的构造函数,因为它与合约同名。构造函数是一个特定函数,仅在合约首次部署时调用一次,之后无法调用。此函数通常用于设置一些参数(这里是所有者的初始贡献)。

1
2
3
function Fallback() {
  contributions[msg.sender] = 1000 * (1 ether);
}

第二个函数contribute()将调用者发送的以太币数量(msg.value)存储在contributions映射中。如果此值大于合约所有者的贡献,则调用者成为合约的所有者。

1
2
3
4
5
6
7
function contribute() public payable {
  require(msg.value < 0.001 ether);
  contributions[msg.sender] += msg.value;
  if(contributions[msg.sender] > contributions[owner]) {
    owner = msg.sender;
  }
}

getContribution()是一个简单的getter:

1
2
3
function getContribution() public constant returns (uint) {
  return contributions[msg.sender];
}

withdraw()允许合约所有者提取所有资金。注意函数签名后的onlyOwner关键字。这是一个修饰符,确保此函数仅由所有者调用。

1
2
3
function withdraw() onlyOwner {
  owner.transfer(this.balance);
}

最后,最后一个函数是合约的fallback函数。如果调用者之前做出了贡献,则可以执行此函数。

1
2
3
4
function() payable {
  require(msg.value > 0 && contributions[msg.sender] > 0);
  owner = msg.sender;
}

Fallback函数

要理解什么是fallback,我们必须了解以太坊中的函数选择器和参数机制。当你在以太坊中调用函数时,你实际上是在向网络发送交易。此交易包含发送的以太币数量(msg.value)和所谓的data,这是一个字节数组。此字节数组保存要调用的函数的id和函数的参数。他们选择使用函数签名的keccak256的前四个字节作为函数id。例如,如果函数签名是transfer(address,uint256),函数id是0xa9059cbb。

如果你想调用transfer(0x41414141, 0x42),数据将是:

1
0xa9059cbb00000000000000000000000000000000000000000000000000000000414141410000000000000000000000000000000000000000000000000000000000000042

在执行过程中,智能合约做的第一件事是使用调度器检查函数id。如果没有匹配,则调用fallback函数(如果存在)。

你可以使用我们的开源反汇编器Ethersplay可视化此调度器:

Ethersplay显示EVM调度器结构

(*) 为简化起见,从源代码中删除了Owner继承。你可以在此处找到solidity文件和运行时字节码:fallback

解决方案

如果我们将所有内容放在一起,我们必须:

  1. 调用contribution以在contributions中放入一些初始值
  2. 调用fallback函数成为合约的所有者
  3. 调用withdraw以获取所有资金

(1) 通过在浏览器的开发者工具控制台中调用contract.contribution({value:1})轻松完成。调用fallback函数(2)的一种简单方法是使用metamask插件直接向合约发送以太币。然后通过调用contract.withdraw()实现(3)。

2. Fallout

挑战描述

这里的目的是成为合约的所有者。

起初,这个合约似乎有一个构造函数和四个函数。但如果我们仔细查看构造函数,我们会发现函数的名称与合约的名称略有不同:

1
2
3
4
5
6
7
8
9
contract Fallout is Ownable {

  mapping (address => uint) allocations;

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

因此,此函数不是构造函数,而是一个经典的公共函数。合约部署后,任何人都可以调用它!

这可能看起来太简单而不像是真正的漏洞,但它是真实的。使用我们的内部静态分析器Slither,我们发现了几个犯此错误的合约(例如,ZiberCrowdsale或PeerBudsToken)!

解决方案

我们只需要调用contract.Fal1out()成为所有者,就是这样!

3. Token

挑战描述

这里我们在合约中获得了20个免费代币。我们的目标是找到一种持有大量代币的方法。

称为transfer的函数允许在用户之间转移代币:

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

起初,函数看起来很好,因为它似乎检查溢出

1
require(balances[msg.sender] - _value >= 0);

通常,当我必须处理算术计算时,我选择简单的方法,使用我们的开源符号执行器manticore来检查是否可以滥用合约。我们最近添加了对evm的支持,这是一个用于审计整数相关问题的非常强大的工具。但在这里,仔细一看,我们意识到_value和balances是无符号整数,意味着balances[msg.sender] - _value >= 0总是为真!

所以我们可以在以下产生下溢:

1
balances[msg.sender] -= _value;

结果,balances[msg.sender]将包含一个非常大的数字!

解决方案

要触发下溢,我们可以简单地调用contract.transfer(0x0, 21)。然后balances[msg.sender]将包含2**256 – 1。

4. Delegation

挑战描述

这里的目的是成为合约Delegation的所有者。

在此合约中没有直接更改所有者的方法。但是,它持有另一个合约Delegate,具有此函数:

1
2
3
function pwn() {
  owner = msg.sender;
}

Delegation的一个特点是在fallback函数中使用delegatecall。

1
2
3
4
5
function() {
  if(delegate.delegatecall(msg.data)) {
    this;
  }
}

挑战的提示非常明确:

delegatecall的使用特别危险,并已在多个历史性黑客攻击中用作攻击向量。使用它,你的合约实际上是在说“这里,-其他合约-或-其他库-,随意处理我的状态”。委托者完全访问你的合约状态。delegatecall函数是一个强大的功能,但也是一个危险的功能,必须极其小心地使用。

请参阅The Parity Wallet Hack Explained文章,了解此想法如何被用于窃取3000万美元的准确解释。

所以这里我们需要调用fallback函数,并在msg.data中放入pwn()的签名,以便delegatecall将在Delegation的状态内执行函数pwn()并更改合约的所有者。

解决方案

正如我们在“Fallback”中看到的,我们必须在msg.data中放入pwn()的函数id;即0xdd365b8b。结果,Delegate.pwn()将在Delegation的状态内被调用,我们将成为合约的所有者。

5. Force

挑战描述

这里我们必须向一个空合约发送以太币。由于没有可支付的fallback函数,直接向合约发送以太币将失败。

有其他方法可以向合约发送以太币而不执行其代码:

  • 调用selfdestruct(address)
  • 指定地址作为挖矿奖励目的地
  • 在合约创建之前向地址发送以太币

解决方案

我们可以创建一个合约,该合约将简单地调用selfdestruct(address)到目标合约。

1
2
3
4
5
6
contract Selfdestruct{
  function Selfdestruct() payable{}
  function attack(){
    selfdestruct(0x..);
  }
}

注意我们使用可支付的构造函数。这样做,我们可以在构造时直接将一些值放入合约中。然后此值将通过selfdestruct发送。

你可以使用Remix浏览器在ropsten上轻松测试和部署此合约。

6. Re-entrancy

挑战描述

最后一个挑战!你必须在合约创建期间向合约发送一个以太币,然后取回你的钱。

合约有四个函数。我们对其中两个感兴趣。

donate让你向合约捐赠以太币,发送的以太币数量存储在balances中。

1
2
3
function donate(address _to) public payable {
  balances[_to] += msg.value;
}

withdraw,第二个函数允许用户检索存储在balances中的以太币。

1
2
3
4
5
6
7
8
function withdraw(uint _amount) public {
  if(balances[msg.sender] >= _amount) {
    if(msg.sender.call.value(_amount)()) {
      _amount;
    }
    balances[msg.sender] -= _amount;
  }
}

以太币通过调用msg.sender.call.value(_amount)()发送。

起初,这里一切看起来都很好,因为发送的值在balances[msg.sender] -= _amount;中减少,并且没有办法在不发送以太币的情况下增加balances。

现在回想一下“Fallback”中解释的fallback函数机制。如果你向包含fallback函数的合约发送以太币,此函数将被执行。这里的问题是什么?你可以有一个fallback函数回调withdraw,因此msg.sender.call.value(_amount)()可以在balances[msg.sender] -= _amount执行之前执行两次!

此漏洞称为重入漏洞,并在臭名昭著的DAO黑客攻击中使用。

解决方案

要利用重入漏洞,你必须使用另一个合约作为代理。此合约将需要:

  • 有一个将调用withdraw的fallback函数
  • 调用donate以在易受攻击的合约中存入以太币
  • 调用withdraw

在我们的not-so-smart-contracts数据库中,你将找到一个利用此漏洞的通用骨架示例。我将把适应此骨架的练习留给读者。

与之前的挑战类似,你可以使用Remix浏览器在ropsten上测试和部署合约。

结论

这个CTF真的很酷。界面使得很容易进入智能合约安全。Zeppelin做得很好,所以感谢他们!

如果你对文章中提到的工具感兴趣,或者你需要智能合约安全评估,请随时联系我们!

如果你喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计