Generated banner for Cairo Vulnerability Scanner
Generated logo for Cairo Vulnerability Scanner

Cairo Vulnerability Scanner

Trail of BitsOfficialPublished by Trail of Bits

  • Skill
  • Review and audit
  • Security
  • CC-BY-SA-4.0
  • No issues found

From the author

Scans Cairo/StarkNet smart contracts for 6 critical vulnerabilities including felt252 arithmetic overflow, L1-L2 messaging issues, address conversion problems, and signature replay. Use when auditing StarkNet projects.
— the description by Trail of Bits

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The Cairo Vulnerability Scanner is a structured auditing framework designed to identify security risks specific to the Cairo language and the StarkNet ecosystem. It focuses on six critical vulnerability patterns, including arithmetic overflows, L1-L2 messaging issues, and signature replay attacks. The skill enforces a rigorous reporting process that requires a comprehensive coverage table, ensuring that every identified pattern is explicitly addressed, cleared, or marked as not applicable for the target contract.

What it does

  • Scans Cairo source code for 6 specific security vulnerability patterns.
  • Provides a mandatory coverage table to document the status of each vulnerability pattern.
  • Offers specific mitigation strategies and code examples for identified security flaws.
  • Integrates with static analysis tools like Caracal to automate detection.
  • Validates L1-L2 bridge logic and cross-layer message handling.
  • Ensures signature verification logic includes necessary replay protection.

When to use it

  • During security audits of StarkNet smart contracts.
  • When reviewing L1-L2 bridge implementations for potential vulnerabilities.
  • As part of a pre-launch security assessment for Cairo-based applications.
  • When validating signature verification and L1 handler logic.

Advantages

  • Prevents incomplete audits by requiring a full coverage table for all 6 patterns.
  • Focuses on platform-specific risks unique to the Cairo/StarkNet environment.
  • Provides actionable remediation steps and code-level fixes.
  • Standardizes the reporting format for security findings.

Usage examples

“Audit my StarkNet contract in src/ for security vulnerabilities.”
The agent will scan the codebase, generate a 6-row coverage table, and provide detailed reports for any found vulnerabilities with remediation advice.
“Check my L1 handler implementation for potential security flaws.”
The agent will specifically analyze the #[l1_handler] functions for from_address validation and access control issues.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/trailofbits/cairo-vulnerability-scanner/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip cairo-vulnerability-scanner.zip -d .agents/skills

Documentation by Trail of Bits

Cairo/StarkNet Vulnerability Scanner

1. Purpose

Systematically scan Cairo smart contracts on StarkNet for platform-specific security vulnerabilities related to arithmetic, cross-layer messaging, and cryptographic operations. This skill encodes 6 critical vulnerability patterns unique to Cairo/StarkNet ecosystem.

2. When to Use This Skill

  • Auditing StarkNet smart contracts (Cairo)

  • Reviewing L1-L2 bridge implementations

  • Pre-launch security assessment of StarkNet applications

  • Validating cross-layer message handling

  • Reviewing signature verification logic

  • Assessing L1 handler functions

3. Platform Detection

File Extensions & Indicators
  • Cairo files: .cairo

Language/Framework Markers
// Cairo contract indicators
#[contract]
mod MyContract {
    use starknet::ContractAddress;

    #[storage]
    struct Storage {
        balance: LegacyMap<ContractAddress, felt252>,
    }

    #[external(v0)]
    fn transfer(ref self: ContractState, to: ContractAddress, amount: felt252) {
        // Contract logic
    }

    #[l1_handler]
    fn handle_deposit(ref self: ContractState, from_address: felt252, amount: u256) {
        // L1 message handler
    }
}

// Common patterns
felt252, u128, u256
ContractAddress, EthAddress
#[external(v0)], #[l1_handler], #[constructor]
get_caller_address(), get_contract_address()
send_message_to_l1_syscall
Project Structure
  • src/contract.cairo - Main contract implementation

  • src/lib.cairo - Library modules

  • tests/ - Contract tests

  • Scarb.toml - Cairo project configuration

Tool Support
  • Caracal: Trail of Bits static analyzer for Cairo

  • Installation: cargo install --git https://github.com/crytic/caracal --profile release --force (a Rust tool — not on PyPI)

  • Usage: caracal detect src/

  • cairo-test: Built-in testing framework

  • Starknet Foundry: Testing and development toolkit


4. How This Skill Works

