预防智能合约的漏洞 - 应对意外转入以太币

2022-04-08 14:07:29 浏览数 (1)

原文:https://medium.com/better-programming/how-to-find-vulnerability-in-smart-contracts-unexpected-ether-89f157ce2888 译文出自:登链翻译计划[1] 译者:aisiji[2] 校对:Tiny 熊[3]

破解一个游戏合约了解如何预防攻击

通常,当你发送以太币到合约时,合约会执行 fallback 函数或者其他合约中定义的函数。但本文将介绍两个例外情况,以太币可以存入合约中却不运行任何代码。

这些依赖所转移的以太币数量的合约,在以太币被强制发送时有被攻击的风险。

漏洞

一个典型且有价值的防御编程技术是在强制执行状态转换或者验证操作时进行不变性检查。这个方法包含定义一组不变量(invariant)并且检查它们在一次(或多次)操作后是否发生改变。一个不变量的例子——固定发行的ERC20 token[4]totalSupply是不可变的。因为没有函数可以修改这个变量。

特别注意,有一个典型的不变量,你可能会用到,但其实它很容易被外部用户操控(尽管在智能合约中定义了规则)。这个不变量就是,当前存储在合约中的以太币(this.balance)。通常,开发者第一次学习 Solidity 时,容易误认为合约只能通过 payable 函数接收以太币。这种错误的理解可能导致合约对以太币余额存在错误假设,从而导致各种漏洞。而漏洞的关键就是(不正确)使用了this.balance

有两种方法,可以(强制)将以太币转给没有使用 payable 函数或者没有执行任何代码的合约:

1. 自毁函数(Self-destruct)

每个合约都可以执行selfdestruct函数,这个函数会从合约地址移除所有字节码并将存储在这个地址的所有以太币转移到参数指定的地址。如果这个指定的地址也是一个合约,并不会调用任何函数(包括 fallback 函数)。因此,selfdestruct函数可以强制转移以太币到任何合约,不管这个合约中存在什么代码,甚至根本没有 payable 函数。这就是说,攻击者可以创建一个有selfdestruct的合约,并向其发送以太币,调用selfdestruct(target),从而强制将以太币转移到target合约。

2. 预先转入以太币

另一种将以太币转移到合约的方法,是用以太币预加载合约地址。合约地址是确定的——实际上,这个地址是根据创建合约地址的 Keccak-256(类似 SHA-3)哈希和交易 nonce 计算而来,计算如下:

代码语言:javascript复制
address = sha3(rlp.encode([account_address,transaction_nonce]))

我们探讨一些可能产生的陷阱。看一个非常简单的合约EtherGame.sol

代码语言:javascript复制
contract EtherGame {

    uint public payoutMileStone1 = 3 ether;
    uint public mileStone1Reward = 2 ether;
    uint public payoutMileStone2 = 5 ether;
    uint public mileStone2Reward = 3 ether;
    uint public finalMileStone = 10 ether;
    uint public finalReward = 5 ether;

    mapping(address => uint) redeemableEther;
    // Users pay 0.5 ether. At specific milestones, credit their accounts.
    function play() external payable {
        require(msg.value == 0.5 ether); // each play is 0.5 ether
        uint currentBalance = this.balance   msg.value;
        // ensure no players after the game has finished
        require(currentBalance <= finalMileStone);
        // if at a milestone, credit the player's account
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender]  = mileStone1Reward;
        }
        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender]  = mileStone2Reward;
        }
        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender]  = finalReward;
        }
        return;
    }

    function claimReward() public {
        // ensure the game is complete
        require(this.balance == finalMileStone);
        // ensure there is a reward to give
        require(redeemableEther[msg.sender] > 0);
        uint transferValue = redeemableEther[msg.sender];
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
 }

这个合约是一个简单的游戏(其中涉及竞赛条件),玩家一次向合约转 0.5 个以太币,并希望自己会第一个达到三个 MileStone 中的一个。其 MileStone 是一定量的以太币。第一个达到的玩家可以在游戏结束后分享一部分以太币。当最后一个 MileStone 10 个以太币达到时,玩家就会得到奖励。

这个EtherGame合约代码的 14 行和 32 行的this.balance的用法有问题。攻击者可以通过selfdestruct函数(前面提到过的)强制向该合约发送少量的以太币(如 0.1 以太币),以此阻止将来有玩家达到 MileStone。这个 0.1 以太币的贡献会导致this.balance永远不会是 0.5 以太币的倍数,因为所有合法玩家都只能向合约转 0.5 以太币。这样 18、21、24 行所有的if都不会为 true。

更糟糕的是,错过 MileStone 的攻击者可以强制转 10 个以太币(让合约余额大于或等于finalMileStone),这样就会永久锁定合约中所有的奖金。根据 32 行的条件claimReward函数总是会返回(即,因为this.balancefinalMileStone大)。

如何避免

这种类型的漏洞通常都是由于滥用this.balance导致的。合约逻辑,应该尽可能避免依赖合约余额的精确值,因为合约余额是可以被人为操纵的。如果应用逻辑基于this.balance,你就不得不处理意外转入的余额。

如果要求存储的以太币是一个确切数量,应该自定义变量,在 payable 函数中递增,这样才能安全的追踪存入的以太币。这种变量不会受到调用selfdestruct强制发送以太币的影响。

考虑到这个因素,以下是EtherGame合约的正确版本:

代码语言:javascript复制
contract EtherGame {

    uint public payoutMileStone1 = 3 ether;
    uint public mileStone1Reward = 2 ether;
    uint public payoutMileStone2 = 5 ether;
    uint public mileStone2Reward = 3 ether;
    uint public finalMileStone = 10 ether;
    uint public finalReward = 5 ether;
    uint public depositedWei;

    mapping (address => uint) redeemableEther;

    function play() external payable {
        require(msg.value == 0.5 ether);
        uint currentBalance = depositedWei   msg.value;
        // ensure no players after the game has finished
        require(currentBalance <= finalMileStone);
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender]  = mileStone1Reward;
        }
        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender]  = mileStone2Reward;
        }
        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender]  = finalReward;
        }
        depositedWei  = msg.value;
        return;
    }

    function claimReward() public {
        // ensure the game is complete
        require(depositedWei == finalMileStone);
        // ensure there is a reward to give
        require(redeemableEther[msg.sender] > 0);
        uint transferValue = redeemableEther[msg.sender];
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
 }

在这个版本中,我们创建了一个新变量depositedWei,它会跟踪玩家存入的以太币。请注意不要再用this.balance了。

参考资料

[1]

登链翻译计划: https://github.com/lbc-team/Pioneer

[2]

aisiji: https://learnblockchain.cn/people/3291

[3]

Tiny 熊: https://learnblockchain.cn/people/15

[4]

ERC20 token: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md

0 人点赞