智能合约一旦部署到区块链上就无法修改,出了问题可能造成巨大的资金损失。我最近就经历了一次智能合约的线上故障,过程惊心动魄,最后虽然有惊无险,但是教训深刻。本文是这次故障的完整复盘,包括故障发生的过程、排查思路、紧急处理、根本原因分析,以及事后总结的经验教训。如果你也在做智能合约开发,希望这篇文章能帮你避坑。

一、背景介绍

先说说项目背景。

我们团队在做一个DeFi项目,核心是一个借贷协议,用户可以抵押加密资产借出稳定币。合约用Solidity编写,部署在以太坊上。整个系统有多个合约,包括核心借贷合约、质押合约、预言机合约、治理合约等。

项目上线前做了充分的测试,包括单元测试、集成测试、还有第三方安全审计。审计公司也没有发现严重的问题。上线之后运行了一个多月,一切正常,锁仓量稳步增长。

但是就在一个普通的周五晚上,故障发生了。

那天晚上我正在家吃饭,突然收到监控告警,说借贷合约的某个函数调用失败率突然飙升。我心里咯噔一下,赶紧打开电脑查看。这一看不要紧,发现问题比我想象的严重得多。

接下来的几个小时,是我职业生涯中最紧张的几个小时。我们团队全员在线,排查问题、讨论方案、紧急处理,一直忙到凌晨。最后虽然没有造成资金损失,但是过程惊心动魄,教训非常深刻。

本文就把这次故障的完整过程记录下来,希望能给做智能合约开发的同学一些参考。

二、故障发生

故障发生在周五晚上八点多。

首先是监控系统告警,显示借贷合约的borrow函数调用失败率从平时的不到1%飙升到了60%以上。很多用户反映无法借款,交易提交之后就revert了。

我赶紧打开Etherscan查看合约的交易记录,发现最近的borrow交易大部分都失败了,失败的原因是revert,但是没有具体的错误信息。因为我们的合约没有自定义错误信息,revert的时候只返回了空数据,不知道具体是哪个条件触发了revert。

这是第一个教训:智能合约一定要有详细的错误信息,出了问题才能快速定位。我们当时为了省Gas,没有加太多require的错误信息,结果出了问题根本不知道是哪里错了。

然后我查看了合约的状态变量,发现一个异常:流动性池的可用资金变成了负数。这怎么可能呢?uint类型怎么会是负数?仔细一看,原来是可用资金的计算逻辑出了问题,导致数值溢出,显示成了一个巨大的数字。

这时候我意识到问题可能比较严重。如果可用资金的计算错了,可能会影响整个协议的运行。

三、排查过程

发现问题之后,我立刻在团队群里拉了紧急会议,所有人都上线了。

第一步:确认影响范围

首先要确认问题影响了哪些功能。我们测试了合约的各个函数,发现borrow(借款)和repay(还款)都失败了,但是deposit(存款)和withdraw(取款)还能正常使用。也就是说,用户可以存钱取钱,但是不能借钱还钱。

这个影响还算可控,至少用户的资金没有被锁死,还能取出来。但是如果不尽快修复,可能会引发挤兑,用户都把钱取走,协议的流动性就枯竭了。

第二步:定位问题代码

接下来要定位具体是哪里出了问题。因为没有错误信息,我们只能通过代码审查和状态变量来推断。

我们把borrow函数的代码一行一行地看,检查每个require条件。borrow函数的大致逻辑是:

  1. 检查用户的抵押品是否足够
  2. 检查流动性池是否有足够的资金
  3. 计算借款金额和利息
  4. 更新用户的借款记录
  5. 转账给用户

我们怀疑是第二步,检查流动性池资金的逻辑出了问题。因为可用资金显示异常,可能是这个检查条件不满足,导致revert。

仔细看了一下计算可用资金的代码,发现了一个可疑的地方。可用资金的计算公式是:

availableLiquidity = totalDeposits - totalBorrows - pendingInterest

这个公式看起来没问题,但是问题出在pendingInterest的计算上。pendingInterest是待收取的利息,它的计算依赖于一个叫interestRate的变量。