When invoked, I will:

  1. Search your codebase for Cairo files

  2. Analyze each contract for the 6 vulnerability patterns

  3. Report findings with file references and severity, above them a coverage table carrying a verdict for every pattern

  4. Provide fixes for each identified issue

  5. Check L1-L2 interactions for messaging vulnerabilities


5. Example Output

When vulnerabilities are found, you'll get a report like this:

=== CAIRO/STARKNET VULNERABILITY SCAN RESULTS ===

6. Vulnerability Patterns (6 Patterns)

I check for 6 critical vulnerability patterns unique to Cairo/Starknet. For detailed detection patterns, code examples, mitigations, and testing strategies, see VULNERABILITY_PATTERNS.md.

Pattern Summary:
  1. Felt252 Arithmetic Overflow/Underflow ⚠️ HIGH - felt252 wraps silently; use u128/u256

  2. L1 to L2 Address Conversion ⚠️ HIGH - L1 address not validated against STARKNET_FIELD_PRIME

  3. L1 to L2 Message Failure ⚠️ HIGH - No cancellation path when a message cannot be consumed

  4. Overconstrained L1 <-> L2 Interaction ⚠️ MEDIUM - Coupling that can strand funds or block progress

  5. Signature Replay Protection ⚠️ HIGH - No nonce, or a domain separator missing chain/contract

  6. Unchecked from_address in L1 Handler ⚠️ CRITICAL - Any L1 contract can drive the handler

For complete vulnerability patterns with code examples, see VULNERABILITY_PATTERNS.md.

7. Scanning Workflow

Step 1: Platform Identification
  1. Verify Cairo language and StarkNet framework

  2. Check Cairo version (Cairo 1.0+ vs legacy Cairo 0)

  3. Locate contract files (src/*.cairo)

  4. Identify L1-L2 bridge contracts (if applicable)

Step 2: Arithmetic Safety Sweep
# Find felt252 usage in arithmetic
rg "felt252" src/ | rg "[-+*/]"

# Find balance/amount storage using felt252
rg "felt252" src/ | rg "balance|amount|total|supply"

# Should prefer u128, u256 instead
Step 3: L1 Handler Analysis

For each #[l1_handler] function:

  • [ ] Validates from_address parameter

  • [ ] Checks address != zero

  • [ ] Has proper access control

  • [ ] Emits events for monitoring

Step 4: Signature Verification Review

For signature-based functions:

  • [ ] Includes nonce tracking

  • [ ] Nonce incremented after use

  • [ ] Domain separator includes chain ID and contract address

  • [ ] Cannot replay signatures

Step 5: L1-L2 Bridge Audit

If contract includes bridge functionality:

  • [ ] L1 validates address < STARKNET_FIELD_PRIME

  • [ ] L1 implements message cancellation

  • [ ] L2 validates from_address in handlers

  • [ ] Symmetric access controls L1 ↔ L2

  • [ ] Test full roundtrip flows

Step 6: Static Analysis with Caracal
# Run Caracal detectors
caracal detect src/

# Specific detectors
caracal detect src/ --detectors unchecked-felt252-arithmetic
caracal detect src/ --detectors unchecked-l1-handler-from
caracal detect src/ --detectors missing-nonce-validation

8. Reporting Format

Coverage Table

Report on every pattern in §6, whether or not it turned anything up. Emit this table above the findings, with all 6 rows present:

#PatternVerdictEvidence
1Felt252 Arithmetic Overflow/Underflowclearbalances are u256; searched felt252 in arithmetic, none
2L1 to L2 Address Conversion
3L1 to L2 Message Failure
4Overconstrained L1 <-> L2 Interaction
5Signature Replay Protection
6Unchecked from_address in L1 Handler

Each verdict is one of:

  • found — cite file:line and write the finding up in full below.

  • clear — the pattern applies to this contract and the contract handles it. Name the function, trait, or check you searched for, so a reader can repeat the search.

  • n/a — the pattern cannot apply here. Give the reason in one clause ("no L1 handlers in this contract"). Not having looked is not n/a.

A table with fewer than 6 rows is an incomplete scan and must be reported as one. A row whose Verdict cell is empty is incomplete in the same way: row 1 above is filled in to show the shape, and every row is filled in the same way before the report is done. Six clear verdicts is a result a reader can act on. A report that covers two patterns and says nothing about the other four reads exactly like a clean contract, and that is the failure this table exists to prevent.

Finding Template
## [CRITICAL] Unchecked from_address in L1 Handler

**Location**: `src/bridge.cairo:145-155` (handle_deposit function)

