Skip to content
On this page

揭秘 NotOptimisticPortal:跨链通信的陷阱与机会

在区块链的世界里,跨链通信是连接不同链条、实现资产和信息互通的关键技术。而 CTF 题目 NotOptimisticPortal,则将我们带入了一个精心设计的复杂场景,挑战我们深入理解跨链验证机制的脆弱之处。

题目描述

“这个 Portal 依赖于一套复杂的加密证明链来验证跨链消息。它声称能够抵御无效的状态转换,但验证与执行之间的鸿沟,可能比看起来更宽。你能否设法为你的钱包铸造一些代币?”

深入剖析:CTF 题目的核心挑战

NotOptimisticPortal 的核心是模拟一个 L2 到 L1 的跨链消息通信机制。题目暗示,虽然 Portal 声称安全,但验证和实际执行之间可能存在差异。这正是攻击点所在。

为了攻破它,我们需要掌握以下几个关键概念:

  • Function Selectors (函数选择器):Solidity 函数在调用时,通过其函数签名计算出的 4 字节哈希来确定调用哪个函数。了解这一点有助于我们精准定位和调用合约的特定功能。
  • Checks-Effects-Interactions (CEI) 模式:这是一种安全编程模式,要求先进行所有检查(Checks),然后执行状态更改(Effects),最后再进行外部调用(Interactions)。遵守 CEI 模式的合约能有效防止重入攻击等风险。NotOptimisticPortal 的挑战在于,它似乎打破了这个模式,或者存在某种方式可以绕过它的安全性。
  • Merkle Patricia Tries (默克尔帕特里夏树) 和 RLP 编码:这是以太坊中用于存储和验证数据结构的两种核心技术。Merkle Patricia Trie 允许高效地验证一个特定数据是否存在于一个庞大的数据集中(例如,验证一个账户状态或存储值是否存在于 L2 的状态根中),而 RLP (Recursive Length Prefix) 编码则是以太坊序列化数据的标准方式。题目提示我们需要深入理解这些,因为跨链验证的证明就依赖于它们。

攻击思路的线索

题目中的“提示”至关重要:

  1. “有时你验证的数据并不完全是你执行的数据。” 这点明了漏洞的核心:数据差异。即使我们提交的证明显示某个消息是有效的,但在实际执行阶段,合约可能处理的是另一份“不完全相同”的数据。这可能涉及到对 RLP 解码、Merkle 证明验证逻辑的误用。
  2. “如果一个哈希循环似乎无法解决,寻找打破循环的方法。” 这个提示暗示了可能存在的循环依赖或无限递归的可能性,或者需要找到一个“捷径”来绕过预设的校验流程。

代码解读:NotOptimisticPortal.sol

通过阅读 NotOptimisticPortal.sol 的代码,我们可以发现:

  • executeMessage 函数是核心,它接收 L2 的证明 (ProofData) 和消息内容,然后进行验证并执行。
  • _verifyMessageInclusion 函数是负责验证 L2 状态和存储证明的关键。它使用 Lib_SecureMerkleTrie 来验证 L2 账户状态和消息槽的存在性。
  • _executeOperation 函数负责实际执行调用,它会检查消息的函数选择器,然后执行 target.call(callData)
  • sendMessage 函数用于 L1 用户向 L2 发送消息,会进行 burn 操作,并修改存储槽。
  • submitNewBlock_____37278985983 函数由 sequencer 权限调用,用于提交新的 L2 区块头,更新 L2 的状态根。

漏洞点猜想:

最有可能的漏洞点存在于 executeMessage 函数中:验证(_verifyMessageInclusion)和执行(_executeOperation 以及最后的 _mint)的顺序

executeMessage 函数中,先执行了所有消息的 _executeOperation,然后在最后才调用 _verifyMessageInclusion 进行验证,并且在验证完成后才执行 _mint

这里的关键点在于 _executeOperation_messageData 中的每个消息进行执行。如果我们可以构造 _messageData,使得其中包含的调用能够影响到后续的验证逻辑,或者能够通过一个“欺骗性”的消息,在验证前就获得代币,那么就可能成功。

特别是,_executeOperation 确保 callData 的前 4 字节是 0x3a69197e (即 onMessageReceived 的函数选择器)。但题目提示“有时你验证的数据并不完全是你执行的数据”,这可能意味着,在 _verifyMessageInclusion 中使用 accountStateRlpstorageTrieProof 来验证的消息,在 _executeOperation 中执行的消息,即使拥有相同的 withdrawalHash,其内部的某些数据可能被操纵。

解题思路:MyContract.solMyScript.sol

MyContract.sol 扮演了一个中间者的角色,它实现了 IMessageReceiver 接口,并在 onMessageReceived 函数中接收来自 NotOptimisticPortal 的消息。

MyScript.sol 则是实际执行攻击的脚本。它展示了如何:

  1. 成为 L1 Portal 的所有者:通过 transferOwnership_____610165642 函数,将 NotOptimisticPortal 的所有权转移给自己(MyContract 合约),从而获得管理权限。
  2. 成为 L1 Portal 的 Sequencer:通过 updateSequencer_____76439298743 函数,将 NotOptimisticPortalsequencer 地址设置为 MyContract 合约。
  3. 提交一个构造的 L2 区块头:通过 submitNewBlock_____37278985983 函数,用一个包含特定 newBlockHeader 的 RLP 编码来更新 Portal 的 L2 状态。这个 newBlockHeader 的构造是攻击的关键,它可能包含了伪造的状态根或关键数据。
  4. 执行 executeMessage:最后,通过 executeMessage 函数,利用构造的 ProofData 和消息数据,触发铸币 (_mint)。

利用 CEI 模式的“间隙”

NotOptimisticPortal 的漏洞点在于,它先执行了所有可能的影响状态的 _executeOperation,然后再进行验证。我们可以利用这个“间隙”:

  • 首先,通过 sendMessage 函数,向 L2 发送一条消息,并存储一个状态槽。
  • 接着,利用 NotOptimisticPortalownersequencer 权限,提交一个伪造的 L2 区块头,使得 L2 的状态根和存储根与我们之前发送的消息状态槽不匹配。
  • 最关键的是,在 executeMessage 中,我们构造的 ProofData 实际上指向的是一个 L2 上已被修改过的状态。但是,由于 _executeOperation 的执行顺序,它会先执行消息,例如将 NotOptimisticPortal 的所有权转移给自己。
  • 然后,当 _verifyMessageInclusion 被调用时,它会基于假的 L2 状态根进行验证,但此时 NotOptimisticPortal 的状态已经被我们控制了。
  • 最后,_mint 函数会被调用,将代币铸造到我们指定的地址。

胜利的时刻:铸造你的代币!

通过精巧地利用 L2 状态验证与 L1 执行之间的不一致性,以及合约权限的管理,我们可以成功绕过 NotOptimisticPortal 的防御机制,为自己的钱包铸造代币,从而赢得这场 CTF 挑战。

这个题目让我们深刻认识到,在跨链通信中,每一个环节的安全性都至关重要,任何微小的验证与执行之间的不匹配,都可能成为攻击者突破的入口。


Built with AiAda