科普 | 深处的蚁穴:与 Gas 相关的三种安全问题

Ajian   |     |   3965 次阅读

编者注:一段时间以来,一些开发者认为,可以做些改进,让用户可以用其它代币而不是 ETH 来支付 Gas,从而改进用户体验。本文第三小节就指出了在具体实现中会遇到的问题,以及解决方案。

-图片来源:MahkeoUnsplash-

每一笔记载到以太坊区块链上的交易都需要耗费不小的运算量。而 Gas 就是运算量大小的计量单位,也是支付相关费用的数量单位。用户往往认为 Gas 徒增困惑和烦恼,开发者则会从优化成本的角度来考虑这个问题。

但作为一名智能合约审计师,我通常认为 Gas 是一个潜在的 攻击向量(译者注:即可能导致安全漏洞的环节)。本文中我将解释 Gas 可能导致安全漏洞的三种方式。其中第三种我看别人都还没有写过。

发交易者付 Gas 费

关于以太坊上的交易,最基本的事实就是:Gas 费用(即交易手续费)是由 交易发送方 来支付的——也就是签名该交易的账户来支付。

这里就潜藏着一个攻击向量,因为:

  1. 一个攻击者可以诱使你发出交易,并且
  2. 他们可以让这笔交易耗费大量的 Gas

第一个条件不难达成。如果我在中心化交易所(例如:Coinbase)里有个账户,我可以要求交易所转账到我指定的地址。交易所的标准实现就包含了从自己的账户中发送交易,但这样做的时候,他们就要支付 Gas 费用。

要满足第二个条件对智能合约开发者来说也是小菜一碟。而智能合约里可以写入任意代码、对发过来的交易作出回应。下面这个简单 智能合约,在收到 ETH 转账时可以消耗掉大量 Gas。

pragma solidity 0.5.1;
contract GasBurner {
    uint256 counter;
    function() external payable {
        for (uint256 i = 0; i < 100; i++) {
            counter += 1;
        }
    }
}

交易发送者会指定该笔交易可以使用 Gas 用量上限,也就所谓的“交易 Gas Limit”。Gas Limit 通常是模拟交易并观察 Gas 用量之后自动确定的,如果一个交易所真是这样自动确定 Gas Limit 的,他们就会因为这种攻击而损失大笔资金。

延伸阅读

上面那段代码只会消耗掉 Gas,毫无意义,但我们可以把它转变为更有用的合约。也许攻击者的合约可以拿这些 Gas 来作更有用的用途。正如 Level K 近来说的那样,过量的 Gas 的一大用途是铸造 GasToken,这些 token 是可以转手卖出的。

缓解措施

要使自己免受这种攻击,请确保自己在交易中给出的 Gas Limit 是合理有度的。

凡超上限,必有所伤

因为以太坊上的计算资源是有限的,单个区块可用的 Gas 是有个上限的,这就是所谓的“区块 Gas 上限”(译者注:交易和区块的 Gas 用量上限英文都为 Gas Limit,翻译时有意区分)。矿工要收集交易并打包到区块里,因为矿工们可以得到 Gas 费用,所以他们会尽可能高效利用区块的 Gas 空间(即让交易的 Gas Limit 总和尽可能接近区块 Gas 上限)(译者注:只是理论上如此,实际上矿工的打包策略非常多样)。本文写作时,以太坊主网的 Gas 上限约为 800 万。一笔交易的 Gas 耗用量如果大于这个上限,是根本没法被打包的。这可以成为一个拒绝服务式攻击向量——攻击者可以让一个合约没办法再运行。下面就是一个有漏洞合约的例子。

pragma solidity 0.5.1;
contract TerribleBank {
    struct Deposit {
        address depositor;
        uint256 amount;
    }
    Deposit[] public deposits;

    function deposit() external payable {
        deposits.push(Deposit({
            depositor: msg.sender,
            amount: msg.value
        }));
    }

    function withdrawAll() external {
        uint256 amount = 0;
        for (uint256 i = 0; i < deposits.length; i++) {
            if (deposits[i].depositor == msg.sender) {
                amount += deposits[i].amount;
                delete deposits[i];
            }
        }

        msg.sender.transfer(amount);
    }
}

如果 deposit 数组足够长, withdrawAll() 就再也没法被调用了,因为这样的一笔交易在一个区块里都塞不下。而且攻击者很容易实现这一点——只需不断调用 deposit(),直至达到所需的数组长度。然后,合约中所有的 ETH 都会被锁住。

当然,你也可以对整个区块链系统制造一场拒绝服务式攻击——用你的交易塞爆区块。只要你的交易给出的 Gas 价格足够高,理性的矿工就会打包攻击者的交易。

延伸阅读

SWC-128, “对区块 Gas 上限的 DoS 攻击”,讲清楚了这种攻击的整个类型。