**Description**:
The `handle_deposit` L1 handler function does not validate the `from_address` parameter. Any L1 contract can send messages to this function and mint tokens for arbitrary users, bypassing the intended L1 bridge access controls.

**Vulnerable Code**:
```rust
// bridge.cairo, line 145
#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,  // Not validated!
    user: ContractAddress,
    amount: u256
) {
    let current_balance = self.balances.read(user);
    self.balances.write(user, current_balance + amount);
}
```

**Attack Scenario**:
1. Attacker deploys malicious L1 contract
2. Malicious contract calls `starknetCore.sendMessageToL2(l2Contract, selector, [attacker_address, 1000000])`
3. L2 handler processes message without checking sender
4. Attacker receives 1,000,000 tokens without depositing any funds
5. Protocol suffers infinite mint vulnerability

**Recommendation**:
Validate `from_address` against authorized L1 bridge:
```rust
#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,
    user: ContractAddress,
    amount: u256
) {
    // Validate L1 sender
    let authorized_l1_bridge = self.l1_bridge_address.read();
    assert(from_address == authorized_l1_bridge, 'Unauthorized L1 sender');

    let current_balance = self.balances.read(user);
    self.balances.write(user, current_balance + amount);
}
```

**References**:
- building-secure-contracts/not-so-smart-contracts/cairo/unchecked_l1_handler_from
- Caracal detector: `unchecked-l1-handler-from`

9. Priority Guidelines

Critical (Immediate Fix Required)
  • Unchecked from_address in L1 handlers (infinite mint)

  • L1-L2 address conversion issues (funds to zero address)

High (Fix Before Deployment)
  • Felt252 arithmetic overflow/underflow (balance manipulation)

  • Missing signature replay protection (replay attacks)

  • L1-L2 message failure without cancellation (locked funds)

Medium (Address in Audit)
  • Overconstrained L1-L2 interactions (trapped funds)


10. Testing Recommendations

Unit Tests
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_felt252_overflow() {
        // Test arithmetic edge cases
    }

    #[test]
    #[should_panic]
    fn test_unauthorized_l1_handler() {
        // Wrong from_address should fail
    }

    #[test]
    fn test_signature_replay_protection() {
        // Same signature twice should fail
    }
}
Integration Tests (with L1)
// Test full L1-L2 flow
#[test]
fn test_deposit_withdraw_roundtrip() {
    // 1. Deposit on L1
    // 2. Wait for L2 processing
    // 3. Verify L2 balance
    // 4. Withdraw to L1
    // 5. Verify L1 balance restored
}
Caracal CI Integration
# .github/workflows/security.yml
- name: Run Caracal
  run: |
    # Rebuilds from source each run; cache ~/.cargo or pin a release binary instead.
    cargo install --git https://github.com/crytic/caracal --profile release --force
    caracal detect src/ --fail-on high,critical

11. Additional Resources


12. Quick Reference Checklist

Before completing Cairo/StarkNet audit:

Arithmetic Safety (HIGH):

  • [ ] No felt252 used for balances/amounts (use u128/u256)

  • [ ] OR felt252 arithmetic has explicit bounds checking

  • [ ] Overflow/underflow scenarios tested

L1 Handler Security (CRITICAL):

  • [ ] ALL #[l1_handler] functions validate from_address

  • [ ] from_address compared against stored L1 contract address

  • [ ] Cannot bypass by deploying alternate L1 contract

L1-L2 Messaging (HIGH):

  • [ ] L1 bridge validates addresses < STARKNET_FIELD_PRIME

  • [ ] L1 bridge implements message cancellation

  • [ ] L2 handlers check from_address

  • [ ] Symmetric validation rules L1 ↔ L2

  • [ ] Full roundtrip flows tested

Signature Security (HIGH):

  • [ ] Signatures include nonce tracking

  • [ ] Nonce incremented after each use

  • [ ] Domain separator includes chain ID and contract address

  • [ ] Signature replay tested and prevented

  • [ ] Cross-chain replay prevented

Tool Usage:

  • [ ] Caracal scan completed with no critical findings

  • [ ] Unit tests cover all vulnerability scenarios

  • [ ] Integration tests verify L1-L2 flows

  • [ ] Testnet deployment tested before mainnet

  • [ ] Coverage table emitted with all 6 rows, each carrying a verdict of found, clear or n/a with a reason


