去中心化金融 (DeFi) 作为区块链生态当红项目形态,其安全尤为重要。从去年至今,发生了几十起安全事件。BlockSec 作为长期关注 DeFi 安全的研究团队 (https://blocksecteam.com),独立发现了多起 DeFi 安全事件,研究成果发布在顶级安全会议中(包括 USENIX Security, CCS 和 Blackhat)。在接下来的一段时间里,我们将系统性分析 DeFi 安全事件,剖析安全事件背后的根本原因。
今天带来这个系列的第一篇,去中心化跨链资产桥梁项目 ChainSwap 攻击事件。
0xffffffff. 前言
北京时间 2021 年 7 月 11 日凌晨,去中心化跨链资产桥梁项目 ChainSwap 再次遭到攻击,部署于该跨链桥上的 20+个项目遭到攻击,损失超过 800 万美元,是 DeFi 发展史迄今为止发生的最严重的攻击事件。本文将抽丝剥茧分析攻击的全部细节。
时间:Jul-10-2021 07:16:11 PM +UTC #12801461
阅读建议:
如果您刚刚接触 DeFi,可以从头开始看起,但是文章较长,看不下去记得点个关注再走
如果您对 Sushiswap、AMM、DEX 等比较了解,可以直接从「0x1 攻击分析」开始
0x0. 背景介绍
ChainSwap 是一个跨链资产桥(Cross-Chain Asset Bridge)。所谓跨链资产桥,本质上就是为了解决在多个智能合约链的资产转移问题。举例来说,在币安智能链(BSC)链上的 USDT 代币,想要转换到以太坊主链上进行交易,则必须通过跨链资产桥进行转换。ChainSwap、Ren Protocol 等是目前具有代表性的跨链资产桥项目,其上承载了数千万美元的跨链交易,也使得这次攻击造成的损失极为惨重。
① 跨链资产桥 ChainSwap
随着以太坊及基于以太坊的去中心化金融(DeFi)生态的不断发展壮大,越来越多基于以太坊虚拟机的智能合约区块链应运而生,这其中就包括了币安智能链(Binance Smart Chain,BSC)、火币生态链(Huobi Eco Chain,HECO)及波场链(TRON Chain)。
在多种智能合约链共存的场景下,出于在不同链上进行代币交换的需求,跨链资产桥项目逐渐进入人们的视野。传统的跨链资产桥如 Shapeshift、ChangeNOW 等是中心化的服务,在去中心化的大背景下,去中心化的跨链资产桥项目不断出现,而 ChainSwap 正是其中的代表服务之一。
设计一个跨链资产桥,最大的难点在于如何在不同链之间验证交易。针对这个问题,ChainSwap 采取了 PoA (Proof of Authority,即权威证明)机制。
整个 ChainSwap 网络中存在一组验证者节点(这个节点可以是一个人也可以是一个合约),这些节点的工作是验证一条链上的交易并在另一条链上提供证明。举例来说,假设用户 A 希望将以太坊链上的 1000USDT 转移到 BSC 链上,这一步转移操作实现如下:
首先在以太坊链上,服务调用链上合约的 send 方法,把用户的 1000USDT 发送给以太坊上的 ChainSwap 合约,生成一个发送交易。
随后,ChainSwap 会将这笔交易的信息发送给一组验证者,这些验证者会将这笔交易验证并返回一组签名。
最后在 BSC 链上,服务调用链上合约的 receive 方法,在 BSC 链上收到 1000USDT 发回给用户。这个调用会发送以太坊上交易的签名以验证在以太坊链上的交易。
② ChainSwap 的实现机制概述
从本质上来说,ChainSwap 是不同链上交易的撮合方。拿以上的转移操作为例,第二步需要有一个独立的验证者对以太坊链上用户的转账操作进行证明。那么怎样实现高效安全的签名机制呢?
让我们回归以太坊的基本知识。以太坊用户生成地址的过程如下:首先生成一个巨大的随机数作为私钥(Private Key),该私钥经过一定的单项算法可以得到公钥(Public Key),再通过某个单向哈希算法生成地址。所以本质上来说,地址的背后对应私钥。
私钥不仅可以用来解密,还可以用来签名。具体来说,任何人(在这里是 ChainSwap 的验证者)可以将某个消息序列化,并用私钥生成对应的签名。签名包括四个部分:消息的摘要(Digest)和三个参数(R、V、S)。
签名之后,怎样验证签名的有效性呢?智能合约的 Solidity 编程语言提供了 ecrecover 函数,可以通过以上四个参数计算得到签名这个消息的地址。合约的实现方必须自行验证这个地址是否有效。后文我们将看到,正是由于没有验证地址的有效性,导致了 ChainSwap 的这次攻击。③ ChainSwap 的实现机制概述在分析代码和攻击之前,首先概述一下 ChainSwap 的实现机制:
ChainSwap 作为一个跨链资产桥,其设置了一个 Factory (工厂模型)用于管理和查询下属的项目,即找到跨链币种在以太坊主链上的地址。
每个项目可以向 ChainSwap 申请对接,自动成为跨链代币。ChainSwap 会为这些代币创建一个映射 Token (TokenMapped)用于代表该 Token 在链上的“映射”。
上文说到,为了跨链地证明交易,ChainSwap 设置了几个验证者来进行交易验证。ChainSwap 代码中将验证者命名为签名者(Signatory),且为每一个验证者限定了配额(Quota),也就是说每个验证者验证的交易额在一段时间内是有限制的。但验证者会有一定的初始配额,且该配额会随着时间累积。
0x1. 代码分析
ChainSwap 会为该项目承接的每一个代币创建一个 TokenMapped 合约来代表该代币在链上的映射。攻击者的入口即为 TokenMapped 合约的 receive 方法。下面我们逐步分析相关代码,看看攻击是如何实现的。
① 攻击入口分析
上图展示了 receive 函数的全部代码。正常情况下,用户在其他链上向 ChainSwap 发送某种 Token 之后,交易经过签名者验证,用户可以将验证信息发给以太坊主链上的 ChainSwap 合约,后者会在验证之后将 Token 转账给给用户。receive 实现的正是这样一个流程。
该函数的实现较为复杂,在开头首先收取 0.05ETH 的费用(通过_chargeFee 内部函数)。接下来,合约向 ChainSwap Factory 请求参数,获得最小签名个数(参数 minSignatures),对传入签名个数进行验证。从交易记录中可以查到这个值为 1,也就是说跨链交易只需要一个签名者(Signatory)的签名即可通过。在后文中会对这个参数的修改过程进行描述。
然后对每一个签名,首先常规验证传入的签名数组是否有重复。其次,通过 ecrecover 验证签名的有效性。这一步是常规的的签名验证过程,由于和攻击本身无关,不再赘述。最后,通过_decreaseAuthQuota 减少验证者的配额。看到这里,整个实现逻辑似乎是完整且清晰的。
但是通过以上分析过程我们发现,入口函数 receive 并没有对签名者(Signatory)的身份进行验证,Signatory 参数的存在仅仅用于配合 ecrecover 函数验证消息是否确实是由 Signatory 进行签名。也就是说,任何人都可以生成一个签名。也许在 receive 函数调用的内部函数中对 Signatory 进行了验证?我们继续往下看。
上图展示了第一个内部函数 _decreaseAuthQuota 的实现,该函数的主要功能为减少记录在 _authQuotas 映射中对应 Signatory 的配额值。实现本身非常简单,但是红线部分展示的 modifier 部分引起了我们的注意。_decreaseAuthQuota 并没有对 Signatory 的合法性进行检查,那么这个 modifier 是否有验证呢?
上图展示了 updateAutoQuota(signatory) 的实现。modifier 是 Solidity 语言的一种语法糖,以上图展示的 modifier 为例,这个 modifier 修饰的函数会将自己添加在函数中,最后一行的短划线表示 modifier 标注的函数的真正函数体将会出现的位置。可见该 modifier 的实现方式是先调用 authQuotaOf 函数更新 Signatory 的配额,并根据条件进行更新。注意到这里在调用 authQuotaOf 之后也没有做 Signatory 的合法性进行验证。
最后来看 authQuotaOf 的实现。首先直接获取_authQuotas 映射中 Signatory 的配额。不同于 Python 等主流编程语言,Solidity 是没有类似 Python 中 KeyError 的概念的,如果映射(mapping)中不存在这个键,则会返回 0,然而这里没有对返回值进行检查。接下来,通过 autoQuotaRatio 和 autoQuotaPeriod 两个参数计算新的最大配额 quotaCap。通过搜索以太坊交易记录,这两个参数的值分别为 1e15 (除以 1e18 后为 0.1%)和 86400 (一天的秒数)。
因此倒数第三行计算得出的 quotaCap 是一个很大的值(cap() 的实际实现为 IERC20(token).totalSupply(),也就是这个链上代币的总供应量)。因此 quotaCap 的理论最大值为代币供应量的 0.1%。
倒数第二行,由于 lasttimeUpdateQuotaOf 中找不到这个 Signatory,因此返回 0 (第二次犯错),因此在 Signatory 非法时 delta 可以比 quotaCap 更大。最后一行通过计算返回更新后的配额,通过以上的分析不难看出,在攻击中返回的正是 quotaCap。
总结一下,receive 函数实现的整个过程,都没有对传入的 Signatory 的合法性进行检查。因此攻击者只需要随机生成一个地址并生成对应的签名,即可骗过 ChainSwap,自己为自己提供签名。同时,由于 authQuotaOf 在实现逻辑上的错误,在 Signatory 不合法时会返回一个非常大的值,导致了这次攻击事件的发生。而本次攻击事件发生的本质,是没有对映射索引的值进行验证。由于 Solidity 并不会在映射的键不存在时触发任何错误(键是否存在只能靠返回值是否为 0 进行判断),因此这类检查就显得非常重要。正是由于(荒谬地)缺少这样的检查,导致了这次损失超过 800 万美元的攻击。
③ 番外篇 1:参数 minSignatures 的修改
通过以上的分析不难看出,攻击发生的主要原因是缺少对映射索引返回值的检查。但参数参数 minSignatures 的值同样引起了我们的注意。我们发现在交易 0x50d462f4 中,参数 minSignatures 的值被修改成 1:
这个交易出现在区块 12377182,这是攻击发生的 65 天前。这种针对协议底层参数的修改只能由官方进行(而不是攻击者自行修改的),通过阅读代码也验证了这一点:
Factory 合约继承了 Configurable 合约以提供参数配置功能。图中函数声明中的 governance modifier 代表该函数仅能通过管理者进行修改。我们并不知道为什么 ChainSwap 官方修改了这个参数,但这个修改一方面增加了系统的不可靠性,另一方面为攻击者提供了便利,因为攻击者只需构造一个签名便可骗过 ChainSwap 系统,直接提取代币。
④ 番外篇 2:虚假的验证
有趣的是,在管理者 Factory 的合约实现中,ChainSwap 设计了一个变量来保存 Signatory 构成的数组,代码如下图所示:
但很遗憾的是,通过对 Factory 合约实现的审计,我们发现这个变量仅仅是在管理 Signatory 配额的时候被使用,并没有验证一个任意地址是否包含在这个数组中的代码实现,因此任何一个人都可以创造一个地址并为自己生成签名。
0x2. 攻击分析
经过以上的代码分析,不难得出一个最简单的攻击流程:只需要自己批量创建多个地址,生成并提交签名即可。由于 TokenMapped 并不会进行任何验证,可以将合约中锁仓的代币直接获得,实现空手套白狼。
攻击者的真实攻击就是这样实现的。以其中一笔攻击交易为例,交易执行 Trace 如下:
可以看出,攻击只是简单地调用了以上展示的有 bug 的 receive 函数,传入了虚假的签名,就套到了攻击者本不应获得的代币。
通过这样的攻击方式,攻击者攻击了基于 ChainSwap 的 20+个项目,盗走了价值 800 万美元以上的代币,造成了极为严重的损失。
BlockSec 团队以核心安全技术驱动,长期关注 DeFi 安全、数字货币反洗钱和基于隐私计算的数字资产存管,为 DApp 项目方提供合约安全和数字资产安全服务。团队发表 20 多篇顶级安全学术论文 (CCS, USENIX Security, SP),合伙人获得 AMiner 全球最具影响力的安全和隐私学者称号 (2011-2020 排名全球第六). 研究成果获得中央电视台、新华社和海外媒体的报道。独立发现数十个 DeFi 安全漏洞和威胁,获得 2019 年美国美国国立卫生研究院隐私计算比赛 (SGX 赛道) 全球第一名。团队以技术驱动,秉持开放共赢理念,与社区伙伴携手共建安全 DeFi 生态。
扫描二维码,关注更多精彩
https://www.blocksecteam.com/
contact@blocksecteam.com