联盟链智能合约安全浅析
作者:极光@知道创宇404区块链安全研究团队
时间:2020年8月27日
前言
随着区块链技术的发展,越来越多的个人及企业也开始关注区块链,而和区块链联系最为紧密的,恐怕就是金融行业了。 然而虽然比特币区块链大受热捧,但毕竟比特币区块链是属于公有区块链,公有区块链有着其不可编辑,不可篡改的特点,这就使得公有链并不适合企业使用,毕竟如果某金融企业开发出一个区块链,无法受其主观控制,那对于它的意义就不大。因此私有链就应运而生,但私有链虽然能够解决以上的问题,如果仅仅只是各个企业自己单独建立,那么还将是一个个孤岛。如果能够联合起来开发私有区块链,最好不过,联盟链应运而生。
目前已经有了很多的联盟链,比较知名的有Hyperledger
。超级账本(Hyperledger)是Linux基金会于2015年发起的推进区块链数字技术和交易验证的开源项目,加入成员包括:IBM、Digital Asset、荷兰银行(ABN AMRO)、埃森哲(Accenture)等十几个不同利益体,目标是让成员共同合作,共建开放平台,满足来自多个不同行业各种用户案例,并简化业务流程。
为了提升效率,支持更加友好的设计,各联盟链在智能合约上出现了不同的发展方向。其中,Fabric
联盟链平台智能合约具有很好的代表性,本文主要分析其智能合约安全性,其他联盟链平台合约亦如此,除了代码语言本身
的问题,也存在系统机制安全
,运行时安全
,业务逻辑安全
等问题。
Fabric智能合约
Fabric的智能合约称为链码(chaincode),分为系统链码和用户链码。系统链码用来实现系统层面的功能,用户链码实现用户的应用功能。链码被编译成一个独立的应用程序,运行于隔离的Docker容器中。 和以太坊相比,Fabric链码和底层账本是分开的,升级链码时并不需要迁移账本数据到新链码当中,真正实现了逻辑与数据的分离,同时,链码采用Go、Java、Nodejs语言编写。
数据流向
Fabric链码通过gprc与peer节点交互
(1)当peer节点收到客户端请求的输入(propsal)后,会通过发送一个链码消息对象(带输入信息,调用者信息)给对应的链码。
(2)链码调用ChaincodeBase里面的invoke方法,通过发送获取数据(getState)和写入数据(putState)消息,向peer节点获取账本状态信息和发送预提交状态。
(3)链码发送最终输出结果给peer节点,节点对输入(propsal)和 输出(propsalreponse)进行背书签名,完成第一段签名提交。
(4)之后客户端收集所有peer节点的第一段提交信息,组装事务(transaction)并签名,发送事务到orderer节点排队,最终orderer产生区块,并发送到各个peer节点,把输入和输出落到账本上,完成第二段提交过程。
链码类型
- 用户链码
由应用开发人员使用Go(Java/JS)语言编写基于区块链分布式账本的状态及处理逻辑,运行在链码容器中, 通过Fabric提供的接口与账本平台进行交互
- 系统链码
负责Fabric节点自身的处理逻辑, 包括系统配置、背书、校验等工作。系统链码仅支持Go语言, 在Peer节点启动时会自动完成注册和部署。
部署
可以通过官方 Fabric-samples
部署test-network
,需要注意的是国内网络环境对于Go编译下载第三方依赖可能出现网络超时,可以参考 goproxy.cn 解决,成功部署后如下图:
语言特性问题
不管使用什么语言对智能合约进行编程,都存在其对应的语言以及相关合约标准的安全性问题。Fabric 智能合约是以通用编程语言为基础,指定对应的智能合约模块(如:Go/Java/Node.js)
- 不安全的随机数
随机数应用广泛,最为熟知的是在密码学中的应用,随机数产生的方式多种多样,例如在Go程序中可以使用 math/rand 获得一个随机数,此种随机数来源于伪随机数生成器,其输出的随机数值可以轻松预测。而在对安全性要求高的环境中,如 UUID 的生成,Token 生成,生成密钥、密文加盐处理。使用一个能产生可能预测数值的函数作为随机数据源,这种可以预测的数值会降低系统安全性。
伪随机数是用确定性的算法计算出来自[0,1]均匀分布的随机数序列。 并不真正的随机,但具有类似于随机数的统计特征,如均匀性、独立性等。 在计算伪随机数时,若使用的初值(种子)不变,这里的“初值”就是随机种子,那么伪随机数的数序也不变。在上述代码中,通过对比两次执行结果都相同。
通过分析rand.Intn()的源码,可见,在”math/rand” 包中,如果没有设置随机种子, Int() 函数自己初始化了一个 lockedSource 后产生伪随机数,并且初始化时随机种子被设置为1。因此不管重复执行多少次代码,每次随机种子都是固定值,输出的伪随机数数列也就固定了。所以如果能猜测到程序使用的初值(种子),那么就可以生成同一数序的伪随机数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
fmt.Println(rand.Intn(100)) // fmt.Println(rand.Intn(100)) // fmt.Println(rand.Float64()) // 产生0.0-1.0的随机浮点数 fmt.Println(rand.Float64()) // 产生0.0-1.0的随机浮点数 jiguang@example$ go run unsafe_rand.go 81 87 0.6645600532184904 0.4377141871869802 jiguang@example$ go run unsafe_rand.go 81 87 0.6645600532184904 0.4377141871869802 jiguang@example$ |
- 不当的函数地址使用
错误的将函数地址当作函数、条件表达式、运算操作对象使用,甚至参与逻辑运算,将导致各种非预期的程序行为发生。比如在如下if语句,其中func()
为程序中定义的一个函数:
1 2 3 |
if (func == nil) { ... } |
由于使用func
而不是func()
,也就是使用的是func
的地址而不是函数的返回值,而函数的地址不等于nil
,如果用函数地址与nil
作比较时,将使其条件判断恒为false
。
- 资源重释放
defer 关键字可以帮助开发者准确的释放资源,但是仅限于一个函数中。 如果一个全局对象中存储了大量需要手动释放的资源,那么编写释放函数时就很容易漏掉一些释放函数,也有可能造成开发者在某些条件语句中提前进行资源释放。
- 线程安全
很多时候,编译器会做一些神奇的优化,导致意想不到的数据冲突,所以,只要满足“同时有多个线程访问同一段内存,且其中至少有一个线程的操作是写操作”这一条件,就需要作并发安全方面的处理。
- 内存分配
对于每一个开发者,内存是都需要小心使用的资源,内存管理不慎极容易出现的OOM(OutOfMemoryError),内存泄露最终会导致内存溢出,由于系统中的内存是有限的,如果过度占用资源而不及时释放,最后会导致内存不足,从而无法给所需要存储的数据提供足够的内存,从而导致内存溢出。导致内存溢出也可能是由于在给数据分配大小时没有根据实际要求分配,最后导致分配的内存无法满足数据的需求,从而导致内存溢出。
1 2 |
var detailsID int = len(assetTransferInput.ID) assetAsBytes := make([]int, detailsID) |
如上代码,assetTransferInput.ID
为用户可控参数,如果传入该参数的值过大,则make内存分配可能导致内存溢出。
- 冗余代码
有时候一段代码从功能上、甚至效率上来讲都没有问题,但从可读性和可维护性来讲,可优化的地方显而易见。特别是在需要消耗gas执行代码逻辑的合约中。
1 2 3 4 5 6 |
if len(assetTransferInput.ID) < 0 { return fmt.Errorf("assetID field must be a non-empty") } if len(assetTransferInput.ID) == 0 { return fmt.Errorf("assetID field must be a non-empty") } |
运行时安全
- 整数溢出
不管使用的何种虚拟机执行合约,各类整数类型都存在对应的存储宽度,当试图保存超过该范围的数据时,有符号数就会发生整数溢出。
涉及无符号整数的计算不会产生溢出,而是当数值超过无符号整数的取值范围时会发生回绕。如:无符号整数的最大值加1会返回0,而无符号整数最小值减1则会返回该类型的最大值。当无符号整数回绕产生一个最大值时,如果数据用于如 []byte(string),string([]byte) 类的内存拷贝函数,则会复制一个巨大的数据,可能导致错误或者破坏堆栈。除此之外,无符号整数回绕最可能被利用的情况之一是用于内存的分配,如使用 make() 函数进行内存分配时,当 make() 函数的参数产生回绕时,可能为0或者是一个最大值,从而导致0长度的内存分配或者内存分配失败。
智能合约中GetAssetPrice函数用于返回当前计算的差价,第228可知,gas + rebate
可能发生溢出,uint16表示的最大整数为65535,即大于这个数将发生无符号回绕问题:
1 2 3 4 5 6 7 |
var gas uint16 = uint16(65535) var rebate uint16 = uint16(1) fmt.Println(gas + rebate) // 0 var gas1 uint16 = uint16(65535) var rebate2 uint16 = uint16(2) fmt.Println(gas1 + rebate2) // 1 |
- 除数为零
代码基本算数运算过程中,当出现除数为零的错误时,通常会导致程序崩溃和拒绝服务漏洞。
在CreateTypeAsset
函数的第64行,通过传入参数appraisedValue
来计算接收资产类型值,实际上,当传入参数appraisedValue
等于17时,将发生除零风险问题。
- 忽略返回值
一些函数具有返回值且返回值用于判断函数执行的行为,如判断函数是否执行成功,因此需要对函数的返回值进行相应的判断,以 strconv.Atoi
函数为例,其原型为: func Atoi(s string) (int, error)
如果函数执行成功,则返回第一个参数 int;如果发生错误,则返回 error,如果没有对函数返回值进行检测,那么当读取发生错误时,则可能因为忽略异常和错误情况导致允许攻击者引入意料之外的行为。
- 空指针引用
指针在使用前需要进行健壮性检查,从而避免对空指针进行解引用操作。试图通过空指针对数据进行访问,会导致运行时错误。当程序试图解引用一个期望非空但是实际为空的指针时,会发生空指针解引用错误。对空指针的解引用会导致未定义的行为。在很多平台上,解引用空指针可能会导致程序异常终止或拒绝服务。如:在 Linux 系统中访问空指针会产生 Segmentation fault 的错误。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func (s *AssetPrivateDetails) verifyAgreement(ctx contractapi.TransactionContextInterface, assetID string, owner string, buyerMSP string) *Asset { .... err = ctx.GetStub().PutPrivateData(assetCollection, transferAgreeKey, []byte(clientID)) if err != nil { fmt.Printf("failed to put asset bid: %v\n", err) return nil } } // Verify transfer details and transfer owner asset := s.verifyAgreement( ctx, assetTransferInput.ID, asset.Owner, assetTransferInput.BuyerMSP) var detailsID int = len(asset.ID) |
- 越界访问
越界访问是代码语言中常见的缺陷,它并不一定会造成编译错误,在编译阶段很难发现这类问题,导致的后果也不确定。当出现越界时,由于无法得知被访问空间存储的内容,所以会产生不确定的行为,可能是程序崩溃、运算结果非预期。
系统机制问题
- 全局变量唯一性
全局变量不会保存在数据库中,而是存储于单个节点,如果此类节点发生故障或重启时,可能会导致该全局变量值不再与其他节点保持一致,影响节点交易。因此,从数据库读取、写入或从合约返回的数据不应依赖于全局状态变量。
- 不确定性因素
合约变量的生成如果依赖于不确定因素(如:本节点时间戳)或者某个未在账本中持久化的变量,那么可能会因为各节点该变量的读写集不一样,导致交易验证不通过。
- 访问外部资源
合约访问外部资源时,如第三方库,这些第三方库代码本身可能存在一些安全隐患。引入第三方库代码可能会暴露合约未预期的安全隐患,影响链码业务逻辑。
业务逻辑安全
- 输入参数检查不到位
在编写智能合约时,开发者需要对每个函数参数进行合法性,预期性检查,即需要保证每个参数符合合约的实际应用场景,对输入参数检查不到位往往会导致非预期的结果。如近期爆出的Filecoin测试网
代码中的严重漏洞,原因是 transfer
函数中对转账双方 from, to
地址检查不到位,导致了FIL无限增发。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
### Before func (vm *VM) transfer(from, to address.Address, amt types.BigInt) aerrors.ActorError { if from == to { return nil } ... } ### After func (vm *VM) transfer(from, to address.Address, amt types.BigInt) aerrors.ActorError { if from == to { return nil } fromID, err := vm.cstate.LookupID(from) if err != nil { return aerrors.Fatalf("transfer failed when resolving sender address: %s", err) } toID, err := vm.cstate.LookupID(to) if err != nil { return aerrors.Fatalf("transfer failed when resolving receiver address: %s", err) } if fromID == toID { return nil } ... } |
- 函数权限失配
Fabrci智能合约go代码实现中是根据首字母的大小写来确定可以访问的权限。如果方法名首字母大写,则可以被其他的包访问;如果首字母小写,则只能在本包中使用。因此,对于一些敏感操作的内部函数,应尽量保证方法名采用首字母小写开头,防止被外部恶意调用。
- 异常处理问题
通常每个函数调用结束后会返回相应的返回参数,错误码,如果未认真检查错误码值而直接使用其返回参数,可能导致越界访问,空指针引用等安全隐患。
- 外部合约调用引入安全隐患
在某些业务场景中,智能合约代码可能引入其他智能合约,这些未经安全检查的合约代码可能存在一些未预期的安全隐患,进而影响链码业务本身的逻辑。
总结
联盟链的发展目前还处于项目落地初期阶段,对于联盟链平台上的智能合约开发,项目方应该强化对智能合约开发者的安全培训,简化智能合约的设计,做到功能与安全的平衡,严格执行智能合约代码安全审计(自评/项目组review/三方审计)
在联盟链应用落地上,需要逐步推进,从简单到复杂,在项目开始阶段,需要设置适当的权限以防发生黑天鹅事件。
REF
[1] Hyperledger Fabric 链码
https://blog.51cto.com/clovemfong/2149953
[2] fabric-samples
https://github.com/hyperledger/fabric-samples
[3] Fabric2.0,使用test-network
https://blog.csdn.net/zekdot/article/details/106977734
[4] 使用V8和Go实现的安全TypeScript运行时
https://php.ctolib.com/ry-deno.html
[5] Hyperledger fabric
https://github.com/hyperledger/fabric
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1317/