13. Rationalizations to Reject

  • "The contract is small, so most patterns obviously don't apply." Obvious to whom? An n/a costs one clause and makes the judgment reviewable. Silence records nothing, and a reader cannot tell it apart from not having checked.

  • "Caracal reported nothing, so the contract is clean." Caracal covers a subset of these 6 patterns and does not reach the logic-level ones at all. A clean tool run is one row of evidence, not a verdict on the patterns it never examined. Say which patterns it covered.

  • "I checked the patterns that matter for this contract." Deciding which patterns matter is the scan, not a precondition for starting it. Rank by severity after the table is complete, not by leaving rows out.

  • "No findings, so there is nothing to report." A zero-finding scan still emits the full coverage table. That table is the deliverable: it is what distinguishes a contract that was examined from one that was glanced at.

  • "Cairo 1 has native overflow checks, so arithmetic is safe." Name the types. felt252 does not behave like the sized integer types, and the boundary between them is where pattern 4 lives.

  • "The L1 side validates that." Then cite the L1 contract. A check you believe exists across the bridge is an assumption until you have read it, and unverified from_address is the canonical StarkNet bridge bug.

Files

resources/VULNERABILITY_PATTERNS.md

6. Vulnerability Checklist (6 Patterns)

6.1 FELT252 ARITHMETIC OVERFLOW/UNDERFLOW ⚠️ HIGH

Description: The felt252 type in Cairo represents field elements in range [0, P] where P is the StarkNet prime. Unchecked arithmetic can overflow (wrapping to 0) or underflow (wrapping to P-1), similar to unsigned integers.

Detection Patterns:

// VULNERABLE: Direct arithmetic on felt252 without bounds checking
#[external(v0)]
fn transfer(ref self: ContractState, to: ContractAddress, amount: felt252) {
    let sender = get_caller_address();
    let mut sender_balance = self.balances.read(sender);

    // OVERFLOW/UNDERFLOW: No bounds checking!
    sender_balance = sender_balance - amount;  // Can underflow to ~P
    sender_balance = sender_balance + amount;  // Can overflow past P

    self.balances.write(sender, sender_balance);
}

// VULNERABLE: felt252 arithmetic in calculations
let reward = base_reward * multiplier;  // Can overflow
let remaining = total - claimed;  // Can underflow

What to Check:

  • [ ] Direct arithmetic on felt252 type without overflow/underflow checks

  • [ ] Balance updates using felt252

  • [ ] Reward/fee calculations using felt252

  • [ ] No explicit validation that values stay within expected range

  • [ ] Consider using default integer types (u8, u16, u32, u64, u128, u256)

Mitigation:

// OPTION 1: Use default integer types (RECOMMENDED)
#[storage]
struct Storage {
    // Use u128 or u256 instead of felt252 for balances
    balances: LegacyMap<ContractAddress, u128>,
}

#[external(v0)]
fn transfer(ref self: ContractState, to: ContractAddress, amount: u128) {
    let sender = get_caller_address();
    let sender_balance = self.balances.read(sender);

    // u128 has built-in overflow/underflow protection
    assert(sender_balance >= amount, 'Insufficient balance');

    self.balances.write(sender, sender_balance - amount);
    self.balances.write(to, self.balances.read(to) + amount);
}

// OPTION 2: Explicit checks with felt252
#[external(v0)]
fn transfer_felt(ref self: ContractState, to: ContractAddress, amount: felt252) {
    let sender = get_caller_address();
    let sender_balance = self.balances_felt.read(sender);

    // Explicit overflow/underflow checks
    assert(sender_balance >= amount, 'Insufficient balance');

    let new_sender_balance = sender_balance - amount;
    let recipient_balance = self.balances_felt.read(to);
    let new_recipient_balance = recipient_balance + amount;

    // Verify no overflow occurred
    assert(new_recipient_balance >= recipient_balance, 'Overflow');

    self.balances_felt.write(sender, new_sender_balance);
    self.balances_felt.write(to, new_recipient_balance);
}

// OPTION 3: SafeMath-style library (if available)
use openzeppelin::security::safemath::SafeMath;

let safe_result = SafeMath::add(balance, amount)?;

Tool Detection:

  • Caracal detector: unchecked-felt252-arithmetic

  • Look for: felt252 used in balances, arithmetic operations without bounds checks

References: building-secure-contracts/not-so-smart-contracts/cairo/arithmetic_overflow


4.2 L1 TO L2 ADDRESS CONVERSION ⚠️ HIGH

Description: Ethereum L1 addresses are uint256 (256 bits), but StarkNet addresses are felt252 with range [0, P] where P < 2^256. L1 addresses >= P will map to zero address or unexpected address on L2.

