比原链开发笔记 : 如何打造一个安全可持续运行的区块链系统

首页 > 观点 >正文

【摘要】本文对历史上出现的重要一些漏洞进行案例分析,归纳出注意事项。比原链类似比特币采用UTXO和POW共识模型,又加入了图灵完备的智能合约系统,研究历史的教训对我们大有裨益,当然也希望其他链的开发者在设计自己的区块链可以避免出现类似的漏洞,以及可以安全地进行版本升级。

专栏作者     长铗  ·  2017-08-04 09:46

金评媒(https://www.jpm.cn)编者按:如何让故障概率降到最小,同时在故障生产时如何快速隔离问题,这是每个区块链的设计和开发者需要认真考虑。

最近BitcoinCash(BCC)的问世,以及以太坊Parity钱包发生15万个以太币被盗事件, 说明无论是漏洞还是版本升级引起的分叉,区块链系统都面临着被攻击的危险。包括之前出现的DAO事件,交易所失窃事件,一系列的问题都可能让区块链投资者备受财务和精神双重损失。

据历史统计,即使是最安全的比特币区块链, 运行过程都大概出现了数十次的BUG, 软件系统产生问题不可避免,但如何让故障概率降到最小,同时在故障生产时如何快速隔离问题,这是每个区块链的设计和开发者需要认真考虑。

本文对历史上出现的重要一些漏洞进行案例分析,归纳出注意事项。比原链类似比特币采用UTXO和POW共识模型,又加入了图灵完备的智能合约系统,研究历史的教训对我们大有裨益, 当然也希望其他链的开发者在设计自己的区块链可以避免出现类似的漏洞,以及可以安全地进行版本升级。

1、非图灵完备脚本系统的攻击

比特币的交易数据结构是由很多脚本操作码构成的,攻击者设计了多种交易结构类型使用这些操作码对节点进行拒绝服务攻击
比如0.3.5版本之前的比特币系统允许攻击者使用OP_LSHIFT脚本进行拒绝服务攻击。

switch (opcode)
       ......
   case OP_LSHIFT:
     if (bn2 < bnZero)
       return false;
     bn = bn1 << bn2.getulong();
     break;

这是0.3.2版本中script.cpp中描述OP_LSHIFT的代码。OP_LSHIFT是一个数值运算的操作码,功能是bn2.getulong()左移bn1位并保留符号位得到bn。攻击者可能采用位运算让输出溢出的方式使进程崩溃,从而达到拒绝服务攻击的效果。

if (opcode == OP_CAT ||
   opcode == OP_SUBSTR ||
   opcode == OP_LEFT ||
   opcode == OP_RIGHT ||
   opcode == OP_INVERT ||
   opcode == OP_AND ||
   opcode == OP_OR ||
   opcode == OP_XOR ||
   opcode == OP_2MUL ||
   opcode == OP_2DIV ||
   opcode == OP_MUL ||
   opcode == OP_DIV ||
   opcode == OP_MOD ||
   opcode == OP_LSHIFT ||
   opcode == OP_RSHIFT)
return false; // Disabled opcodes.

这是0.3.5以后版本中script.cpp描述OP_LSHIFT的代码。OP_LSHIFT已经直接被禁用,可以看到还包括废除了其他多种数值操作符,这些都可能会导致缓冲区溢出的问题, 在设计虚拟机系统时需要格外注意风险。 当然OP_LSHIFT等脚本操作码只是被禁用,并没有直接从代码里删除,这是为了兼容以前客户端版本数据的合法性, 如果想要重新启用这些脚本,就需要一次硬分叉。

另外,攻击者还会利用操作码的计算复杂性来进行攻击,比如在#71036中区块发现了几个OP_CHECKSIG,这种命令会使节点做很多不必要的操作。

mTemplates.insert(make_pair(TX_PUBKEYHASH, CScript() << OP_DUP << OP_HASH160 << OP_PUBKEYHASH << OP_EQUALVERIFY << OP_CHECKSIG));

这是0.3.2版本中OP_CHECK是加密脚本,整个交易的输入、输出、脚本(从最近执行OP_CODESEPARATOR)都要哈希。所以当一个交易里面有几个OP_CHECKSIG的时候,会使交易进行重复哈希,有可能引起内存消耗过大甚至直接挂起,从而达到拒绝服务攻击的效果。

bool CScriptCompressor::IsToPubKey(std::vector &pubkey) const
{
    if (script.size() == 35 && script[0] == 33 && script[34] == OP_CHECKSIG
                            && (script[1] == 0x02 || script[1] == 0x03)) {
        pubkey.resize(33);
        memcpy(&pubkey[0], &script[1], 33);
        return true;
    }
    if (script.size() == 67 && script[0] == 65 && script[66] == OP_CHECKSIG
                            && script[1] == 0x04) {
        pubkey.resize(65);
        memcpy(&pubkey[0], &script[1], 65);
        CKey key;
        return (key.SetPubKey(CPubKey(pubkey))); // SetPubKey fails if this is not a valid public key, a case that would not be compressible
    }
    return false;
}

0.8版本对OP_CHECKSIG进行了限制,在这部分代码中首先匹配各脚本命令所占字节和所在位置,如果不正确,就不会继续执行压缩交易。

正确且合理使用OP指令是重要前提, 同时在设计时不要急于为了扩展功能把复杂步骤合并为一个OP指令, 这样可能存在非原子性操作BUG或者易于被DDOS攻击。

2.   参数限制不规范的漏洞

在#74638区块中,攻击者在交易中产生了超过184亿比特币,并发送到网络上的两个地址。几个小时之内,在修复错误后,交易记录被从事务日志中删除,并将网络分配到比特币协议的更新版本。这是在比特币历史上发现和利用的唯一重大安全漏洞。

"out" : [
               {
                   "value" : 92233720368.54277039,
                  "scriptPubKey" : "OP_DUP OP_HASH160 0xB7A73EB128D7EA3D388DB12418302A1CBAD5E890 OP_EQUALVERIFY OP_CHECKSIG"
               },
               {
                   "value" : 92233720368.54277039,
                   "scriptPubKey" : "OP_DUP OP_HASH160 0x151275508C66F89DEC2C5F43B6F9CBE0B5C4722C OP_EQUALVERIFY OP_CHECKSIG"
               }
           ]

上述输出产生两个90多亿的比特币原理如下: 比特币只检查UTXO的输入是否大于等于输出,如果大于等于交易就成立。

in: 0.50 BTC
out:92233720368.54277039,92233720368.54277039
fee:0.51 92233720368.54277039+92233720368.54277039= -0.01

两个90多亿的19位双精度浮点数相加等于-0.01。所以这笔交易通过了。

此外,0.7.2版本以前,攻击者可以发送一系列的消息包含一个整数整除0在Bloom Filter处理代码,这是对参数使用不规范导致可以远程导致Bitcoin-qt和bitcoind崩溃。数值精度和范围的控制在设计数据结构字段中需要极端慎重, 不仅是安全问题,同时可能尽可能减少磁盘占用空间。

3.   私钥保护缺陷

在0.4.1-0.5.0版本中比特币私钥并没有被加密,dat文件被加密。比特币私钥有可能被盗走。问题在于管理比特币私钥的是BSDDB数据库引擎。当你使用数据库删除某一数据的时候,他只是标记这个数据被删除了,而不是把数据域。新生成的bat文件也只是追加到数据的后面,而不是覆盖。

另外当程序设计账户私钥管理系统时,特别注意在于缓存私钥和随机数种子时, 操作系统内存的管理, 在不需要缓存或者关闭客户端时, 清除在内存中储存的敏感数据。

4. 比特币节点的网络攻击

0.7.0版本,比特币协议有一个预警系统,用来传播关于数字货币的重要新闻。对于收到的每个警报,节点都会检查警报签名。每一项检查都需要一段时间,通常在1到4秒之间。验证时间并不取决于签名的正确性。因此,攻击者可能会在不付出代价的时候向节点注入无效的警报,并耗尽受害者的节点CPU。

如果一个恶意节点发送以每秒3000次警报每秒64Kb的频率向受害节点发送警报,会使受害节点的CPU消耗100%去关联 ThreadMessageHandler2()线程。

即使是发送正确警报的节点,在64字节/秒的连接上连续发送一个188字节的正确警告仍然会使受害者的CPU使用率上升到100%。

解决办法:

  • 断开任何发送坏警报节点的网络连接

  • 检查一个警告是否有超过一次的验证,超过一次的警告就直接拒绝

void ThreadMessageHandler2(void* parg)
{
    printf("ThreadMessageHandler started\n");
#ifdef __WXMSW__
    SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
#else
	setpriority(PRIO_PROCESS, getpid(), PRIO_MIN);
#endif
    loop
    {
        // Poll the connected nodes for messages
        vector vNodesCopy;
        CRITICAL_BLOCK(cs_vNodes)
            vNodesCopy = vNodes;
        foreach(CNode* pnode, vNodesCopy)
        {
            pnode->AddRef();
            // Receive messages
            TRY_CRITICAL_BLOCK(pnode->cs_vRecv)
                ProcessMessages(pnode);
            // Send messages
            TRY_CRITICAL_BLOCK(pnode->cs_vSend)
                SendMessages(pnode);
            pnode->Release();
        }
        // Wait and allow messages to bunch up
        vnThreadsRunning[2]--;
        Sleep(100);
        vnThreadsRunning[2]++;
        if (CheckForShutdown(2)) return;
    }
}

可以看到检查时间并不取决于警报的正确性,只要调用ThreadMessageHandler2线程就会sleep(100)。

5.   图灵完备合约系统更复杂,漏洞几率更大

以以太坊为例,以太坊区块链账户模型是account模型,因为具有图灵完备的智能合约系统,实现起来比比特币更复杂,使得在以太坊上出现的很多智能合约都存在漏洞。The DAO 和最近出现的Parity钱包漏洞就是很好的例子。下面主要分析一下Parity钱包漏洞。

Parity Multisig电子钱包版本1.5+的漏洞被发现,使得攻击者从三个高安全的多重签名合约中窃取到超过15万ETH(约3000万美元)。攻击原理如下:

成为合约的owner

function() payable{
    if(msg.value > 0)
        Deposit(msg.sender,msg.value);
    else if(msg.data.length > 0)
        _walletLibrary.delegatecall(msg.data);
}

通过往这个合约地址转账一个value = 0 ,msg.data.length > 0 的交易, 执行到_walletLibrary.delegatecall的分支,该函数能无条件的调用合约内的任何一个函数,黑客调用了一个叫做 initWallet的函数:

function initWallet(address [] _owners,uint _required,uint _daylimit){
    initDayLimit(_daylimit);
    initMultiowned(_owners,_required);
}

这个函数再次调用initMultiowned函数:

function initMultiowned(address [] _owners,uint _required){
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender)
    m_ownerIndex[uint(msg.sender)] = 1;
    for(uint i=0;i<_owners.length;++i)
    {
        m_owners[2+i]=uint(_owners[i]);
        m_ownerIndex[uint(_owners[i])] = 2+i;
    }
    m_required = _required;
}

