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

  • aisiji
  • 更新于 2022-03-01 09:35
  • 阅读 2768

破解一个游戏合约了解如何预防攻击 —— 不要再用this.balance

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

1_SuJOoiVOZuh25jY0A37GDw 图片来源: Stillness InMotion

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

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

漏洞

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

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

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

1. 自毁函数(Self-destruct)

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

2. 预先转入以太币

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

address = sha3(rlp.encode([account_address,transaction_nonce]))

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

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合约的正确版本:

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
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
aisiji
aisiji
江湖只有他的大名,没有他的介绍。