Detection Patterns:

// VULNERABLE: L1 contract doesn't validate address range
// L1 Bridge contract (Solidity)
function depositToL2(address l2Recipient, uint256 amount) external {
    // WRONG: No check that l2Recipient < STARKNET_FIELD_PRIME
    uint256[] memory payload = new uint256[](2);
    payload[0] = uint256(uint160(l2Recipient));  // Direct conversion!
    payload[1] = amount;

    starknetCore.sendMessageToL2(l2Contract, selector, payload);
}

// VULNERABLE: L2 handler assumes address is valid
// L2 Contract (Cairo)
#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,
    user: ContractAddress,  // Could be zero if L1 address >= P!
    amount: u256
) {
    // No validation that user != 0
    let current_balance = self.balances.read(user);
    self.balances.write(user, current_balance + amount);
}

What to Check:

  • [ ] L1 bridge contracts validate addresses before sending to L2

  • [ ] Validation: 0 < address < STARKNET_FIELD_PRIME

  • [ ] L2 contracts check that addresses != 0

  • [ ] Documentation warns about address conversion issues

Mitigation:

// SECURE: L1 Bridge with address validation (Solidity)
contract L1Bridge {
    // StarkNet field prime (approximately)
    uint256 constant STARKNET_FIELD_PRIME =
        0x0800000000000011000000000000000000000000000000000000000000000001;

    function depositToL2(uint256 l2Recipient, uint256 amount) external {
        // Validate L2 address is within valid range
        require(l2Recipient != 0, "Zero address");
        require(l2Recipient < STARKNET_FIELD_PRIME, "Address out of range");

        uint256[] memory payload = new uint256[](2);
        payload[0] = l2Recipient;
        payload[1] = amount;

        starknetCore.sendMessageToL2(l2Contract, selector, payload);
    }
}
// SECURE: L2 handler with validation (Cairo)
#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,
    user: ContractAddress,
    amount: u256
) {
    // Validate user address is not zero
    let zero_address: ContractAddress = 0.try_into().unwrap();
    assert(user != zero_address, 'Invalid user address');

    // Validate L1 sender is authorized bridge
    let expected_l1_bridge: felt252 = self.l1_bridge_address.read();
    assert(from_address == expected_l1_bridge, 'Unauthorized L1 sender');

    let current_balance = self.balances.read(user);
    self.balances.write(user, current_balance + amount);
}

Testing:

// Test with edge case addresses
#[test]
fn test_large_address_conversion() {
    // Address close to STARKNET_FIELD_PRIME
    let large_address = STARKNET_FIELD_PRIME - 1;
    // Should handle correctly

    let invalid_address = STARKNET_FIELD_PRIME + 1;
    // Should reject on L1
}

References: building-secure-contracts/not-so-smart-contracts/cairo/l1_to_l2_address_conversion


4.3 L1 TO L2 MESSAGE FAILURE ⚠️ HIGH

Description: Messages from L1 to L2 may not be processed by the sequencer (due to gas spikes, congestion, etc.). Without a cancellation mechanism, funds sent via failed messages are locked permanently.

Detection Patterns:

// VULNERABLE: L1 bridge without cancellation mechanism
contract L1Bridge {
    function depositToL2(uint256 l2Recipient, uint256 amount) external payable {
        // Lock funds on L1
        require(msg.value == amount, "Incorrect amount");

        // Send message to L2
        starknetCore.sendMessageToL2(l2Contract, selector, payload);

        // PROBLEM: If message fails to process on L2, funds locked forever!
        // No way to cancel and return funds to user
    }
}

What to Check:

  • [ ] L1 bridge implements message cancellation mechanism

  • [ ] Uses startL1ToL2MessageCancellation and cancelL1ToL2Message

  • [ ] Cancellation delay period documented

  • [ ] Users can recover funds if L2 message not processed

Mitigation:

