Qtum研究院:悲剧为何不断重演?4个维度全面解析智能合约漏洞

    xiaoxiao2022-07-02  96

    *本文仅代表作者个人观点

     

    编者的话:频繁发生的智能合约安全事件使得人们对于智能合约技术安全现状产生了极大的质疑,这也影响了目前区块链技术的普及与发展。本文从四个维度对当下的智能合约技术漏洞进行分析,浅谈漏洞原因及编码建议。

     

    Qtum开发团队在设计中,UTXO模型与EVM虚拟机通过Qtum特有的AAL连接起来,(回顾:深度解析Qtum量子链账户抽象层(Qtum AAL))使得solidity智能合约可以直接适用于Qtum。因此Qtum量子链智能合约给区块链带来了更好的拓展性,可以在区块链上运行更多更复杂的代码逻辑,并保存正确的结果于链上。且智能合约的基本操作对象就是链上的原生代币和地址,以及发行的各种Qrc20和Qrc721等token,这些token带有很强的经济价值。

     

    合约被盗的消息自其技术发明以来未曾间断,大到知名交易所小到许多项目方,其中最著名的The DAO事件,黑客成功获取超过360万个以太币,导致当时以太币价格从20多美元直接跌破13美元。The DAO之后,合约漏洞引发的资产盗取也偶有发生,可以预见,以后我们会遇到更多的类似攻击,所以智能合约的安全问题备受大家的关注。

    本文将列举一部分典型的solidity本身存在的问题,这些问题容易被忽略且易于被攻击,并针对这些问题给出相应的建议。

     

    NO.1 随机数

    公链世界中的数据是共享的,不管采取何种挖矿方式(PoW或者PoS等)来更新区块,其最终状态都是可以计算的,这也就意味着区块链生态系统中不存在随机数seed,在solidity设计中也不存在rand()函数来产生随机数。

     

    近期很火爆的Dapp大部分是菠菜类型,这意味着它们需要很强的随机性来实现赌局,但solidity却不支持随机数。有一些合约的设计者想到使用未来的块变量,如区块哈希值,时间戳,区块高度或是Gas上限作为随机的来源,这是有很大的安全漏洞的,其最大的问题就是这些量都是由挖矿的矿工控制的,因此并不是真正随机的。

     

    比如我们根据下一个块的hash作为随机对象,赌局参与者压奇偶来进行对赌,则一个大的矿池可以押注一个偶数,然后他们再计算出hash是奇数的块时不广播该区块,而率先广播偶数hash的区块,只要测算好成本和成功率,大矿工和矿池有一定几率可以控盘该赌博游戏。此外,仅使用块变量意味着伪随机数对于一个块中的所有交易都是相同的,所以攻击者可以通过在一个块内进行多次交易来使收益倍增。

    如何解决?

    对于区块链随机数,现在并没有很好地特例解决方案,我们只能使得随机性从区块链之外来获得的,这样才能保证其随机的公平性。比如我们可以通过接入一个中心化的高信誉实体的随机系统,该系统充当一个随机数的提供者,通过区块链来抵押资产和发放奖励。

     

    NO.2 重入漏洞

    solidity合约的一个特点是可以调用已经部署在区块链网络中的合约的代码,由于合约可以处理链上代币,这种攻击就产生了。

     

    合约中存在一个fallback()函数,它是合约里的特殊函数,没有名字,不能有参数,没有返回值,当我们给合约地址直接转账时,我们没有向合约发送任何函数调用,则此时会触发fallback()函数,所以一次攻击可以在fallback()中设置一些恶意操作从而执行进一步的代码逻辑实现作恶。The DAO主要就是因重入攻击而损失大量代币。

     

    如何解决?

    当合约直接转账至未知地址时,可能会发生重入攻击,攻击者可以在 fallback()函数中的外部地址处构建一个包含恶意代码的合约。因此,当合约向此地址转账时,它将调用恶意代码。通常,恶意代码会在易受攻击的合约上执行一个函数、该函数会运行一项开发人员不希望的操作。

    contract EtherStore { uint256 public withdrawalLimit = 1 ether; mapping(address => uint256) public lastWithdrawTime; mapping(address => uint256) public balances; function depositFunds() public payable { balances[msg.sender] += msg.value; } function withdrawFunds (uint256 _weiToWithdraw) public { require(balances[msg.sender] >= _weiToWithdraw); // limit the withdrawal require(_weiToWithdraw <= withdrawalLimit); // limit the time allowed to withdraw require(now >= lastWithdrawTime[msg.sender] + 1 weeks); require(msg.sender.call.value(_weiToWithdraw)()); balances[msg.sender] -= _weiToWithdraw; lastWithdrawTime[msg.sender] = now; } }

    这是被攻击的合约,它的两个函数功能如下:

    depositFunds()功能只是增加用户余额

    withdrawFunds()功能允许用户指定要撤回的代币数量

    我们来看攻击合约:

    import "EtherStore.sol"; contract Attack { EtherStore public etherStore; // intialise the etherStore variable with the contract address constructor(address _etherStoreAddress) { etherStore = EtherStore(_etherStoreAddress); } function pwnEtherStore() public payable { // attack to the nearest ether require(msg.value >= 1 ether); // send eth to the depositFunds() function etherStore.depositFunds.value(1 ether)(); // start the magic etherStore.withdrawFunds(1 ether); } function collectEther() public { msg.sender.transfer(this.balance); } // fallback function - where the magic happens function () payable { if (etherStore.balance > 1 ether) { etherStore.withdrawFunds(1 ether); } } }

     

    攻击者通过使用EtherStore合约地址作为构造函数的输入参数来创建Attack.sol,在合约中将公共变量etherStore指向受攻击的合约,攻击者调用其中的pwnEtherStore()函数,在该合约中存入大于1的代币,我们假设是10。

     

    我们可以模拟一下攻击合约与被攻击合约的运行情况:

    - 1:调用pwnEtherStore()后,EtherStore的 despoitFunds() 函数将会被调用,并伴随1代币的 msg.value(和大量的 Gas),我们假设攻击合约的地址为(0x0...123),则此时EtherStore中会记录balance[0x0...123] = 1

    - 2:Attack合约调用 EtherStore中的withdrawFunds()函数,此时合约中有余额,且从未提款,则判断条件全部通过

    - 3:合约发送1代币给Attack合约,此时将处罚Attack.sol的fallback()函数

    - 4:fallback()函数判断到EtherStore合约地址的余额大于1,则调用其withdrawFunds()函数,此时实现了重入EtherStore合约

    - 5:由于我们使用了address.call.value()来发送代币,则此时还未抛出异常,同时还未进行到后面的变量改变,则此时balance[0x0...123] = 1,提币时间也未更新,重入成功,将一直发送代币到Attack合约中

    - 6:当EtherStore.sol中代币少于1时,fallback()内部条件判断失败,执行EtherStore的剩余代码,更新余额和提币时间,但此时重入攻击已经结束,被攻击合约中的余额已少于1

     

     

    我们现在可以在编码时刻意的避免这样的问题发生:

    - 1:在发送代币时采用内置的transfer()函数,此时将只附带少量的gas,使得即使是攻击合约,也没有足够的gas来多次调用合约,从而避免重入攻击

    - 2:确保所有改变状态变量的逻辑发生在代币被发送出合约(或任何外部调用)之前,即将任何对未知地址执行外部调用的代码,放置在本地化函数或代码执行中作为最后一个操作,例如上述合约,若将内部的变量(余额和提币时间)的更改放到转账之前,也可以避免重入攻击的发生

    - 3:引入互斥锁,添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用。

     

    NO.3 短地址攻击该攻击涉及到给合约函数传递参数时的ABI编码规范。以地址参数为例,我们给函数传递地址参数时,需要将其转为hexed形式,标准为40个十六进制字符,但我们可以发送只有36位十六进制的地址,此时EVM会将0填到编码参数的末尾以补成预期的长度,这就对一些第三方调用函数带来了可攻击的漏洞。

    如何解决?

    我们以锁仓钱包提币为例,当用户申请Qrc20 token提币时,并未验证输入的地址是否合法,则会出现短地址攻击。我们来考虑Qrc20代币的转账接口:

    function transfer(address to, uint tokens) public returns (bool success);

     

    若用户提交100个提币申请到一个地址,则钱包要根据transfer()函数参数要求对参数进行编码:

    a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000000000000000000000000000000000056bc75e2d63100000

     

    前四个字节(a9059cbb)是 transfer()函数签名/选择器,第二个32字节是地址,最后32个字节是表示代币数量的uint256 。

     

    若我们现在发送一个丢失 1 个字节(2 个十六进制数字)的地址,即以

    0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde

    作为地址发送(缺少最后两位数字),并取回相同的 100 个代币。如果钱包没有验证这个输入,它将被编码为以下格式:

    a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde0000000000000000000000000000000000000000000000056bc75e2d6310000000

     

    此时 00 已经被填充到编码末尾,此时发送到链上智能合约的编码将被错误解析:address 参数将被读为

    0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00

    并且数量将被读为 56bc75e2d6310000000(多了两个0),代表25600个代币。若钱包中多余25600个token,则会取出25600个token到被补全的地址中,此时将发生token盗取。注:攻击者可以很容易产生末尾是0的地址。

     

    所有输入发送到区块链之前对其进行验证可以防止该类型的攻击。同时参数排序在这里起着重要的作用,ABI编码的填充只发生在字符串末尾,因此智能合约中参数的合理排序可以杜绝此类攻击的发生。

     

    NO.4 高gas竞争

    产生这一漏洞的原因是原生代币作为gas存在的缺陷:矿工总会选择打包gas较高的交易来获得更高的回报和收益。

    如何解决?

    矿工在挖矿时选择将交易池中的哪些交易包含在该区块中,一般来说是根据交易的gasPrice来排序,第一种攻击类型(可重入攻击)在向被攻击合约发起调用时就需要很高的gas来确保每一步交易都能被矿工打包进区块。同样的,若一个合约的需求是获得一个需要高度计算的结果,并对该结果提供高额的悬赏,此时攻击者可以不必花费大量的计算来获得正确结果,而只需要监测交易池,看看其中是否存在问题的解,然后攻击者获得答案并验证,若通过则构建一笔极高gasPrice的交易,使得自己的交易抢在原始交易之前被打包到一个区块中,这时候就出现了盗取他人计算结果来获取利益的攻击方案。

    著名的ERC20代币标准的approve()函数就存在该风险:

    function approve(address _spender, uint256 _value) returns (bool success)

     

    该函数让用户可以授权其他用户代表他们转移代币。若A授权B可转移100token,但A在某一时刻后悔并想修改该授权,则A需要创建一笔交易,比如改为B可转移50token,则B可以检测该交易,交易出现后B构建交易将100token花掉,并设置很高的gasprice使得自己的交易可以优于A的交易,更快的被打包,则最终,B总共拥有了150token的使用权。

     

    可以采用的一种方法是在合约中创建逻辑,设置gasPrice的上限。这可以防止用户增加gasPrice并因超出上限而获得优先的交易排序。但这种预防措施只能缓解普通用户的攻击概率,若矿工盯上这种交易,并发动攻击,他们是可以随意选择交易打包的,所以在合约层面很难杜绝矿工本身作恶。

     

    总结

    上述只是总结了一些比较不易察觉的容易被攻击的漏洞类型,还有很多solidity语言本身的问题,比如精度不足,存储空间昂贵而设计的多种大小的变量、早期初始化函数易出现非命名风险(早期的solidity构造函数是与合约同名的函数,容易因修改合约名称而出现构造函数实现产生大量权限问题)等。

     

    现在市面上也出现了很多标准化合约,比如OpenZeppelin的很多标准库,其中的科学数学计算以及变量精度处理都做得十分出色,大家在写合约时可以充分的借鉴甚至直接继承其中的功能,来避免漏洞的出现 。

     

    参考文献

    [1] ERC20 漏洞剖析 https://vessenes.com/the-erc20-short-address-attack-explained/

    [2] Solidity 安全 https://blog.sigmaprime.io/solidity-security.html

    [3] Solidity 官方文档 https://solidity.readthedocs.io/en/latest/index.html

    最新回复(0)