作者:安比(SECBIT)实验室 & 轻信科技(LedgerGo)
本文以蜜罐合约和 BancorLender 合约为例,详细介绍 Solidity 语言中「未初始化的 storage 指针」问题,并追踪 Solidity 编译器关于此问题的开发进展。
安比(SECBIT)实验室在 BancorLender (0x2d820ea3A6b9302c500feeb7F6361bA1DdfA5aBa) 合约中发现野指针问题(uninitialized-wild-pointer)。该合约中的一个状态变量会意外地被另一个函数修改,偏离原本设计意图。目前项目方不明确。建议项目方应立即废弃该合约,并重新发布修复后的合约。野指针问题是 Solidity 语言的最初设计欠缺考虑,而且 Solidity 编译器为了向前兼容,对这类安全问题仅采取警告提示,而开发者往往又很容易忽视这些提示,最终导致问题代码部署上线。
下面我们通过一个蜜罐例子来解释「未初始化的 storage 指针」这个缺陷。
蜜罐合约:别人看中的是你的本金
在计算机领域,蜜罐(Honeypot)通常指故意伪装成看似有利用价值并故意留有 bug 的系统,用来吸引黑客攻击,从而达到分析、监控、收集证据、拖延攻击等目的。
而以太坊主网上存在这样一类游戏合约:以高额回报为诱饵,并故意露出破绽,让参与者误认为自己有很高的概率可以获胜,诱导参与者转入以太参与游戏而损失本金。通常称这类合约为“蜜罐合约”。
"蜜罐"这个词,其实很形象:罐子里有可口的蜂蜜,吸引着熊去吃,但周边其实有暗藏的陷阱,真正目的是为了抓住熊。
“蜜罐合约”的部署者通常利用各种技巧使代码部分特殊用途不易被参与者发现,利用当中的信息不对称,使参与者产生错误判断,从而被骗取本金。
「未初始化的 storage 指针」正是“蜜罐合约”部署者最常用的一种技巧。这个问题源于 Solidity 语言以及编译器设计上的失误。
我们结合下面这个名为 Honeypot 的简化合约说明。这是一个竞猜合约,参与者调用 guess()
接口,传入 _number
数字进行竞猜,如果猜的数字等于合约中的 luckyNum
,则竞猜成功,参与者可获取两倍回报。
聪明的你可以仔细思考一下,竞猜数字 _number
应该填多少?
终极答案是 42 吗?由于变量 luckyNum
在最开始(第 2 行)被赋值 42,并且没有其他被赋值操作,因此绝大多数人都会猜 42。
然而这个合约极具迷惑性,42 并不是正确答案。到底哪里出了问题?变量 luckyNum
什么时候被修改了?
让我们来理一理:函数 guess()
先把参与者的地址和竞猜数字放入 gameHistory 数组中保存(第 12 ~ 15 行)。而数组 gameHistory
由 Game
结构体(Struct)构成。函数开始先通过 Game game
声明了一个结构体变量 game(第 12 行)
,再分别对成员变量进行赋值(第 13 ~ 14 行),最后将变量 game
塞到 gameHistory
数组中(第 15 行)。
看着“似乎”没毛病。然而,这里有很严重的问题。
传统编程语言中,我们在函数内部申明一个变量,通常默认是局部变量。但 Solidity 在语言设计上埋了个坑,在此处反直觉地默认让引用类型(Reference Type)变量 game
(第 12 行)存储位置为 storage,因此对变量 game
的修改,作用范围是“全局”的。并且对于未初始化的 storage 指针(类似传统语言中的空指针),Solidity 默认其指向 storage 的起始地址,即指向合约开头定义的状态变量(第 2 ~ 3 行)。
变量 luckyNum
值不是 42,那么到底是多少呢?
Solidity 将源码中的状态变量(常量除外),根据一定规则,按照出现顺序依次排列存储在 storage 中。
而 luckyNum
变量正是这个合约中第一个被定义的状态变量,占据了 storage 的开始位置(slot 0x00)。
game.player = msg.sender;
game.number = _number;
因此以上代码中的赋值操作会分别更新 storage slot 0x00 ~ 0x01 上的值,即将 luckyNum
值设为 msg.sender
,将 last
值设为 _number
。
如果参与者猜 42,则会白白丢币。
luckyNum
的正确答案应该是调用者自己的地址。
安比(SECBIT)实验室发现,有很多人会利用 Solidity 语言以及编译器的这种“特性”,再加上其他复杂的干扰条件或故意漏出的破绽,部署“蜜罐”合约欺骗其他人。在大部分案例里,参与者根本无法获胜,而部署者有权限将合约里的币全部转走,并且通常中招者还具备不少智能合约安全常识。
再如另一个名为 OpenAddressLottery 的彩票合约(0x741F1923974464eFd0Aa70e77800BA5d9ed18902),根据参与者的地址“随机”生成一个 0 至 7 间的整数。合约声称任何人均有八分之一的概率中奖而赢走 7 倍于投注金额的以太币,中奖条件为生成的数等于代码中的 LuckyNumber
[2]。
与第一个例子类似,代码中标明了 LuckyNumber
值为 7(第 11 行),并且看上去没有其他方法可以修改该变量。目前以太坊智能合约中很难生成无法预测的随机数(其实这是部署者故意留的破绽)。有智能合约安全知识的人可能会跃跃欲试,利用在其他智能合约中调用的方法来预测随机数,从而获取奖励(不可能的,这辈子都不可能)。
注意 forceReseed()
函数中的 SeedComponents s
(第 16 行),这与前面的问题代码如出一辙,并且该函数只有 owner
才能调用。蜜罐部署者可利用该函数中第 20 行的 s.component4 = tx.gasprice * 7
来修改 LuckyNumber
为想要的任意值,从而使任何人都无法中奖。蜜罐部署者最终利用 selfdestruct()
将合约自毁,并把受害者转入的以太币转出至自己的地址。
类似的蜜罐合约在以太坊主网上存在不少(不完全列表如下),大家牢记这个知识点,千万别中招。
0xd1915A2bCC4B77794d64c4e483E43444193373Fa
0x650734bfd0465b7c6cd2932ea555e721308fd0b3
0x0d83102ec81853f3334Bd2b9E9fcCE7adf96ccC7
0xe6f245bb5268b16c5d79a349ec57673e477bd015
0x787b9a8978b21476abb78876f24c49c0e513065e
0xd4342df2c7cfe5938540648582c8d222f1513c50
0xe19ca313512e0231340e778abe7110401c737c23
0x6324d9d0a23f5ddba165bf8cc61da455350895f2
0xEFba96262F277cC8073dA87e564955666D30a03b0x6a2e025f43ca4d0d3c61bdee85a8e37e81880528
问题合约 BancorLender:从蜜罐到安全漏洞
除了“蜜罐合约”,「未初始化的 storage 指针」问题还会严重影响智能合约代码质量,导致合约代码无法正常执行,甚至留下安全漏洞。
结合 BancorLender 代码具体分析。
BancorLender 合约 offerToLend()
函数中声明了一个结构体(struct)变量 BorrowAgreement agreement
。
显然开发者原本想将 agreement
作为局部变量使用,但未初始化的 storage 指针会指向第 1035 行定义的状态变量 agreements
。
作为由结构体 BorrowAgreement
构成的动态数组,agreements
变量占据了 storage 的开始位置(slot 0x00),并按照动态数组的规则存放在 storage 上。
如果熟悉动态数组在 storage 上的排列方式 [1],则知道 slot 0x00 位置保存的是当前动态数组的大小,即 agreements
中的元素个数,而其他位置则依次保存的是数组中的实际值。
回到上面的问题代码,在这里,slot 0x00 被未初始化的 agreement
storage 指针所指向,因此,问题代码中第 1051 行至 1054 行的赋值操作则会分别更新 storage slot 0x00 ~ 0x03 上的值。也就是说,slot 0x00 处原本存储数组大小的值被设为 msg.sender
。这完全不合情理,使得代码逻辑十分混乱,代码的功能完全无法正常完成,在一些情况下会造成很严重的后果。
那么,这里正确的代码究竟该如何写?
其实很简单,只需给第 1050 行代码,加上 memory
限定,即可标明 agreement
是局部变量,而不会影响到 storage 上的值。
BorrowAgreement memory agreement;
事实上,Solidity 编译器对于这种“常见”错误写法有警告,提示开发者使用关键字 storage 显式标明变量,以及未初始化的 storage 指针(Uninitialized storage pointer)警告。
但是报 warning 并不会影响正常编译,而开发者往往很容易忽略编译器的各种警告提示(而且仅凭少量且模糊的警告信息,开发者并不知道如何正确修改代码),继续部署问题代码进行使用,从而留下极大的安全风险。
Solidity 的 storage 空指针(引用)是一个设计缺陷
在传统编程语言中(如C, C++),对空指针(Null Pointer)的访问,通常会引起程序的报错或崩溃。空指针的值等于零,但是语言和底层系统也同时保证内存中地址为 0
的位置是不能存放有意义的值。而在例如 Java 或者 C# 中有 引用
的概念,但是它们都定义了一个空引用
的值,"null"
。空引用
是一个引用的安全保护值,保证这个引用不会指向任何数据。
但与传统编程语言不同,以太坊智能合约语言 Solidity 中存在 memory 与 storage 的两个数据存储的概念,其中 storage 是一个外部的持久化存储空间,位于区块链上。然而,Solidity 语言却允许定义一个指向外部存储 storage 的指针(引用),这个引用在未初始化的情况下等于 0
,而在 storage 地址为 0
的位置存放着有意义的数据。大家这时候可能已经感觉到哪里不对了,在 Solidity 语言中,竟然允许存在一个没有定义 空引用
状态的数据引用,即一个未初始化的指针会默认指向有意义的数据,如果此时直接对「未初始化的 storage 引用」进行赋值,那么就会错误覆盖合约存储在 storage 上面的状态变量。如果 Solidity在设计初期考虑了 空引用
的值,或者像 C++ 那样禁止定义 空引用
,那么这类问题就能彻底避免。
注:在 Solidity 术语中,引用与指针两个概念并不做区分。
新曙光:Solidity 编译器即将改进升级
编译器把源码编译成字节码的过程中,会对源码进行语法以及安全性检查,并给出各类提示。其中代表有问题的提示级别有 warning 和 error。通常 warning 级别不会影响编译结果,而 error 级别的问题会导致编译器罢工。
追溯 Solidity 编译器开发历程我们发现,早期版本的 Solidity,一直把上文提到的「未初始化的 storage 指针」问题作为 error 处理。2016 年 10 月 15 日,开发者为了修复其他一些问题,将此处的提示级别降为 warning [3]。
此后一直不断有人提 issue,警告不应允许「未初始化的 storage 指针」。而开发团队则一直回应称编译器已提示 warning 信息 [4]。
并解释之所以不提升为 error 是为了兼容部分特殊场景下代码可编译通过。
由于 Solidity 编译器开发团队认为修复该问题可能会带来兼容性问题,于是在今年 3 月份将该问题的修复放到了下一个大版本(0.5.0)。开发者需使用 pragma experimental "v0.5.0"
标记来触发。
普通开发者很少会利用这个实验特性,再加上普遍忽视警告信息,因此以太坊主网上一直部署着不少带有此问题的代码。
好消息是,安比(SECBIT)实验室发现 Solidity 编译器开发团队于 20 多天前往 develop 分支合并了该问题的修复代码,不区分是否是 0.5.0 以上的版本 [4]。也就是说上文中的所有问题代码,不出意外在下一个版本(Solidity 0.4.25)都无法正常通过编译。
安比(SECBIT)实验室同步了最新编译器代码进行验证。
// test1.sol
contract C {
function f() public {
uint[] storage x;
uint[10] storage y;
uint[10] z;
}
}
对于以上问题代码,新版编译器报错如下:
test1.sol:3:3: Error: Uninitialized storage pointer.
uint[] storage x;
^--------------^
test1.sol:4:3: Error: Uninitialized storage pointer.
uint[10] storage y;
^----------------^
明确提示 Error: Uninitialized storage pointer
,无法通过编译。
// test2.sol
contract C {
function f() public {
uint[] x;
}
}
而对于没有显示声明变量存储位置(storage 或 memory)的代码,报错如下:
test2.sol:4:3: Error: Data location must be specified as either "memory" or "storage".
uint[] x;
^------^
同样也无法通过编译。
Solidity 0.4.25 应该很快进入正式发布阶段。
很明显,0.4.24 以来,Solidity 语法上新增了很多更严格的要求,强制要求开发者写出更严谨的合约代码。
案例带来的提示
回顾本文中 Solidity「未初始化的 storage 指针」问题。Solidity 中函数内部声明的引用类型变量默认存储位置为 storage,而未初始化的 storage 指针会指向 storage 的起始地址,从而合约开头定义的若干个状态变量会被覆盖修改。
以此案例为教训,安比(SECBIT)实验室有下列提示:
智能合约开发者需要搞清楚
storage
和memory
等关键词的意义和用法,尽量显示标明智能合约开发者必须重视合约编译过程中的每一个 warning 信息
编译器作为基础工具,设计得当则可在一定程度上杜绝特定安全问题
编译器开发和程序语言设计一定要严谨,从底层设计层面规避因使用者应理解偏差或使用不当带来的风险
我们欣慰地看到,Solidity 语言正变得越来越严谨。有理由相信以太坊 Solidity 开发生态将迎来更大的发展。
参考文献
[1] 动态数组存储讲解, https://medium.com/@hayeah/diving-into-the-ethereum-vm-the-hidden-costs-of-arrays-28e119f04a9b
[2] 蜜罐合约讨论, https://www.reddit.com/r/ethdev/comments/7wp363/how_does_this_honeypot_work_it_seems_like_a/
[3] Use warning function in TypeChecker, https://github.com/ethereum/solidity/commit/0dd75ac100d59d81321d8815638c8f252b2fe467
[4] Uninitialised storage references should not be allowed, https://github.com/ethereum/solidity/issues/1789
[5] Turn uninitialized storage variables into error, https://github.com/ethereum/solidity/pull/4415/files
以上数据均由安比(SECBIT)实验室提供,合作交流请联系[email protected]。
安比(SECBIT)实验室
安比(SECBIT)实验室专注于区块链与智能合约安全问题,全方位监控智能合约安全漏洞、提供专业合约安全审计服务,在智能合约安全技术上开展全方位深入研究,致力于参与共建共识、可信、有序的区块链经济体。
安比(SECBIT)实验室创始人郭宇,中国科学技术大学博士、耶鲁大学访问学者、曾任中科大副教授。专注于形式化证明与系统软件研究领域十余年,具有丰富的金融安全产品研发经验,是国内早期关注并研究比特币与区块链技术的科研人员之一。研究专长:区块链技术、形式化验证、程序语言理论、操作系统内核、计算机病毒。