// SECURE: L1 Bridge with cancellation support
contract L1Bridge {
    IStarknetCore public starknetCore;
    uint256 public constant MESSAGE_CANCEL_DELAY = 5 days;

    // Track message hashes for cancellation
    mapping(bytes32 => address) public messageOwner;

    function depositToL2(
        uint256 l2Contract,
        uint256 l2Recipient,
        uint256 amount
    ) external payable returns (bytes32 msgHash) {
        require(msg.value == amount, "Incorrect amount");

        uint256[] memory payload = new uint256[](2);
        payload[0] = l2Recipient;
        payload[1] = amount;

        // Send message and get hash
        msgHash = starknetCore.sendMessageToL2{value: msg.value}(
            l2Contract,
            DEPOSIT_SELECTOR,
            payload
        );

        // Track message owner for cancellation
        messageOwner[msgHash] = msg.sender;

        emit DepositInitiated(msg.sender, l2Recipient, amount, msgHash);
        return msgHash;
    }

    function startCancellation(bytes32 msgHash, uint256 nonce) external {
        require(messageOwner[msgHash] == msg.sender, "Not message owner");

        uint256[] memory payload = new uint256[](2);
        payload[0] = uint256(uint160(msg.sender));
        payload[1] = msg.value;

        // Start cancellation process
        starknetCore.startL1ToL2MessageCancellation(
            l2Contract,
            DEPOSIT_SELECTOR,
            payload,
            nonce
        );

        emit CancellationStarted(msgHash, msg.sender);
    }

    function completeCancellation(
        bytes32 msgHash,
        uint256 nonce
    ) external {
        require(messageOwner[msgHash] == msg.sender, "Not message owner");

        uint256[] memory payload = new uint256[](2);
        payload[0] = uint256(uint160(msg.sender));
        payload[1] = msg.value;

        // Complete cancellation (after delay)
        starknetCore.cancelL1ToL2Message(
            l2Contract,
            DEPOSIT_SELECTOR,
            payload,
            nonce
        );

        // Return funds to user
        (bool success, ) = msg.sender.call{value: msg.value}("");
        require(success, "Transfer failed");

        delete messageOwner[msgHash];

        emit DepositCancelled(msgHash, msg.sender);
    }
}

L2 Considerations:

// L2 contract should handle idempotent deposits
#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,
    user: ContractAddress,
    amount: u256
) {
    // If message is replayed after cancellation attempt,
    // should handle gracefully (idempotency)
}

References: building-secure-contracts/not-so-smart-contracts/cairo/l1_to_l2_message_failure


4.4 OVERCONSTRAINED L1 <-> L2 INTERACTION ⚠️ MEDIUM

Description: Asymmetrical validation on L1 vs L2 sides creates situations where funds can be deposited but not withdrawn, or vice versa. Different access control rules trap user funds.

Detection Patterns:

// VULNERABLE: L1 has whitelist, L2 doesn't
contract L1Bridge {
    mapping(address => bool) public whitelist;

    function depositToL2(uint256 l2Recipient, uint256 amount) external {
        require(whitelist[msg.sender], "Not whitelisted");  // Whitelist on L1
        // Send to L2
    }

    function withdrawFromL2(uint256 amount) external {
        // No whitelist check - anyone can withdraw!
    }
}
// VULNERABLE: L2 allows deposits but L1 blocks withdrawals
#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,
    user: ContractAddress,
    amount: u256
) {
    // No restrictions - accepts from any L1 address
    self.balances.write(user, self.balances.read(user) + amount);
}

#[external(v0)]
fn initiate_withdrawal(ref self: ContractState, amount: u256) {
    // User can initiate withdrawal on L2
    send_message_to_l1_syscall(to_address, payload);
}

// But L1 contract has restrictions preventing withdrawal completion!

What to Check:

  • [ ] Same validation rules on both L1 and L2

  • [ ] If L1 has whitelist, L2 should too (or vice versa)

  • [ ] If L1 has blacklist, L2 should too

  • [ ] Access controls symmetric across layers

  • [ ] Test full roundtrip: deposit on L1 → withdraw on L1

Mitigation:

// SECURE: Symmetric validation on L1
contract L1Bridge {
    mapping(address => bool) public blockedUsers;

    function depositToL2(uint256 l2Recipient, uint256 amount) external {
        require(!blockedUsers[msg.sender], "User blocked");
        // Process deposit
    }

    function finalizeWithdrawal(uint256 recipient, uint256 amount) external {
        // Validate L2 message
        require(!blockedUsers[msg.sender], "User blocked");
        // Process withdrawal
    }
}
// SECURE: Symmetric validation on L2
#[storage]
struct Storage {
    blocked_users: LegacyMap<ContractAddress, bool>,
}

#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,
    user: ContractAddress,
    amount: u256
) {
    // Same validation as L1
    assert(!self.blocked_users.read(user), 'User blocked');
    self.balances.write(user, self.balances.read(user) + amount);
}