但是,initWallet没有检查以防止攻击者在合同初始化后调用到initMultiowned, 这个函数使得这个合约的所有者被改为攻击者。

在parity钱包源代码里修改漏洞的时候仅仅改了一个词,把权限问题划分清楚。

-  function initDaylimit(uint _limit) internal 
+  function initDaylimit(uint _limit) only_uninitialized

由此我们可以看到使用图灵完备的智能合约系统时一定要注意合约之间、合约的函数之间相互调用时产生的权限和逻辑问题。

当然建议在设计图灵完备合约系统时,摈弃大而全的设计,当性能和功能会相互挚肘时, 还是应该选择最小可用性和性能优先的原则。系统设计最难做到的部分是放弃,如果区块链是金融系统的一部分,那么稳定安全是第一要务,复杂的功能只能增加系统性风险。

6. 协议版本升级的风险

0.3.13版本,比特币进行了版本升级,每笔交易都要有0.01的比特币的交易费,导致了很多微小交易没人打包变成了无效的交易,这些无效的交易放在钱包里可能被继续交易而一直没有被确认。比特币开发者又更改了版本,小额交易(t<0.01)不需要支付交易费。

此外,版本升级还会引起重复验证交易,可能会存在一种处理重复交易的攻击方式。 存在重复验证交易可能会有两种形式,一是故意添加进来的重复交易。二是版本升级时旧区块与新区块产生重复交易。比如一条交易信息广播出来,新旧两个版本的客户端分别使用新旧两个版本号打包交易,当验证交易的时候,同一个交易的UTXO就会被查找两次,很有可能造成双重支付攻击。BIP30已经解决,在同一条链中,不允许已经验证的交易的标识符与以前的、未完全花费的标识符匹配。只有当前交易的前一个交易没有可输出的输出时,重复交易才可被使用。