我们发现interestRate的值异常大,比正常值大了好几个数量级。这导致pendingInterest被算成了一个巨大的数字,availableLiquidity就变成了负数(uint溢出),然后检查可用资金的条件就失败了。

第三步:找到根本原因

那interestRate为什么会异常呢?我们继续追查interestRate的计算逻辑。

interestRate是根据资金利用率来计算的,利用率越高,利率越高。计算公式是一个分段函数,利用率低于某个阈值的时候利率增长缓慢,超过阈值之后利率快速增长。

我们发现资金利用率的计算也出了问题。资金利用率 = totalBorrows / totalDeposits。因为totalDeposits的计算包含了一些已经被提取但是还没有更新的金额,导致totalDeposits被算小了,利用率就被算成了超过100%,触发了利率的快速增长段,interestRate就变得异常大。

那totalDeposits为什么会被算小呢?最后发现,是因为在withdraw函数中,有一个状态变量的更新顺序错了。应该先更新totalDeposits再转账,但是代码里是先转账再更新。在正常情况下这不会有问题,因为交易是原子的。但是如果在转账之后、更新之前,有另一个交易读取了totalDeposits,就会读到不正确的值。

虽然以太坊是串行执行交易的,不会有真正的并发问题,但是在同一个区块中,如果有多个交易调用不同的函数,后面的交易会读到前面交易的中间状态。我们的问题就是这样产生的:一个withdraw交易先执行,转账了但是还没更新totalDeposits,紧接着一个borrow交易执行,读到了不正确的totalDeposits,导致计算出错。

这是一个非常隐蔽的bug,测试的时候没有覆盖到这种交易顺序的场景,审计公司也没有发现。

四、紧急处理

找到问题之后,接下来是紧急处理。

方案一:暂停合约

我们的合约有一个暂停功能,可以在紧急情况下暂停所有操作。但是如果暂停了,用户就什么都做不了了,包括取款。这可能会引起恐慌,而且暂停期间如果价格剧烈波动,用户的抵押品可能会被清算,造成损失。

方案二:升级合约

我们的合约是可升级的,用的是代理模式。理论上可以部署一个修复版本,然后升级代理指向新合约。但是升级需要多签审批,我们的多签有5个管理员,需要至少3个签名。周五晚上大家都在,但是协调签名需要时间,而且升级之后还需要验证新合约是否正常。

方案三:用管理账户重置异常变量

我们合约有一个管理函数,可以重置一些状态变量。如果能把异常的interestRate和pendingInterest重置成正确的值,合约就能恢复正常。但是这个函数需要谨慎使用,因为重置错了会造成更大的问题。

经过讨论,我们决定先采用方案三,用管理账户重置异常变量,让合约先恢复正常。然后再用方案二,部署修复版本,彻底解决问题。

执行过程

首先,我们在测试网络上模拟了重置操作,确认不会造成其他问题。然后在主网上执行重置交易,把interestRate和pendingInterest重置成正确的值。

重置之后,我们立刻测试了borrow和repay函数,发现都能正常工作了。失败率降回了正常水平。

这时候已经是晚上十一点多了,大家稍微松了一口气。但是还不能掉以轻心,因为根本原因还没有修复,同样的问题可能再次发生。

接下来我们开始准备修复版本。把withdraw函数中的状态变量更新顺序改对,先更新再转账。然后在测试网络上做了充分的测试,包括各种交易顺序的场景。

测试通过之后,开始走多签升级流程。5个管理员中有3个在线,完成了签名。升级执行之后,我们又做了一轮测试,确认所有功能正常。

全部处理完已经是凌晨三点多了。虽然很累,但是问题解决了,而且没有造成资金损失,大家都松了一口气。

五、根本原因分析

故障处理完之后,我们做了详细的根本原因分析。

直接原因

withdraw函数中状态变量更新顺序错误,先转账后更新,导致在特定的交易顺序下,后续交易读到不正确的totalDeposits值,进而导致利率计算异常,最终引发借款和还款功能失败。