#[external(v0)]
fn initiate_withdrawal(ref self: ContractState, amount: u256) {
    let user = get_caller_address();

    // Same validation as L1
    assert(!self.blocked_users.read(user), 'User blocked');

    // Process withdrawal
}

Testing Strategy:

// Test full roundtrip scenarios
#[test]
fn test_deposit_and_withdraw_roundtrip() {
    // 1. Deposit on L1
    // 2. Verify balance on L2
    // 3. Initiate withdrawal on L2
    // 4. Finalize withdrawal on L1
    // 5. Verify funds returned

    // Should succeed for all valid users
}

#[test]
fn test_blocked_user_cannot_roundtrip() {
    // Blocked user should fail at EVERY step
    // Not just one side
}

References: building-secure-contracts/not-so-smart-contracts/cairo/l1_l2_overconstrained


4.5 SIGNATURE REPLAY PROTECTION ⚠️ HIGH

Description: Signatures without nonce tracking and domain separation can be replayed: same signature used multiple times on the same chain, or across different chains (mainnet/testnet).

Detection Patterns:

// VULNERABLE: No nonce tracking
#[external(v0)]
fn execute_with_signature(
    ref self: ContractState,
    to: ContractAddress,
    amount: u128,
    signature: Array<felt252>
) {
    // Verify signature
    let message_hash = hash_message(to, amount);
    let signer = recover_signer(message_hash, signature);

    // WRONG: No nonce! Same signature can be replayed infinitely
    self.transfer_internal(signer, to, amount);
}

// VULNERABLE: No chain ID in signature
fn hash_message(to: ContractAddress, amount: u128) -> felt252 {
    // Missing chain ID - signature valid on mainnet AND testnet!
    pedersen_hash(to.into(), amount.into())
}

What to Check:

  • [ ] Signatures include nonce that is incremented after each use

  • [ ] Nonce stored per signer address

  • [ ] Signatures include domain separator (chain ID, contract address)

  • [ ] Signature hash includes all relevant parameters

  • [ ] Cannot replay same signature twice

  • [ ] Cannot replay signature from testnet on mainnet

Mitigation:

// SECURE: Proper signature with nonce and domain separator
use openzeppelin::account::interface::ISRC6;

#[storage]
struct Storage {
    nonces: LegacyMap<ContractAddress, felt252>,
    // Other storage
}

#[external(v0)]
fn execute_with_signature(
    ref self: ContractState,
    to: ContractAddress,
    amount: u128,
    nonce: felt252,
    signature: Array<felt252>
) {
    // Get signer's current nonce
    let signer = get_caller_address();
    let current_nonce = self.nonces.read(signer);

    // Verify nonce matches
    assert(nonce == current_nonce, 'Invalid nonce');

    // Build message hash with domain separator
    let message_hash = self.hash_message_with_domain(
        signer,
        to,
        amount,
        nonce
    );

    // Verify signature
    let is_valid = self.verify_signature(signer, message_hash, signature);
    assert(is_valid, 'Invalid signature');

    // Increment nonce BEFORE execution (reentrancy protection)
    self.nonces.write(signer, current_nonce + 1);

    // Execute operation
    self.transfer_internal(signer, to, amount);
}

fn hash_message_with_domain(
    self: @ContractState,
    signer: ContractAddress,
    to: ContractAddress,
    amount: u128,
    nonce: felt252
) -> felt252 {
    // Include domain separator for replay protection
    let domain_separator = self.get_domain_separator();

    // Hash all parameters including domain and nonce
    let mut message = array![
        domain_separator,
        signer.into(),
        to.into(),
        amount.into(),
        nonce
    ];

    poseidon_hash_span(message.span())
}

fn get_domain_separator(self: @ContractState) -> felt252 {
    // EIP-712 style domain separator
    let chain_id = get_tx_info().unbox().chain_id;
    let contract_address = get_contract_address();

    poseidon_hash_span(
        array!['StarkNet Domain', chain_id, contract_address.into()].span()
    )
}

Using OpenZeppelin:

// BETTER: Use OpenZeppelin Account implementation
use openzeppelin::account::Account;
use openzeppelin::account::interface::AccountABIDispatcher;

// OpenZeppelin Account automatically handles:
// - Nonce management
// - Domain separation
// - Signature verification
// - Replay protection

Testing:

#[test]
#[should_panic(expected: ('Invalid nonce',))]
fn test_cannot_replay_signature() {
    // Execute with signature once
    contract.execute_with_signature(to, amount, nonce, signature);

    // Try to replay same signature - should fail
    contract.execute_with_signature(to, amount, nonce, signature);
}