总结与相关建议

从上面的分析我们可以看到,区块链也避免不了漏洞、攻击。当出现漏洞需要修补漏洞和提升性能的时候,就需要进行软件升级,说到版本升级就必须说一下软分叉和硬分叉的区别 。

区块链升级伴随着新的版本号的出现,区块数据格式和交易的数据格式都会更换新的版本号,升级软件的矿工可以不接受旧版本号的交易消息,没有升级软件的矿工接收新版本号的交易消息可以称为软分叉,软分叉最终会达成新版本号一致,硬分叉就是一刀切,升级软件的矿工不接受旧的版本号的消息,没有升级软件的矿工不接受新的版本号的交易消息。

软硬分叉可以用这两个公式来表示:

  • 软分叉:a = 1; a = 1+2; a = 2;

  • 硬分叉:a =1 ; a = 2;

版本升级时需要考虑的问题:

  1. 考虑大部分网络支持规则之前/之后运行的旧/新软件的所有四种组合

  2. 考虑与老客户/矿工的向后兼容

  3. 在主网部署之前先在测试网络上可用

  4. 逐步推出变革,一步一步来

升级流程建议

  1. 不接受非标准的交易到内存池,如果它有一个未知的版本号

  2. 使用代码对过去1000个区块的版本号进行计数

  3. 如果最近的区块有55%或更多的版本号有未知版本号,则警告用户需要升级

升级的方案 说明如何以最小化风险和中断的方式处理未来升级的可能性

  1. 必须设计操作码以便有任何一个交易不通过时都可视为无操作。当攻击者根据新规则进行有效的新交易攻击时,对旧的矿工/客户端无效,导致块分裂

  2. 使用新操作码的交易将被给予新版本号

  3. 运行新代码的矿工产生具有新版本号的块,因此可以测量对新功能的支持

  4. 较旧的节点不会中继或挖掘新的交易,也不会占用新交易的交易

(编辑:杨少康)

上一篇文章                  下一篇文章

长铗

巴比特(www.8btc.com)创始人,区块链研究者,科幻作家,2006~2008连续三届中国科幻小说最高奖银河奖得主。

评论:
    . 点击排行
    . 随机阅读
    . 相关内容