Grech 等人写的“MadMax:在以太坊智能合约中 Gas” 是近期来自 OOPSLA’18 的一篇学术论文,他们尝试测算出有多少智能合约面临这种超过区块 Gas 上限的安全威胁。

FOMO3D 的奖池,价值几百万美元,最后也是被某个人用这样的“区块堵塞攻击”成功夺取。

缓解措施

智能合约审计师一看到 for 循环就会紧张起来。要尽可能避免使用这种循环,除非你对迭代次数用比较小的常数做了简直。

要发动区块堵塞攻击是很贵的,因此,若要不被贼惦记,你设计的合约应该最小化固定结束时间的经济影响。比如,拍卖通常要设定接受投标的结束时间,那么一次区块堵塞攻击就可以阻止人们去投标。请保证在这个系统中拍卖的东西不会有高到让区块堵塞攻击有利可图的价值。

中继者代理问题

最后我要解释的一类漏洞,目前还没看到有人写过。

作为规则“发交易者付 Gas 费”的变通方案,我已经看到近期许多关于所谓“元交易(meta transaction)”的讨论。一笔元交易非常像一笔交易,但可以由第三方来中继(relay)。该第三方中继者才是实际上发出交易的账户,因此 Gas 费用是由他们来支付的。

具体的实现方式是通过签名和代理合约。用户为他们的元交易签名并广播出去。收到元交易的任何人都可以把该笔交易中继给代理合约。只要消息中包含了一个有效的签名,代理合约就会执行指定的调用。而中继者往往会(以代币而非 ETH)的形式得到报酬。

在这种解决方案中,中继者变成了一种罕见的攻击向量。作为交易的实际发送者,他们可以指定交易的 Gas Limit。只要给出的 Gas 太少,他们就可以让交易失败。如果他们有足够的激励来做这件事,这就会成为一个问题,正如在下面这个合约中所示的那样:

pragma solidity 0.5.1;
contract IERC20 {
    function transfer(address target, uint256 amount) external returns (bool);
}
contract RelayProxy {
    address owner = msg.sender;
    uint256 nonce = 0;
    IERC20 token = IERC20(0x...);
    function execute(
        address payable target,
        bytes calldata data,
        uint256 _nonce,
        uint8 v,
        bytes32 r,
        bytes32 s
    )
        external
    {
        uint256 startGas = gasleft();
        require(_nonce == nonce, "Bad nonce.");
        bytes32 h = hash(target, data, _nonce);
        require(ecrecover(h, v, r, s) == owner, "Bad signature.");
        (bool success, ) = target.call(data);
        if (success) {
            nonce += 1;
        }
        // pay relayer for consumed gas in tokens
        require(token.transfer(msg.sender, startGas - gasleft()));
    }

    function hash(
        address target,
        bytes memory data,
        uint256 _nonce
    )
        internal
        pure
        returns (bytes32)
    {
        return keccak256(abi.encodePacked(target, data, _nonce));
    }
}

Nonce 可以用来防止重放攻击,但请注意,只有当调用成功的时候,Nonce 才会增加。一个恶意的中继者可以不断调整交易的 Gas Limit,导致调用反复失败。系统每一次执行中继者的调用,无论中继者实际用了多少 Gas,他们都可以得到报酬。

缓解措施

一种颇有吸引力的解决方案是无论调用成功与否都增加 Nonce,但这样又会增加另一个攻击向量:每当用户广播他们的元交易时,恶意中继者可以如常中继并导致交易失败,但这时候 Nonce 已经增加了,用户需要签署一笔新的元交易,然后这个过程又再次上演。

另一种方案是如果调用失败就回滚交易,但这就意味着即便调用是因为合理的原因失败的,你也没法补偿中继者了。

其实,这个问题的根本在于中继者是可以决定交易 Gas Limit 的,因此,最好的办法是锁定 Gas Limit、保证在签名的信息中就包含了 Gas Limit 并检查代理合约是否按此 Limit 执行。Status’s relay proxy 就是采取的这种方法。

Christian Lundqvist 的合约“简单多签名钱包” 则主要靠让用户在签名消息中指定中继者来缓解这个问题,这意味着恶意中继者一开始就没法参与进来。

总结

不对 Gas 机制上点心有可能搞出严重的智能合约漏洞。这些问题通常不会在代码中直接显现出来,但真的很要命。


原文链接: https://medium.com/consensys-diligence/silent-but-vulnerable-ethereum-gas-security-concerns-adadf8bfb180
作者: Steve Marx
翻译: 阿剑


你可能还会喜欢:

干货 | Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-6:拒绝服务、时间戳攻击
引介 | 什么是轻客户端,为什么我非得在乎它?
观点 | 以太坊, FOMO3D ,危险的博弈游戏

 
0 人喜欢