cairo-vulnerability-scanner
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.
Install
npx skills add https://github.com/trailofbits/skills/tree/main/plugins/building-secure-contracts/skills/cairo-vulnerability-scanner
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install trailofbits-skills@llmmart
git clone https://github.com/trailofbits/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole trailofbits/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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 implementationsrc/lib.cairo- Library modulestests/- Contract testsScarb.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:
- Search your codebase for Cairo files
- Analyze each contract for the 6 vulnerability patterns
- Report findings with file references and severity, above them a coverage table carrying a verdict for every pattern
- Provide fixes for each identified issue
- 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:
- Felt252 Arithmetic Overflow/Underflow ⚠️ HIGH -
felt252wraps silently; useu128/u256 - L1 to L2 Address Conversion ⚠️ HIGH - L1 address not validated against STARKNET_FIELD_PRIME
- L1 to L2 Message Failure ⚠️ HIGH - No cancellation path when a message cannot be consumed
- Overconstrained L1 <-> L2 Interaction ⚠️ MEDIUM - Coupling that can strand funds or block progress
- Signature Replay Protection ⚠️ HIGH - No nonce, or a domain separator missing chain/contract
- 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
- Verify Cairo language and StarkNet framework
- Check Cairo version (Cairo 1.0+ vs legacy Cairo 0)
- Locate contract files (
src/*.cairo) - 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_addressparameter - 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:
| # | Pattern | Verdict | Evidence |
|---|---|---|---|
| 1 | Felt252 Arithmetic Overflow/Underflow | clear |
balances are u256; searched felt252 in arithmetic, none |
| 2 | L1 to L2 Address Conversion | ||
| 3 | L1 to L2 Message Failure | ||
| 4 | Overconstrained L1 <-> L2 Interaction | ||
| 5 | Signature Replay Protection | ||
| 6 | Unchecked from_address in L1 Handler |
Each verdict is one of:
found— citefile:lineand 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 notn/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
- Building Secure Contracts:
building-secure-contracts/not-so-smart-contracts/cairo/ - Caracal: https://github.com/crytic/caracal
- Cairo Documentation: https://book.cairo-lang.org/
- StarkNet Documentation: https://docs.starknet.io/
- OpenZeppelin Cairo Contracts: https://github.com/OpenZeppelin/cairo-contracts
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 validatefrom_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,clearorn/awith a reason
13. Rationalizations to Reject
- "The contract is small, so most patterns obviously don't apply." Obvious to whom? An
n/acosts 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.
felt252does 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_addressis the canonical StarkNet bridge bug.
Files (skills)
-
agents
-
openai.yaml 259 B
interface: display_name: "Cairo Vulnerability Scanner" short_description: "Find security vulnerabilities in Cairo and StarkNet contracts" icon_small: "assets/trail-of-bits-mark.svg" icon_large: "assets/trail-of-bits-mark.svg" brand_color: "#D83A34"
-
-
assets
-
trail-of-bits-mark.svg 3 KB · in bundle
-
-
resources
-
VULNERABILITY_PATTERNS.md 20.8 KB
## 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**: ```rust // 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**: ```rust // 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**: ```rust // 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**: ```solidity // 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); } } ``` ```rust // 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**: ```rust // 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**: ```solidity // 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**: ```solidity // 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**: ```rust // 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**: ```solidity // 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! } } ``` ```rust // 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**: ```solidity // 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 } } ``` ```rust // 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**: ```rust // 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**: ```rust // 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**: ```rust // 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**: ```rust // 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**: ```rust #[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**: ```rust // 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**: ```rust // 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**: ```rust #[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 ---
-
-
SKILL.md 12.8 KB
--- name: cairo-vulnerability-scanner description: 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. --- # 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 ```rust // 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](resources/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](resources/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 ```bash # 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 ```bash # 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: | # | Pattern | Verdict | Evidence | |---|---------|---------|----------| | 1 | Felt252 Arithmetic Overflow/Underflow | `clear` | balances are `u256`; searched `felt252` in arithmetic, none | | 2 | L1 to L2 Address Conversion | | | | 3 | L1 to L2 Message Failure | | | | 4 | Overconstrained L1 <-> L2 Interaction | | | | 5 | Signature Replay Protection | | | | 6 | Unchecked 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 ````markdown ## [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 ```rust #[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) ```rust // 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 ```yaml # .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 - **Building Secure Contracts**: `building-secure-contracts/not-so-smart-contracts/cairo/` - **Caracal**: https://github.com/crytic/caracal - **Cairo Documentation**: https://book.cairo-lang.org/ - **StarkNet Documentation**: https://docs.starknet.io/ - **OpenZeppelin Cairo Contracts**: https://github.com/OpenZeppelin/cairo-contracts --- ## 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.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.