间接原因

  1. 测试覆盖不足。没有测试不同函数在同一个区块中连续调用的场景,这种边界情况没有被覆盖到。
  2. 错误信息缺失。合约的require没有详细的错误信息,导致排查问题花了很多时间。
  3. 监控不够完善。虽然监控到了失败率飙升,但是没有更详细的指标,比如各个状态变量的异常变化。
  4. 代码审查不够仔细。这个bug其实在代码审查的时候应该能发现,但是当时没有注意到状态变量更新顺序的问题。

深层原因

团队对智能合约的特殊性认识不足。智能合约和传统程序不一样,它的状态是公开的、不可变的,出了问题很难回滚。所以在开发的时候需要更加谨慎,考虑更多的边界情况。

六、经验教训

这次故障给了我们很多教训,总结一下。

1. 状态变量更新顺序很重要

在智能合约中,状态变量的更新顺序一定要仔细考虑。应该先更新所有状态变量,再执行外部调用(比如转账)。这样可以避免在外部调用过程中,其他交易读到中间状态。

这是智能合约开发的一个基本原则,叫做"检查-生效-交互"模式(Checks-Effects-Interactions)。先检查条件,然后更新状态,最后和外部合约交互。我们的代码违反了这个原则,才导致了这个bug。

2. 一定要有详细的错误信息

智能合约的require一定要有详细的错误信息,出了问题才能快速定位。不要为了省一点Gas而省略错误信息,出一次故障的损失比省下的Gas多得多。

3. 测试要覆盖边界情况

智能合约的测试不能只测正常流程,还要测各种边界情况和异常情况。尤其是不同函数的调用顺序、同一个区块中的多个交易、重入攻击等场景。

建议使用模糊测试(fuzzing)工具,比如Echidna,自动生成各种随机的交易序列,发现隐藏的bug。

4. 安全审计不能代替自己的测试

第三方安全审计很重要,但是不能完全依赖审计。审计公司也有遗漏的时候,自己的测试和代码审查才是最基础的保障。

5. 要有完善的应急方案

智能合约出问题是迟早的事情,一定要提前准备好应急方案。包括暂停功能、升级机制、多签管理、状态重置函数等。出了问题才能快速响应,减少损失。

6. 监控要详细

监控不能只看交易失败率,还要监控关键状态变量的变化。如果某个状态变量异常,应该立刻告警,这样能更早发现问题。

7. 可升级合约是双刃剑

可升级合约可以修复bug,但是也带来了信任问题和安全风险。用户需要相信管理员不会恶意升级。而且如果私钥泄露,攻击者可以升级合约偷走资金。所以可升级合约的管理一定要严格,多签、时间锁、社区治理都要有。

七、后续改进

故障之后,我们做了一系列改进。

  1. 全面审查了所有合约的代码,确保都遵循"检查-生效-交互"模式。
  2. 给所有require都加上了详细的错误信息。
  3. 引入了模糊测试工具,增加了测试覆盖率。
  4. 完善了监控系统,增加了关键状态变量的监控和告警。
  5. 制定了更详细的应急响应流程,包括故障分级、处理流程、沟通机制等。
  6. 增加了一个时间锁机制,合约升级需要等待48小时才能生效,给用户足够的时间反应。
  7. 定期做安全演练,模拟各种故障场景,测试应急响应能力。

八、写在最后

这次智能合约故障虽然没有造成资金损失,但是过程惊心动魄,教训深刻。智能合约开发和传统软件开发不一样,它的容错率很低,出了问题可能造成巨大的损失,而且很难挽回。

所以做智能合约开发一定要更加谨慎,更加注重安全。代码审查、测试、审计、监控、应急方案,每一个环节都不能少。

如果你也在做智能合约开发,希望这篇复盘能给你一些参考。不要等到出了问题才重视安全,安全应该从第一天就融入到开发流程中。

最后用一句话结束本文:"在区块链的世界里,代码即法律,漏洞即灾难。"每一行代码都要认真对待,每一个细节都不能放过。