作者:成都链安
原文:《跨链桥安全研究(三) | 多边形战士Polygon安全透析,如何预防“潘多拉魔盒”的开启?》
欢迎大家来到成都链安出品的“跨链桥安全研究”系列文章,在上一篇文章里(跨链桥安全研究(二) | 首次去中心化抢劫Nomad跨链桥事件带给我们什么启发?),我们详细对Nomad跨链桥协议进行专业的技术分析。
今天,成都链安安全研究团队将对多边形战士Polygon安全透析,请继续往下看。
1_Polygon是谁?
Polygon是以太坊的layer2扩容方案,其愿景是建造以太坊的区块链互联网。Polygon提供了一个通用框架,允许开发人员利用以太坊的安全性创建定制的,专注于应用程序的链,并提供一个互操作性网络,结合了各种不同的扩展方案,如:zk-rollup、PoS等。其中,Polygon PoS是目前Polygon上最成熟和广为人知的扩容方案。它利用侧链进行交易处理,实现提升交易速度并节省Gas消耗的目的,网络结构主要包含以下三层:
Ethereum层:
以太坊主网上的一系列合约,主要包括:Staking、Checkpoint、Reward合约,负责PoS权益相关的质押管理功能,包括:提供MATIC原生代币的质押功能,使得任何质押该代币的人可以作为验证者加入系统;验证Polygon网络的转态转换获得质押奖励;惩罚验证者的双重签名、验证者停机等不合法行为;保存checkpoint。
Heimdall层:
权益证明验证节点层,包括一组PoS Heimdall节点,负责将Polygon网络的检查点提交给以太坊主网,同时监听部署在以太坊上的一组质押合约。主要流程为:首先选择验证者池中的一部分活跃的验证者作为块生产者,它们将负责在Bor层创建区块并广播;接着根据Bor提交的检查点,验证Merkle根哈希并附加签名;最后,提议者将负责收集指定检查点的所有验证者签名,如果签名数量达到2/3以上,则在以太坊上提交该检查点。
Bor层:
出块节点层,包括一组由Heimdall层上的验证者委员会定期选取的区块生产者,它们是一个验证者子集,负责将Polygon侧链上的交易聚合并生成区块。该层会定期向Heimdall层发布检查点(checkpoint),其中检查点代表Bor链上的一个快照,如下图所示。
2_Polygon互操作性
2.1 检查点(checkpoint)
检查点机制是一种将Bor层的数据同步到以太坊上的机制,其中同步的数据是检查点,即在一个检查点间隔的时间段内包含的Bor层区块数据快照,源码如下:
Proposer:提议者,它也是由验证者选取的,区块生产者和提议者都是验证者的子集,且他们的责任取决于其在整个池子中的股权比例
RootHash:是由StartBlock到EndBlock之间的Bor块生成的Merkle Hash
以下是编号1到n的Bor块生成RootHash值的伪码:
综上,该值是Bor区块头中的区块号number、区块时间戳time、交易树根Hash值tx hash、收据树根Hash值receipt hash计算得到的keccak256哈希值构成的Merkel tree的根哈希值。
AccountRootHash:需要将每个检查点发送到以太坊上的验证者相关账户信息的Merkle Hash,单个账户信息的哈希值计算方式如下:
由账户Merkle tree根哈希值生成AccountRootHash的方式与RootHash值相同。
2.2 StateSync
状态同步机制(StateSync)是指将以太坊数据同步到Polygon Matic链,主要分为以下几个步骤:
1)首先以太坊上的合约会触发StateSender.sol中的syncState()函数进行状态同步
2)syncState()函数将发出一个event事件,如下:
3)Heimdall层的所有验证者都会收到该事件,其中一个验证者会将该交易打包到heimdall区块中,并添加到待处理的状态同步列表中;
4)bor层节点会通过API获取到上述待同步列表,交给bor层的合约进行进一步的业务逻辑处理。
2.3 Polygon Bridge
Polygon Bridge实现了Polygon和Ethereum之间的双向跨链通道,使得用户可以在两个不同链平台之间更为方便地转移代币而不会产生第三方威胁和市场流动性限制。Polygon Bridge有PoS和Plasma两种类型,二者在Polygon和Ethereum之间的资产转移都有以下相同之处:
1)首先需要将Ethereum上的代币映射到Polygon,如下图所示:
2)同样采用双向锚定技术(Two-way Peg),即
a:从以太坊上转移的代币资产都会先在Ethereum上被锁定,且相同数量的映射代币会在Polygon上被铸造;
b:为了将代币资产提取到Ethereum,首先需要将这些映射代币在Polygon上burn掉,之后再解锁锁定在Ethereum上的资产;
下图为PoS Bridge和Plasma Bridge的对比:
由上图可知,安全性方面,PoS Bridge依赖于外部验证者集合的安全性,而Plasma依赖于Ethereum主链的安全性。同时在用户进行跨链资产转移时(如将代币从Polygon转移到Ethereum),PoS仅需要一个检查点的间隔时间,大约20分钟到3小时;而Plasma则需要一个7天的争议挑战期。同时PoS支持更多的标准代币,而Plasma仅支持三种类型,包括:ETH、ERC20、ERC721。
3_跨链消息传递—PoS Bridge
PoS Bridge主要包含两个功能:Deposit和Withdrawals,其中Deposit指的是将用户在以太坊上的资产转移到Polygon,Withdrawals则指的是将资产从Polygon提取到以太坊上。
Deposit
下面以用户Alice使用PoS Bridge将其以太坊账户上的代币资产发送到其在Polygon账户中为例进行介绍:
1、如果用户想转移的代币资产为ERC20、ERC721、ERC1155类型,则首先需要用户将要转移的代币通过approve函数授权。如下所示:通过调用以太坊上token合约中的approve方法将对应数量的token授权给erc20Predicate合约。
其中approve函数有两个参数:
- spender:用户授权允许花费代币的目标地址
- amount:可以被花费的代币数量
2、上述授权交易被确认后,用户接着通过调用RootChainManager合约的depositFor()方法将代币锁定到以太坊上的erc20Predicate合约中。此处,如果转移的资产类型是ETH,则调用depositEtherFor()。具体如下:
其中depositFor函数有三个参数:
- user:接收Polygon上deposit代币的用户地址
- rootToken:以太坊主链上的token地址
- depositData:ABI编码后的代币数量
以下是RootChainManager合约中depositFor函数的具体代码:
分析源码可知,该函数首先获取到token对应的predicate合约地址,接着调用其lockTokens()函数将token锁定在该合约中。最后_stateSender将调用syncState()进行状态同步,该函数只有admin设置的状态发送者(state sender)才能调用。
3、StateSender.sol中的syncState()函数将提交事件StateSynced,具体为:
其中第一个参数为该log的序号索引,第二个参数用于校验调用者是否是已注册的合法合约地址,第三个是需要进行状态同步的数据。该交易会被添加到Heimdall块中,并被添加到挂起的状态同步列表中。
4、接着Polygon Matic链上的bor节点通过API获取到状态同步列表中的StateSynced事件后,该链上的ChildChainManager合约会调用onStateReceive()函数,该函数用于接收从以太坊上传过来的同步数据,根据状态同步的业务逻辑类型进行下一步处理:
data:包括bytes32类型的syncType、bytes类型的syncData。其中,syncType代表业务类型,包括deposit和mapping代币映射;当syncType为mapping时,syncData为编码后的rootToken地址、childToken地址和bytes32类型的tokenType;当syncType为deposit时,syncData为编码后的user地址、rootToken地址和bytes类型的depositData。depositData在REC20中是数量,ERC721中指的是tokenId。
5、由于此处进行的是Deposit业务,所以接着会调用_syncDeposit()函数。该函数会首先将syncData按照对应格式解码,得到对应的rootToken、user地址、depositData。接着校验rootToken在polygon上是否有对应的映射代币childToken,如果有则调用childToken的deposit()函数。
6、此处我们以ERC20的代币合约为例,介绍映射代币合约如何deposit。该函数将mint对应数量的代币到用户账户中。
该函数有两个参数:
- user:正在进行存款的用户地址
- depositData:用ABI编码的amount
Withdrawals
下面以用户Alice使用PoS Bridge将其在Polygon账户中存放的资金提取到以太坊账户为例进行介绍:
1、当用户withdraw时,需要首先在Polygon链上通过调用映射token合约的withdraw()函数,burn掉对应数量的映射代币。
withdraw仅包含一个参数:将要被burn掉的token数量。对应的token合约中的withdraw()函数如下:
2、上述交易将经过大约20分钟到3小时将被包含到checkpoint中,被验证者提交到以太坊。
3、一旦交易被添加到检查点中并提交到了以太坊,将调用以太坊上的RootChainManager合约的exit()函数,该函数将通过验证提交的检查点内容确认在Polygon上withdraw交易的有效性,并触发对应的Predicate合约解锁用户deposit的代币。
其中,传入该函数的Proof证明inputData包括以下数据:
- headerNumber:包含了withdraw交易的检查点区块header
- blockProof:证明子链中的区块头是提交的merkle root的叶子节点
- blockNumber:子链上包含withdraw交易的区块号
- blockTime:withdraw交易的区块时间戳
- txRoot:区块交易树的root值
- receiptRoot:区块收据树的root值
- receipt:withdraw交易的收据
- receiptProof:withdraw交易收据的默尔克证明
- branchMask:收据树中32位表示的收据路径
- receiptLogIndex:从收据树中读取的日志索引
下面是该函数的核心逻辑,主要包括三部分:第一部分是校验withdraw交易收据的有效性,第二部分是校验检查点是否包含了交易区块,第三部分是调用predicate合约中的exitTokens()函数将锁定的代币发送给用户。
4、以ERC20Predicate合约为例,即从log中解码出接收者、发送者、发送代币数量后,将给定数量的代币发送给用户。
由PoS Bridge跨链消息传递过程源码分析可知,整个过程的函数调用都只有验证者指定的角色才能调用,所以跨链的安全性仅由PoS保证(公证人)。
4_跨链消息传递—Plasma Bridge
Plasma Bridge同样包含两个功能:Deposit和Withdrawals,具体流程如下图所示:
Polygon Plasma与我们跨链桥系列第一篇文章介绍的比特币Plasma MVP实现略有差别,主要采用基于账户模型的Plasma MoreVP。该算法与Plasma相比,主要在withdraw部分做了部分改进。
由于ERC20、ERC721的代币传输,是通过类似比特币UTXO的event日志实现的,所以我们首先介绍一下该事件:
- input1:转账前发送者的账户余额
- input2:转账前接收者的账户余额
- output1:转账后发送者的账户余额
- output2:转账后接收者的账户余额
其次,原先的Plasma MVP,由于区块是由单个(Operator)或者少数的区块生产者生成,因此在Polygon上存在以下两种攻击场景:
Operator作恶:
上一篇文章(跨链桥安全研究(二) | Nomad跨链桥)提到,当用户的交易被Operator打包为Plasma区块后,存在链下数据的不可用性问题。因此,用户在进行exit交易时,如果从较旧的交易开始退出,Operator可以使用其最近的一笔交易对其发起挑战,则会挑战成功。同时,由于Plasma中采用了PoS的检查点机制,Operator如果勾结验证者作恶,甚至可以伪造一些状态转换并提交到以太坊。
用户作恶:
用户在发起exit交易后,继续在Polygon上花费代币,类似于跨链的双花。
综上,Polygon的Plasma MoreVp算法采用了另一种计算退出优先级的算法,即从最近的交易开始退出。该方式由于使用了类似UTXO的LogTransfer事件,只要用户的合法交易使用了正确的input1、input2,即使Operator一些恶意交易打包在用户交易之前,由于用户交易仅来自有效的input,所以也能被正确处理。相关伪代码如下:
Deposit
下面以用户Alice使用Plasma Bridge将其以太坊账户上的代币资产发送到其在Polygon账户中为例进行介绍:
1、首先用户同样需要将其需要转移的代币资产通过approve函数授权给主链(Ethereum)上的Polygon合约depositManager。
2、同样等到授权交易被确认后,用户调用erc20token.deposit()函数,触发depositManager合约的depositERC20ForUser()函数,存入用户的ERC20代币资产。
3、当以太坊主网确认了该deposit交易,接下来会创建一个仅包含这笔交易的区块,并将其采用状态同步机制发送到Polygon网络上的childChain合约中,mint相同数量的映射币并存入用户在Polygon上的账户。
注:由childChain合约源码分析可知,Plasma仅支持三种类型,包括:ETH、ERC20、ERC721。
Withdraw
当用户想使用Plasma bridge从Polygon上提取资产到以太坊上,会经历以下几个步骤:
1、用户通过调用Polygon上映射币的withdraw()函数,burn掉Polygon链上的映射代币资产:
也可以调用Polygon上的Plasma Client的withdrawStart()接口实现。
2、用户可以调用ERC20Predicate合约中startExitWithBurntTokens()函数,该函数首先会调用WithdrawManager.verifyInclusion()校验checkpoint是否包含withdraw交易和对应的收据,代码如下:
验证通过后,将调用WithdrawManager.addExitToQueue()将其按照优先级排序插入到消息队列中:
最后,addExitToQueue()调用_addExitToQueue()铸造一个NFT作为退款凭证:
3、用户等待7天的挑战期
4、挑战期完成,可以调用WithdrawManager.processExits()函数将代币发送给用户。
该函数主要分为两个步骤:首先确认消息队列中的withdraw交易是否已经过了7天挑战期,如果已经超过挑战期则将其该交易移除队列:
接着,判断退款凭证NFT是否在挑战期内被删除,未被删除则将该NFT销毁并将对应资产退还给用户:
5_Polygon Plasma Bridge 双花漏洞
2021 年 10 月 5 日,白帽子Gerhard Wagner提交了一个Polygon漏洞,该漏洞可能导致双花攻击,涉及到的金额为8.5亿美元,白帽子因此获得了Polygon官方的2,000,000 美元漏洞赏金。
在前文Plasma Bridge的介绍中我们知道,完整的一次Withdraw交易过程为:
- 用户在Polygon上发起Withdraw交易,该交易会burn掉用户在Polygon的代币;
- 经过一个检查点间隔(大约30分钟),等待该withdraw交易被包含到检查点中;
- 超过2/3的验证者签名后将其提交到以太坊,此时用户调用ERC20PredicateBurnOnly合约中的startExitWithBurntTokens()校验checkpoint是否包含burn交易;
- 校验通过,则铸造一个NFT退款凭证发给用户
- 用户等待7天挑战期
- 调用WithdrawManager.processExits()销毁NFT,并退款给用户
注意:Polygon为了防止交易重放(双花攻击),使用NFT作为退款凭证,来唯一标识一笔Withdraw交易。但是,由于NFT的ID生成缺陷,造成了攻击者可以构造参数利用同一笔有效的Withdraw交易,生成多个不同ID的NFT,再利用这些NFT进行退款交易,从而实现“双花攻击”。
下面将对如何如何生成NFT进行详细介绍:
1、由上文中的源码解析可知,addExitToQueue()会调用_addExitToQueue()铸造一个NFT:
由传参分析可知,exitid = priority,则NFT的ID即为Plasma Bridge中的age优先级左移一位生成。
2、上文的源码解析可知,age是WithdrawManager.verifyInclusion()函数的返回值,该函数会首先校验withdraw交易的有效性,校验通过则生成对应的age。其中,校验的逻辑中使用了可控参数data解码出的值branchMaskBytes:
同时生成age时也使用了该值:
3、跟踪交易验证逻辑中的调用的MerklePatriciaProof.verify()函数,发现该函数调用_getNibbleArray()对branchMaskBytes进行了转码操作:
4、继续跟踪该解码函数,该函数对branchMaskBytes转码时存在丢弃部分值的情况,这种数值丢失的方式会造成不同的值转码后获得同样的解码值。具体为:如果传入的hp编码后的值b的第一个十六进制位(半个字节)是1或3,就解析第二个十六进制位。否则,就直接忽略第一个字节。
那么如果攻击者构造一个branchMaskBytes参数,使得其第一个十六进制位不等于1和3,则共有14*16 = 224种方式,能够获得相同的转码后的值。
具体的攻击流程为:
- 通过Polygon Plasma向Polygon存入大量ETH/代币
- 在Polygon上发起Withdraw交易,等待7天的挑战期
- 修改withdraw交易中branchMaskBytes参数的第一个字节(同一有效交易最多可以重新提交223次),重复发起Withdraw交易
综上,该漏洞主要是由于生成防止重放的退款凭证NFT的ID算法设计存在问题,导致相同的退款交易可以生成不同的NFT,造成双花攻击。事实证明,编码分支掩码的第一个字节应该始终是0x00. 修复方法是检查编码的分支掩码的第一个字节是否是0x00并且不要将其视为不正确的掩码。
好了,今天的分享就结束了,下一期,成都链安安全研究团队将介绍另一个跨链项目的安全研究,尽请期待。