10|10 安全编程实践

10.1 安全编程概述 本章内容主要参考了consensys关于solidity进行智能合约开发时的最佳安全开发指南(原文链接:https://consensys.github.io/smart-contract-best-practices/),并结合笔者自身项目开发经验和教训进行了相应的调整和补充特此说明和感谢。
虽然以太坊平台作为迄今为止最为成熟的基于区块链的智能合约编程平台并得到大多数人的认可。但区块链领域本身还处于比较早期的阶段,新的功能模块不断被添加同时新的bug和攻击手段也会层出不穷,其面临的安全威胁也是不断变化和升级的。如何用solidity进行智能合约的安全编程是一个值得深入探讨并且时刻处于更新的主题。
【10|10 安全编程实践】用solidity开发链上智能合约和传统软件开发最大的区别就是对安全的重视程度以及发现安全隐患后修复的难度。传统软件如果出现bug可以轻易的通过补丁或者系统升级来修复漏洞,因为业务逻辑层和数据库基本都是分离的而且各自实现时都会采用成熟的中间件产品来加快开发进度和提高安全性。但智能合约如果出现bug其代价往往是高昂的而且都伴随着资金的损失,同时其升级和补丁修复目前还没有统一的行业标准或有效手段。为了减少外部调用依赖甚至连业务数据和业务逻辑往往都耦合在同一个智能合约内部,也给升级智能合约后的数据迁移带来了不小的挑战,可以类比消费类电子产品的嵌入式软件编程。而传统软件系统的开发数据存储都会选择某一种成熟的数据库系统。因此如何用solidity进行智能合约的安全开发不仅意义重大,而且编码实现难度和采用的技巧也需要进行一定的平衡。
传统软件工程开发过程中一般会强调模块化开发,组件重用于以及系统的可升级性。从智能合约安全角度出发以及当智能合约规模到达一定程度后其开发要求也同样如此,特别是在复杂功能合约开发过程中,重用经过第三方严格检查的功能库如zeppelin能大大提高编码效率以及安全性保证。然后由于安全性在智能合约中的重要性排序远高于软件工程标准开发理念,因此智能合约需要在功能固化与可升级、单一庞大合约与模块组件化和重复与重用现成代码之间找一个平衡点。
固化与可升级:智能合约功能具有扩展性会增加程序实现的复杂性并引入潜在的攻击点。但对一些只在特定时间点提供有限功能的智能合约来说,如到期自动失效的社会调查或者预测合约以及bug赏金计划合约等来说,简单和功能固化更高效。
庞大与模块化:一个庞大而且单一的合约将所有状态变量与函数实现都放在同一个合约中,能一定程度上提高代码审查的效率但降低了可读性与模块化划分。同时由于交易发送时有gas限制过于庞大的合约可能发布失败。因此笔者建议当合约功能复杂后还是尽量进行模块化划分,方便调试与发布免得后期再对复杂合约进行功能拆分。但对于测试目的合约来说采用单一合约实现会更简单直接。
重复与重用:solidity中重用代码大致有以下几种方式:通过面向对象的继承或组合方式利用现有代码;引用第三方框架库如zeppelin;拷贝之前部署过的经过实践验证过的合约代码进行重复使用。其中第三种方式是最有效和安全的但却是效率最低的。笔者建议尽量使用前两种方式进行代码重用而不是通过拷贝然后重复实现来完成,在用第三方库的时候记得检查由于库的更新引起的功能不兼容。
10.2 安全编程开发理念 在智能合约开发过程中由于对安全性的重视程度以及区块链编程的特殊性,为了安全有效的进行智能合约编程,业界形成了一些新的开发理念用以提高智能合约的安全性以及当漏洞或错误出现后进行快速补救并尽量避免或减少资金损失。
总结起来有如下几个全新的开发理念:
对错误有所准备:无论你如何小心翼翼的编码错误或漏洞可能最终还是会出现。因此你首先要考虑的是当错误或漏洞出现后,如何提供有效的补救措施来避免或降低损失。一些常见的并且证明可行的方案包括:冻结合约的所有功能、限制转账速度或转账额度等,然后通过其它有效的途径来修复bug或进行功能完善。zeppelin库提供了一些基础的功能性合约来辅助完成诸如断路保护、限制提款速度以及合约状态控制和冻结等操作,可以通过继承这些合约来添加对应支持。
分阶段谨慎发布合约:合约在发布到主网前要经过彻底的测试,尽量在发行发现并修复bug。笔者建议测试流程为先在remix自带vm里面进行初步测试,然后在本地私有链环境进行进一步测试,完了后再部署到公共测试网络如ropsten进行扩大范围测试,当确认没问题后再部署到主网。其中在ropsten公共网络的测试在上线主网时是必不可少的,其它测试环境和流程可以根据功能需求和工期进行灵活安排,并从ropsten公共测试网络开始就提供相应的赏金计划以激励相关的安全研究人员参与。

保持简洁和模块化:用solidity进行智能合约开发过程中对可读写和简洁性的要求远高于对编码技巧的应用。因此在实际的编码实现过程中尽量保持实现逻辑简洁、函数和合约模块化和面向对象分离、利用经过广泛安全测试的第三方功能库而不是自己从头开始编码以及系统中需要去中心化的部分用区块链改造。

保持更新:在有新的漏洞被发现时检查和更新智能合约;将用到的第三方库升级到最新稳定版本;使用最新的安全编程技术和理念。
清楚区块链的特性:由于底层调用指令如call等可以调用目标合约任意函数,并且当合约存在fallback回退函数时在收到资金或无效函数调用时会自动触发,因此很容易形成恶意递归或嵌套调用,在进行资金转账和状态清理时要特别注意。另外由于交易有gas上限区块本身也有gas上限以及栈高度的限制等原因,函数实现体内不适合实现一些需要大量循环或递归调用的操作。区块链上期望得到明确执行时间保证等也是不现实的,不能依赖于此进行一些业务逻辑操作,如根据打包交易的区块号做进一步处理或根据当前交易被打包的区块的时间戳为随机数源。
10.3 安全编程实例 用solidity进行智能合约的安全编程是一个涉及范围很广的话题,另外一个关键是以太坊平台、solidity语言本身以及底层的虚拟机执行环境也处于不断更新和完善,很多bug或者漏洞随着系统的迭代升级就自然消失,截止本书写作完成时solidity最新稳定版本为0.5.13,不过为了和当前最新的zepplin库合约进行兼容,本书代码都采用的是0.5.0的版本,请读者确保你的开发测试环境满足最低版本要求。
由于智能合约具体业务场景可能涉及多方面因而很难进行全覆盖,本章节选一些常见的容易产生bug或漏洞的场景进行分析说明,旨在引导读者在编写智能合约时要避开一些经典的或已知的坑,以免发生安全漏洞或引入不必要的程序BUG。
10.3.1 整数运算
整数运算是智能合约编码过程中常见而不可少的组成部分,但在运算过程中一个常见问题是运算溢出。如果运算产生溢出不管是向上或者向下都会引起未知的资金损失或程序错误。其中一个经典错误就是循环语句中的变量类型溢出,示意代码如下:
contract NumTest { function doTest() public pure returns(uint256) { uint256 total; // err, don't run it, forever loop for(uint8 idx=0; idx< 260; idx++){ total += idx; } return total; } }

由于uint8类型最大值为255因此就形成了死循环。
solidity中整数类型最常见的是uint256类型,虽然其能表示的值范围很大但也会产生溢出BUG,如下是溢出范例:
contract NumTest2 { function doTest() public pure returns(uint256, uint256, uint256) { uint256 max256 = 2**256-1; uint256 zero = 0; return (max256, max256+1, zero-1); } }

从范例运行结果可以看出当最大值加1后会向上溢出变为零;当零值减1后会向下溢出变为最大值。因此在进行整数运算时必须检查其运算结果是否产生了溢出,可以采用类似下面的检查:
c = a + b; assert(c >= a);

如果每次整数运算都进行这样的手工检查运算结果是否溢出是无趣且繁琐的。由于solidity中uint相关的四则运算如此通用,因此zeppelin库提供了运算增强库SafeMath来进行类似的检查。笔者建议凡是在涉及uint的四则运算时都采用此库进行整数的安全运算。然而遗憾的是该库目前只提供uint类型的运算支持暂不支持其它类型,因此其它整数类型的运算还是要手工检查是否溢出或者统一采用uint类型,再次从侧面说明安全性编程重要性远大于技巧的运用和储存空间的节约考虑。
整数运算中另外一个经典问题是运算结果的向下截取。由于目前版本的solidity还不完全支持浮点数,如果要进行高精度运算需要特殊手段实现,一个常见的手段是用乘法因子来辅助实现,示意代码如下:
contract NumTest3 { function doTest() public pure returns(uint256, uint256, uint256) { //round down to 2 not up to 3 uint a = 5; uint b = 2; uint base = 10; return (a/b, a*base/b, base); // (2, 25, 10) } }

直接进行除法运算时返回结果为零;通过乘法因子10来扩展范围,可以得到除法结果为25,但乘法因子为10倍。
10.3.2 断言保护

    推荐阅读