最近爆发的层出不穷的区块链安全问题,从以太坊RPC攻击转移用户资产,到ERC20代币频繁爆出溢出漏洞,这些问题其实大部分应该在区块链系统的设计阶段解决,而不是留给开发者来自己关注安全问题。
我们先来看一下以太坊的设计。首先值得肯定的是,有别于比特币UTXO模型,以太坊设计了账户模型。账户模型对于实现智能合约是十分合理而且非常必要的,直接在UTXO模型上嫁接智能合约的做法显得不伦不类。
然而,以太坊的设计仍然存在不少问题,有许多设计问题实际上是引发安全漏洞的源头。如果在设计时避免了这些问题,至少不会大面积爆发各种安全问题。
以太坊的一个重大安全缺陷是由RPC远程调用引起的。去年以来,黑客利用远程扫描工具或者植入木马,来尝试连接暴露在公网的以太坊节点,然后,不断尝试通过sendTransaction
这个RPC把该节点的所有以太币转移到黑客的地址。
这个操作虽然需要私钥,然而黑客却可以绕开私钥,原因就在于用户正常发送交易时,无论是在钱包输入口令,还是通过web3的JS,都间接调用了geth提供的web3的unlockAccount
命令,这个命令一旦被用户触发,在接下来的一段时间内,黑客的sendTransaction
无需私钥就能成功。并且,由于失败的sendTransaction
不会被写入日志,用户几乎无法发现自己的全节点被黑客盯上了。如果黑客利用木马监听全节点的网络通信,完全可以通过unlockAccount
获取用户口令,而以太坊的RPC是没有任何加密的。
这个安全漏洞实际上完全可以从设计避免。以太坊的钱包仿照了比特币钱包的设计,它实际上把全节点功能和钱包合二为一,用户私钥以加密形式保存在全节点中。中本聪最早设计的这种内置钱包的比特币全节点实际上是有严重安全问题的,但是,以太坊和大部分公链的开发者都照抄了这个设计。当用户进行正常转账时,钱包实际上和全节点的交互如下:
┌───────────┐ ┌─────────────────┐
│ │ │geth (full node) │
│ │unlockAccount(password)│ │
│ │──────────────────────▶│decryptPrivate() │
│ Wallet UI │ │ │
│ │ sendTransaction() │ │
│ │──────────────────────▶│signTransaction()│
│ │ │ │
│ │ │ │
└───────────┘ │broadcast() ─────┼─▶ P2P Network
│ │
│ ┌─────────────┐ │
│ │ Encrypted │ │
│ │ Private Key │ │
│ └─────────────┘ │
└─────────────────┘
用户正常创建交易之前,需要调用unlockAccount
来解锁私钥,然而,这个极其危险而重要的操作本质上是一个RPC调用。黑客能利用sendTransaction
原因就在于,全节点不应该提供任何钱包的功能。全节点工作在P2P和共识层,而钱包工作在应用层,私钥理应由钱包管理,而不是全节点管理。
┌─────────────────┐
│ Wallet UI │
│ │
│inputPassword() │
│ │ ┌────────────────┐
│decryptPrivate() │ │geth (full node)│
│ │ sendRawTransaction() │ │
│signTransaction()│──────────────────────▶│broadcast() ────┼─▶ P2P Network
│ │ └────────────────┘
│ ┌─────────────┐ │
│ │ Encrypted │ │
│ │ Private Key │ │
│ └─────────────┘ │
└─────────────────┘
由钱包管理的加密私钥就不需要暴露在网络上,并且,从用户输入口令,创建交易,签名交易这一过程,根本不需要RPC调用,只有最后一步sendRawTransaction
才需要RPC调用。sendRawTransaction
发送的是待广播的已签名交易,因此,这个数据被黑客截获是没有用的。
虽然比特币和以太坊的全节点提供了disableWallet
这个选项来禁用私钥存储,但是问题在于,全节点根本就不应该提供存储私钥的功能。全节点也不应该提供sendTransaction
,全节点只能提供sendRawTransaction
。有个别公链居然只提供sendTransaction
而不提供sendRawTransaction
,可见其工程实现的安全之差。
以太坊的另一个重大漏洞允许任何用户进行ERC20的超额转账。该漏洞利用原理如下:
转移代币实际上就是调用ERC20合约的transfer(address to, unit256 amount)
方法。该方法一共有两个参数:地址和数量。两个参数实际上都是32字节整数。调用该方法时,用户创建的68字节交易数据如下:
4字节方法哈希,总是a9059cbb
;
32字节以太坊地址,由于以太坊地址总是20字节,因此高位补0,例如:000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcabca
;
32字节代币数量,例如:00000000000000000000000000000000000000000000000000000000000000ff
。
加在一起的交易数据就是:
a9059cbb
000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcabca
00000000000000000000000000000000000000000000000000000000000000ff
然而,以太坊的地址末尾如果是0,用户输入的地址少于20字节时,以太坊会自动给它“补零”。利用以太坊虚拟机的这一“容错性”机制,可以创建一个恶意转账数据:
a9059cbb
000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcab
00000000000000000000000000000000000000000000000000000000000000ff
上述交易数据只有67字节,原因是地址末尾少了一个0字节。然而以太坊虚拟机并不会报错,转账也会成功。更令人惊奇的是,以太坊虚拟机从后面的参数“借”了一个0,然后,在末尾自动补充0,所以,实际参数变成了:
a9059cbb
000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcab00
000000000000000000000000000000000000000000000000000000000000ff00
注意到ff
后面的0是以太坊虚拟机自动补上的,这样一来,amount从ff
变成了ff00
,转账金额扩大了256倍。
通过计算一个末尾带0的地址,黑客就可以对交易所发起攻击。原理是交易所构造的转账交易是按a9059cbb
+用户提现地址
+金额
拼接而成。黑客先填写不足20字节的地址,如果交易所未检查地址长度,黑客通过申请一个ff
金额的提现,实际到账金额是ff00
。
这个锅由以太坊虚拟机来背一点也不冤。检查非法输入是任何高级语言必须提供的基本功能。
最近爆出的ERC20代币的Batch Overflow漏洞看上去是开发者的问题:
function batchTransfer(address[] receivers, unit256 value) {
unit256 amount = receivers.length * value
require(value>0 && balances[msg.sender] >= amount)
...
}
漏洞代码在于计算总额amount = receivers.length * value
时,输入一个非常大的value可能导致计算结果为负数,也就是整数计算溢出,从而导致后续转账成功,黑客凭空为自己转移出天量代币。
当然可以指责开发者没有编写出安全的代码,资深Solidity开发者还会说凡是涉及计算都应该使用SafeMath
这个专为合约开发的计算库。SafeMath
会检查整数计算溢出,例如:
function add(uint256 a, uint256 b) {
uint256 c = a + b;
assert(c >= a);
return c;
}
问题是,以太坊合约是一种非常高级的代码,虚拟机本身理应对所有整数运算自动检查溢出,而不是把责任推给开发者。没有任何人喜欢编写c = add(a, b)
这样的代码。Java虚拟机就通过禁用指针、数组索引检查、运行时类型检查等内置安全机制,有效提升了程序的健壮性。如果以太坊虚拟机内置了整数运算溢出检查,这个微小的工作就足以让95%的合约安全问题不复存在。
类似的问题还包括:合约没有真正的“所有者”,造成合约代码无法升级或者暂停。(目前的暂停机制也是合约逻辑的一部分,而不是以太坊合约机制的一部分)。新的智能合约公链应该在设计时尽量避免潜在的安全问题,从虚拟机上堵住恶意攻击,而不是一味教育开发者编写“安全”的代码。