Appearance
揭秘 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) 编码则是以太坊序列化数据的标准方式。题目提示我们需要深入理解这些,因为跨链验证的证明就依赖于它们。
攻击思路的线索
题目中的“提示”至关重要:
- “有时你验证的数据并不完全是你执行的数据。” 这点明了漏洞的核心:数据差异。即使我们提交的证明显示某个消息是有效的,但在实际执行阶段,合约可能处理的是另一份“不完全相同”的数据。这可能涉及到对 RLP 解码、Merkle 证明验证逻辑的误用。
- “如果一个哈希循环似乎无法解决,寻找打破循环的方法。” 这个提示暗示了可能存在的循环依赖或无限递归的可能性,或者需要找到一个“捷径”来绕过预设的校验流程。
代码解读: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 中使用 accountStateRlp 和 storageTrieProof 来验证的消息,在 _executeOperation 中执行的消息,即使拥有相同的 withdrawalHash,其内部的某些数据可能被操纵。
解题思路:MyContract.sol 与 MyScript.sol
MyContract.sol 扮演了一个中间者的角色,它实现了 IMessageReceiver 接口,并在 onMessageReceived 函数中接收来自 NotOptimisticPortal 的消息。
MyScript.sol 则是实际执行攻击的脚本。它展示了如何:
- 成为 L1 Portal 的所有者:通过
transferOwnership_____610165642函数,将NotOptimisticPortal的所有权转移给自己(MyContract合约),从而获得管理权限。 - 成为 L1 Portal 的 Sequencer:通过
updateSequencer_____76439298743函数,将NotOptimisticPortal的sequencer地址设置为MyContract合约。 - 提交一个构造的 L2 区块头:通过
submitNewBlock_____37278985983函数,用一个包含特定newBlockHeader的 RLP 编码来更新 Portal 的 L2 状态。这个newBlockHeader的构造是攻击的关键,它可能包含了伪造的状态根或关键数据。 - 执行
executeMessage:最后,通过executeMessage函数,利用构造的ProofData和消息数据,触发铸币 (_mint)。
利用 CEI 模式的“间隙”
NotOptimisticPortal 的漏洞点在于,它先执行了所有可能的影响状态的 _executeOperation,然后再进行验证。我们可以利用这个“间隙”:
- 首先,通过
sendMessage函数,向 L2 发送一条消息,并存储一个状态槽。 - 接着,利用
NotOptimisticPortal的owner和sequencer权限,提交一个伪造的 L2 区块头,使得 L2 的状态根和存储根与我们之前发送的消息状态槽不匹配。 - 最关键的是,在
executeMessage中,我们构造的ProofData实际上指向的是一个 L2 上已被修改过的状态。但是,由于_executeOperation的执行顺序,它会先执行消息,例如将NotOptimisticPortal的所有权转移给自己。 - 然后,当
_verifyMessageInclusion被调用时,它会基于假的 L2 状态根进行验证,但此时NotOptimisticPortal的状态已经被我们控制了。 - 最后,
_mint函数会被调用,将代币铸造到我们指定的地址。
胜利的时刻:铸造你的代币!
通过精巧地利用 L2 状态验证与 L1 执行之间的不一致性,以及合约权限的管理,我们可以成功绕过 NotOptimisticPortal 的防御机制,为自己的钱包铸造代币,从而赢得这场 CTF 挑战。
这个题目让我们深刻认识到,在跨链通信中,每一个环节的安全性都至关重要,任何微小的验证与执行之间的不匹配,都可能成为攻击者突破的入口。