#[test]
#[should_panic]
fn test_cannot_use_testnet_signature_on_mainnet() {
    // Generate signature on testnet
    let testnet_signature = sign_on_testnet(message);

    // Try to use on mainnet - should fail due to domain separator
    mainnet_contract.execute_with_signature(..., testnet_signature);
}

Tool Detection:

  • Caracal detector: missing-nonce-validation

  • Look for: Signature verification without nonce checks

References: building-secure-contracts/not-so-smart-contracts/cairo/signature_replay


4.6 UNCHECKED from_address IN L1 HANDLER ⚠️ CRITICAL

Description: L1 handler functions (#[l1_handler]) can be invoked by any L1 contract unless from_address is validated. Missing validation allows unauthorized L1 contracts to send messages.

Detection Patterns:

// VULNERABLE: No from_address validation
#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,  // Not validated!
    user: ContractAddress,
    amount: u256
) {
    // WRONG: Any L1 contract can call this and mint tokens!
    let current_balance = self.balances.read(user);
    self.balances.write(user, current_balance + amount);
}

// VULNERABLE: Only validates on L1 side
// L1 Contract has access control
function depositToL2() external onlyAuthorized {
    starknetCore.sendMessageToL2(...);
}

// But L2 doesn't check - attacker deploys their own L1 contract!
#[l1_handler]
fn handle_deposit(ref self: ContractState, from_address: felt252, ...) {
    // No check that from_address == AUTHORIZED_L1_CONTRACT
}

What to Check:

  • [ ] ALL #[l1_handler] functions validate from_address

  • [ ] Validation: from_address == expected_l1_contract_address

  • [ ] Expected L1 addresses stored in contract storage

  • [ ] Cannot skip validation even if L1 contract has access control

Mitigation:

// SECURE: Validate from_address in L1 handler
#[storage]
struct Storage {
    l1_bridge_address: felt252,
    balances: LegacyMap<ContractAddress, u256>,
}

#[constructor]
fn constructor(ref self: ContractState, l1_bridge: felt252) {
    self.l1_bridge_address.write(l1_bridge);
}

#[l1_handler]
fn handle_deposit(
    ref self: ContractState,
    from_address: felt252,
    user: ContractAddress,
    amount: u256
) {
    // CRITICAL: Validate from_address is authorized L1 bridge
    let authorized_l1_bridge = self.l1_bridge_address.read();
    assert(from_address == authorized_l1_bridge, 'Unauthorized L1 sender');

    // Validate user address
    let zero_address: ContractAddress = 0.try_into().unwrap();
    assert(user != zero_address, 'Invalid user address');

    // Now safe to process deposit
    let current_balance = self.balances.read(user);
    self.balances.write(user, current_balance + amount);

    // Emit event
    self.emit(DepositProcessed { user, amount, from_l1: from_address });
}

// Admin function to update L1 bridge (if needed)
#[external(v0)]
fn update_l1_bridge(ref self: ContractState, new_l1_bridge: felt252) {
    // Access control
    self.ownable.assert_only_owner();

    self.l1_bridge_address.write(new_l1_bridge);
    self.emit(L1BridgeUpdated { new_address: new_l1_bridge });
}

Testing:

#[test]
#[should_panic(expected: ('Unauthorized L1 sender',))]
fn test_unauthorized_l1_sender_rejected() {
    let unauthorized_address = 0x123; // Wrong L1 address

    // Should reject
    contract.handle_deposit(
        from_address: unauthorized_address,
        user: user_address,
        amount: 1000
    );
}

#[test]
fn test_authorized_l1_sender_accepted() {
    let authorized_address = l1_bridge_address;

    // Should succeed
    contract.handle_deposit(
        from_address: authorized_address,
        user: user_address,
        amount: 1000
    );

    assert(contract.balances(user_address) == 1000);
}

Tool Detection:

  • Caracal detector: unchecked-l1-handler-from

  • Look for: #[l1_handler] without from_address validation

References: building-secure-contracts/not-so-smart-contracts/cairo/unchecked_l1_handler_from


Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Trail of Bits
Publisher
Trail of Bits (Official)
Source
trailofbits/skills · plugins/building-secure-contracts/skills/cairo-vulnerability-scanner · commit e4dd0bb
Licence
Creative Commons Attribution-ShareAlike 4.0 International (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Trail of Bits or Trail of Bits.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download cairo-vulnerability-scanner.zip