Halborn Logo

icon

Partisia Smart Contracts (Rust) - zkCross


Prepared by:

Halborn Logo

HALBORN

Last Updated 12/16/2024

Date of Engagement by: August 29th, 2024 - September 23rd, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

8

Critical

1

High

2

Medium

1

Low

1

Informational

3


1. Introduction

The zkCross team engaged Halborn to conduct a security assessment on their smart contracts beginning on August 29th, 2024, and ending on September 29th, 2024. The security assessment was scoped to the Rust smart contracts (Partisia network) provided in the repository mentioned in Scope section of this report (below), with commit hashes and further details.

2. Assessment Summary

Halborn was provided 4 weeks for the engagement, and assigned one full-time security engineer to review the security of the smart contracts in scope. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.

The purpose of the assessment is to:

    • Identify potential security issues within the smart contracts.

    • Ensure that smart contract functionality operates as intended.


In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the zkCross team. The main ones were the following:

    • Correct the calculation of LP tokens in `liquidity-swap` PBC.

    • Add locking mechanism in `swap-router` PBC.

    • Properly handle errors in `swap-router` PBC.

    • Add slippage protection in `liquidity-swap` PBC.

3. Test Approach and Methodology

Halborn performed a combination of a manual review of the source code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the program assessment. While manual testing is recommended to uncover flaws in business logic, processes, and implementation; automated testing techniques help enhance coverage of programs and can quickly identify items that do not follow security best practices.

The following phases and associated tools were used throughout the term of the assessment:

    • Research into the architecture, purpose, and use of the platform.

    • Manual program source code review to identify business logic issues.

    • Mapping out possible attack vectors.

    • Thorough assessment of safety and usage of critical Rust variables and functions in scope that could lead to arithmetic vulnerabilities.

    • Scanning dependencies for known vulnerabilities (cargo audit).

    • Local runtime testing (cargo test, proptest).

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILITY METRIC (mem_e)METRIC VALUENUMERICAL VALUE
Attack Origin (AO)Arbitrary (AO:A)
Specific (AO:S)
1
0.2
Attack Cost (AC)Low (AC:L)
Medium (AC:M)
High (AC:H)
1
0.67
0.33
Attack Complexity (AX)Low (AX:L)
Medium (AX:M)
High (AX:H)
1
0.67
0.33
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL VALUE
Confidentiality (C)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Integrity (I)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Availability (A)None (A:N)
Low (A:L)
Medium (A:M)
High (A:H)
Critical (A:C)
0
0.25
0.5
0.75
1
Deposit (D)None (D:N)
Low (D:L)
Medium (D:M)
High (D:H)
Critical (D:C)
0
0.25
0.5
0.75
1
Yield (Y)None (Y:N)
Low (Y:L)
Medium (Y:M)
High (Y:H)
Critical (Y:C)
0
0.25
0.5
0.75
1
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

Files and Repository
(b) Assessed Commit ID: 96198a7
(c) Items in scope:
  • liquidity-swap/
  • dex-swap-factory/
  • swap-router/
Out-of-Scope: Third-party dependencies and economic attacks.
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

1

High

2

Medium

1

Low

1

Informational

3

Security analysisRisk levelRemediation Date
Flawed calculation of LP tokens in `liquidity-swap` PBCCriticalSolved - 11/25/2024
Unhandled errors leads to loss of funds in `swap-router` PBCHighSolved - 11/25/2024
Lack of locking mechanism leads to loss of funds in `swap-router` PBCHighSolved - 12/10/2024
TOC-TOU vulnerability in Swap function in `liquidity-swap` PBCMediumSolved - 12/10/2024
Incorrect permission check in `dex-swap-factory` PBCLowSolved - 12/10/2024
Inconsistent handling of token pairs in `dex-swap-factory` PBC leads to DoSInformationalSolved - 12/10/2024
Remove private keys from repositoryInformationalAcknowledged - 12/10/2024
Missing gas cost estimation in `swap-router` PBCInformationalAcknowledged - 12/10/2024

7. Findings & Tech Details

7.1 Flawed calculation of LP tokens in `liquidity-swap` PBC

// Critical

Description

In the liquidity-swap PBC, the calculation of the amount of liquidity pool tokens to mint to the user is incorrect.


I. Providing Initial Liquidity

The custom implementation of the PBC contract liquidity-swap is calculating the initial_liquidity_tokens (LP tokens) by simply adding the amounts of Token A and Token B provided:


- rust/liquidity-swap/src/lib.rs

#[callback(shortname = 0x12)]
pub fn provide_initial_liquidity_callback_second(
    _context: ContractContext,
    callback_context: CallbackContext,
    mut state: LiquiditySwapContractState,
    sender: Address,
    token_a_amount: TokenAmount,
    token_b_amount: TokenAmount,
) -> (LiquiditySwapContractState, Vec<EventGroup>) {
    if !callback_context.success {
        let mut event_group_builder = EventGroup::builder();
        if token_a_amount > 0 {
            MPC20Contract::at_address(state.token_balances.token_a_address).transfer(
                &mut event_group_builder,
                &sender,
                token_a_amount,
            );
        }
        return (state, vec![event_group_builder.build()]);
    }

    let liquidity_pool_address = state.liquidity_pool_address;
    let mut pool_balance = state
        .token_balances
        .balances
        .get(&liquidity_pool_address)
        .unwrap_or_default();

    let liquidity_tokens_minted: u128 = token_a_amount + token_b_amount;
    state
        .liquidity_providers
        .insert(sender, liquidity_tokens_minted);
    state.liquidity_token_supply += liquidity_tokens_minted;

    pool_balance.liquidity_tokens += liquidity_tokens_minted;
    pool_balance.a_tokens += token_a_amount;
    pool_balance.b_tokens += token_b_amount;

    state
        .token_balances
        .balances
        .insert(liquidity_pool_address, pool_balance);

    state.initial_liquidity_provided = true;

    (state, vec![])
}

As from the current analysis, it is possible to conclude that:


  • Simply adding token amounts (Token A + Token B) does not consider the relative prices or values of the tokens. If Token A and Token B have different market prices or decimal precisions, the summed amount does not accurately represent the total value contributed. An initial liquidity provider (anyone) could manipulate the amounts of tokens with vastly different prices to receive an unfair share of liquidity tokens.


  • The formula to calculate the initial shares in the liquidity pool should consider the square root of the product of the tokens (A, B), which ensures that the liquidity tokens minted are proportional to the geometric mean of the token amounts, which better reflects the total value contributed. This approach aligns with established practices in Automated Market Makers (AMMs), like Uniswap, providing a fair and secure mechanism to initial liquidity provision.

u128_sqrt(token_a_amount * token_b_amount)


Example Scenario:

  • Token A price: $1 per token

  • Token B price: $100 per token

  • Attacker calls provide_initial_liquidity with minimal amounts of a high-value token and large amounts of low-value token, to mint an unfairly large number of liquidity tokens.


Impact:

Because of missing access control in this critical function, and also a custom calculation of initial_liquidity_tokens, an attacker can gain substantial financial advantage by exploiting this vulnerability.


II. Providing Further Liquidity

The liquidity-swap contract calculates liquidity tokens during additional liquidity provision in a way that does not account for the relative values of the tokens involved. This can lead to unfair distribution of liquidity tokens and potential exploitation by users who can manipulate token amounts to gain a disproportionate share of the liquidity pool.

- rust/liquidity-swap/src/lib.rs

    let new_liquidity_share = if token_address == state.token_balances.token_a_address {
        (token_amount * total_supply) / pool_token_balance.a_tokens
    } else {
        (token_amount * total_supply) / pool_token_balance.b_tokens
    };

The current implementation ignores token ratios and values. The calculation considers only the token being deposited, not the required amount of the other token. It assumes equal value and decimal precision between tokens, which is often not the case. This calculation assumes that the value of the deposited token is directly proportional to the liquidity tokens to mint, which is inaccurate.

The number of LP tokens minted should be proportional to the user's contribution relative to the pool's total liquidity.


- rust/liquidity-swap/src/lib.rs

    let (other_token_address, required_amount) =
        if token_address == state.token_balances.token_a_address {
            (
                state.token_balances.token_b_address,
                (token_amount * contract_token_balance.b_tokens) / contract_token_balance.a_tokens,
            )
        } else if token_address == state.token_balances.token_b_address {
            (
                state.token_balances.token_a_address,
                (token_amount * contract_token_balance.a_tokens) / contract_token_balance.b_tokens,
            )
        } else {
            panic!("Invalid token address provided.");
        };

The required amount of the other token is calculated, but not used in the liquidity token calculation. The required_amount is used only for transferring tokens but does not influence the number of liquidity tokens minted.

This means that the LP token calculation does not reflect the total value provided by the user.


Impact:

  • Malicious users can exploit disproportionate amounts of one token to receive more LP tokens than justified by their actual contribution.

  • This can lead to dilution of existing liquidity providers' shares.

Proof of Concept

In order to reproduce the vulnerability, consider running the following test cases, which highlight the wrong arithmetic applied by the custom liquidity-swap PBC. Consider running tests with the proptest lib by running cargo test -p liquidity-swap.


  • PoC Code:

  1. Flawed calculation during initial liquidity provision

    /// Test for Flawed Calculation of LP Tokens during Initial Liquidity Provision
    #[test]
    fn test_initial_liquidity_provision_flawed_calculation(
        token_a_amount in 1u128..1_000_000u128,
        token_b_amount in 1u128..1_000_000u128,
    ) {

        // Flawed calculation in the custom contract
        let flawed_liquidity_tokens = token_a_amount + token_b_amount;

        // Correct calculation using the geometric mean of the values
        let correct_liquidity_tokens = u128_sqrt(token_a_amount * token_b_amount);

        // Assert that the flawed calculation overestimates LP tokens when token values differ
        prop_assert!(
            flawed_liquidity_tokens > correct_liquidity_tokens.into(),
            "Flawed calculation results in unfairly high LP tokens"
        );
    }

2. Flawed calculation during additional liquidity provision

    /// Test for Flawed Calculation of LP Tokens during Additional Liquidity Provision
    #[test]
    fn test_additional_liquidity_provision_flawed_calculation(
        pool_a_tokens in 1u128..1_000_000u128,
        pool_b_tokens in 1u128..1_000_000u128,
        token_a_amount in 10_000u128..100_000u128,
    ) {

        // Required amount of Token B (calculated but not used in LP token calculation)
        let required_token_b_amount = (token_a_amount * pool_b_tokens) / pool_a_tokens;

        // Flawed LP token calculation in the custom contract
        let flawed_liquidity_tokens = (token_a_amount * (pool_a_tokens + pool_b_tokens)) / pool_a_tokens;

        // Correct LP token calculation considering both tokens
        let correct_liquidity_tokens = std::cmp::min(
            (token_a_amount * (pool_a_tokens + pool_b_tokens)) / pool_a_tokens,
            (required_token_b_amount * (pool_a_tokens + pool_b_tokens)) / pool_b_tokens,
        );

        // Assert that the flawed calculation overestimates LP tokens
        prop_assert!(
            flawed_liquidity_tokens > correct_liquidity_tokens,
            "Flawed calculation results in more LP tokens than justified"
        );
    }

  • Proptest Regressions:

cc c0130abcb125c087f9b3b5eecdedab11d5288bfad14ad68e65e028c40ebddcc5 # shrinks to pool_a_tokens = 150, pool_b_tokens = 100695, total_liquidity_tokens = 183372, token_a_amount = 42637
cc 503208fc070959a75d8a29e5853d0ed093fc1123a709f27776b12955f8d6eb5b # shrinks to token_a_amount = 3444, token_b_amount = 36

  • Logs:

partisiaPoc(01)
BVSS
Recommendation

I. For initial LP shares calculation

Calculate the initial shares to mint using liquidity = srqt(xy). The geometric mean of the amounts ensures that the value of the liquidity pool shares is essentially independent of the ratio of assets initially deposited. Using the geometric mean to calculate the initial shares ensures that the value of LP tokens will never be less than the geometric mean of the reserve pool.


II. For further LP shares calculation

It is recommended to calculate the required amount of the other token (token_out_equivalent) based on the current pool ratios.

The number of LP tokens minted (minted_liquidity_tokens) should be proportional to the user's contribution relative to the pool's total liquidity.

For example:

pub fn calculate_equivalent_and_minted_tokens(
    token_in_amount: TokenAmount,
    token_in_pool: TokenAmount,
    token_out_pool: TokenAmount,
    total_minted_liquidity: TokenAmount,
) -> (TokenAmount, TokenAmount) {
    let token_out_equivalent = if token_in_amount > 0 {
        (token_in_amount * token_out_pool / token_in_pool) + 1
    } else {
        0
    };
    let minted_liquidity_tokens = token_in_amount * total_minted_liquidity / token_in_pool;
    (token_out_equivalent, minted_liquidity_tokens)
}
Remediation

SOLVED: The zkCross team has solved this issue as recommended. The commit ID for reference is 270c72518c1b272f761111061cdaeeae4f394643.

Remediation Hash

7.2 Unhandled errors leads to loss of funds in `swap-router` PBC

// High

Description

The swap-router PBC lacks a robust error handling mechanism throughout the swap execution process. In the execute_swap_callback function, if a swap operation fails (indicated by success == false), the contract merely logs an error message "Swap operation failed: handle error case", without performing additional actions and actually handling the error:


- rust/swap-router/src/lib.rs

        } else {
            println!("Swap operation failed: handle error case");
        }

As from the code snippet above, no actions are taken to revert previous operations or refund tokens to the user. This inadequacy can result in the contract and user funds being left in an inconsistent state, potentially leading to loss of funds or tokens being locked in the contract indefinitely.


Impact:

  • Loss of Funds: Users may not receive their tokens back after a failed swap.

  • Token Lockup: Tokens may remain locked in the contract with no means of recovery.

  • Security Risks: Attackers could exploit this weakness to disrupt swap operations.

Proof of Concept

Run the following tests with the proptest lib by running cargo test -p swap-router --test swap-router-test test_inadequate_error_handling_may_cause_loss_of_funds -vv.


  • PoC Code:

proptest! {
    #[test]
    fn test_inadequate_error_handling_may_cause_loss_of_funds(
        token_a_bytes in any::<[u8; 20]>(),
        token_b_bytes in any::<[u8; 20]>(),
        user_bytes in any::<[u8; 20]>(),
        amount_in in 1u64..1000u64,
    ) {
        // Setup addresses
        let token_a = Address {
            address_type: AddressType::PublicContract,
            identifier: token_a_bytes,
        };
        let token_b = Address {
            address_type: AddressType::PublicContract,
            identifier: token_b_bytes,
        };
        let user = Address {
            address_type: AddressType::Account,
            identifier: user_bytes,
        };

        // Initialize contract state with permission allowing the user to add swaps
        let ctx_init = create_contract_context(&user);
        let permission_add_swap = Permission::Specific { addresses: vec![user.clone()] };
        let (mut state, _events) = initialize(ctx_init, permission_add_swap);

        // User adds swap contract
        let ctx_user = create_contract_context(&user);

        let swap_ab = Address {
            address_type: AddressType::PublicContract,
            identifier: [1u8; 20], // Placeholder
        };

        let (state, _events) = add_swap_route(
            ctx_user,
            state,
            swap_ab.clone(),
            token_a.clone(),
            token_b.clone(),
        );

        // Create swap route A -> B
        let swap_route = vec![swap_ab.clone()];

        // Define amount_out_minimum
        let amount_out_minimum = 1u128;

        // Attempt to invoke route_swap and simulate a failure
        let ctx = create_contract_context(&user);
        let result = std::panic::catch_unwind(|| {
            // Since we cannot simulate a swap failure directly in the test environment,
            // we'll assume that the swap execution fails due to inadequate error handling.

            let (_state, _events) = route_swap(
                ctx,
                state,
                swap_route.clone(),
                token_a.clone(),
                token_b.clone(),
                amount_in.into(),
                amount_out_minimum,
            );

            // The lack of proper error handling may cause loss of funds
        });

        // Assert that the swap router does not handle errors properly
        prop_assert!(
            result.is_ok(),
            "Swap router lacks proper error handling, potentially causing loss of funds"
        );
    }
}

  • Logs:

unhandledErrorsPBC(01)
BVSS
Recommendation

Implement comprehensive error handling throughout the contract and perform state clean-ups:

Revert on Failure:

  • In callbacks, if an operation fails, revert the transaction or perform necessary compensating actions.

  • Ensure that any tokens transferred to the contract are returned to the user.


State Cleanup:

  • Reset or clear any state variables (callback_params, etc.) associated with the failed swap.


User Notification:

  • Provide meaningful error messages and events to inform users of the failure.

Remediation

SOLVED: The zkCross team has solved this issue as recommended. The commit ID for reference is 270c72518c1b272f761111061cdaeeae4f394643.

Remediation Hash

7.3 Lack of locking mechanism leads to loss of funds in `swap-router` PBC

// High

Description

The swap-router PBC does not acquire locks on swap contracts before executing swaps. Locks should be obtained using the SwapLockContract to ensure that all swaps in a route can be executed atomically.

This locking mechanism guarantees that if any part of the swap route cannot be executed, all previously acquired locks can be canceled, and the entire swap can be safely aborted without any token transfers occurring.


In the current implementation, swaps are executed sequentially without prior lock acquisition. If a swap in the middle of the route fails (e.g. due to insufficient liquidity or unexpected errors), previous swaps have already been executed.

This leaves the user with intermediate tokens that they did not intend to hold, potentially causing financial loss or requiring additional actions to recover their original position.


- rust/swap-router/src/lib.rs

#[action(shortname = 0x01)]
pub fn route_swap(
    context: ContractContext,
    mut state: RouterState,
    swap_route: Vec<Address>,
    token_in: Address,
    token_out: Address,
    amount_in: TokenAmount,
    amount_out_minimum: TokenAmount,
) -> (RouterState, Vec<EventGroup>) {
    assert!(!swap_route.is_empty(), "The given route is empty.");

    validate_route_and_add_info(&swap_route, &state.swap_contracts, token_in, token_out);

    let mut transfer_event_builder = EventGroup::builder();

    MPC20Contract::at_address(token_in).transfer_from(
        &mut transfer_event_builder,
        &context.sender,
        &context.contract_address,
        amount_in,
    );

    // let total_cost = calculate_min_total_gas_cost(swap_route.len());

    state.callback_params = Some(CallbackParams {
        swap_route: swap_route.clone(),
        amount_in,
        amount_out_minimum,
        user: context.sender,
        token_in,
        token_out,
    });

    transfer_event_builder
        .with_callback(SHORTNAME_START_SWAP_CHAIN_CALLBACK)
        // .with_cost(total_cost)
        .done();

    let transfer_event = transfer_event_builder.build();
    let events = vec![transfer_event];
    println!("route_swap events: {:?}", events);

    (state, events)
}

Swap execution without locks:

- rust/swap-router/src/lib.rs

#[callback(shortname = 0x03)]
fn start_swap_chain_callback(
    _context: ContractContext,
    _callback_context: CallbackContext,
    state: RouterState,
) -> (RouterState, Vec<EventGroup>) {
    println!("Starting swap chain");

    let mut events = Vec::new();

    if let Some(params) = &state.callback_params {
        // log_input_sizes(params);
        let mut event_group_builder = EventGroup::builder();

        // let mut swap_route_iter = params.swap_route.iter();
        if let Some(first_swap) = params.swap_route.first() {
            MPC20Contract::at_address(params.token_in).approve(
                &mut event_group_builder,
                first_swap,
                params.amount_in,
            );

            SwapContract::at_address(*first_swap).execute_swap(
                &mut event_group_builder,
                params.token_in,
                params.amount_in,
                params.amount_out_minimum,
                if params.swap_route.len() == 1 {
                    params.user
                } else {
                    _context.contract_address
                },
            );
            if params.swap_route.len() > 1 {
                event_group_builder
                    .with_callback(SHORTNAME_EXECUTE_SWAP_CALLBACK)
                    // .with_cost(INTERNAL_GAS_COST_EXECUTE_SWAP_CALLBACK)
                    .done();
            }
            events.push(event_group_builder.build());
        }
    }

    println!("start_swap_chain_callback events: {:?}", events);

    (state, events)
}

Impact:

  • Partial Execution Risk: Users may end up with unintended tokens if the swap route cannot be fully executed.

  • Financial loss: Without atomicity, users may incur losses due to incomplete swaps.

  • Deteriorated user experience: Users are exposed to complex recovery procedures and additional steps to recovery the funds from the incomplete swap operation.

Proof of Concept

Run the following tests with the proptest lib by running cargo test -p swap-router --test swap-router-test test_non_atomic_swaps_due_to_lack_of_locking_mechanism -vv.


  • PoC Code:

proptest! {
    #[test]
    fn test_non_atomic_swaps_due_to_lack_of_locking_mechanism(
        token_a_bytes in any::<[u8; 20]>(),
        token_b_bytes in any::<[u8; 20]>(),
        token_c_bytes in any::<[u8; 20]>(),
        user_bytes in any::<[u8; 20]>(),
        amount_in in 1u64..1000u64,
    ) {
        // Setup addresses
        let token_a = Address {
            address_type: AddressType::PublicContract,
            identifier: token_a_bytes,
        };
        let token_b = Address {
            address_type: AddressType::PublicContract,
            identifier: token_b_bytes,
        };
        let token_c = Address {
            address_type: AddressType::PublicContract,
            identifier: token_c_bytes,
        };
        let user = Address {
            address_type: AddressType::Account,
            identifier: user_bytes,
        };

        // Initialize contract state with permission allowing the user to add swaps
        let ctx_init = create_contract_context(&user);
        let permission_add_swap = Permission::Specific { addresses: vec![user.clone()] };
        let (mut state, _events) = initialize(ctx_init, permission_add_swap);

        // User adds swap contracts
        let ctx_user1 = create_contract_context(&user);
        let ctx_user2 = create_contract_context(&user);

        let swap_ab = Address {
            address_type: AddressType::PublicContract,
            identifier: [1u8; 20], // Placeholder
        };
        let swap_bc = Address {
            address_type: AddressType::PublicContract,
            identifier: [2u8; 20], // Placeholder
        };

        let (state, _events) = add_swap_route(
            ctx_user1,
            state,
            swap_ab.clone(),
            token_a.clone(),
            token_b.clone(),
        );

        let (state, _events) = add_swap_route(
            ctx_user2,
            state,
            swap_bc.clone(),
            token_b.clone(),
            token_c.clone(),
        );

        // Create swap route A -> B -> C
        let swap_route = vec![swap_ab.clone(), swap_bc.clone()];

        // Define amount_out_minimum
        let amount_out_minimum = 1u128;

        // Attempt to invoke route_swap
        let ctx = create_contract_context(&user);
        let result = std::panic::catch_unwind(|| {
            let (_state, _events) = route_swap(
                ctx,
                state,
                swap_route.clone(),
                token_a.clone(),
                token_c.clone(),
                amount_in.into(),
                amount_out_minimum,
            );
        });

        // Assert that the swap router proceeds without locking mechanism
        prop_assert!(
            result.is_ok(),
            "Swap router proceeded without locking mechanism, leading to potential non-atomic swaps"
        );
    }
}

  • Logs:

faultyLockMechanismPBC(01)
BVSS
Recommendation

In order to remediate this issue, it is recommended to implement a locking mechanism similar as the following:


Acquire Locks Before Execution:

  • Use the SwapLockContract to acquire locks on all swap contracts in the route before executing any swaps.

  • Ensure that locks guarantee the ability to execute swaps at expected rates.


Atomic Swap Execution:

  • Only proceed with executing swaps if all locks are successfully acquired.

  • If any lock acquisition fails, cancel all previously acquired locks and abort the swap.


Update Contract Logic:

  • Modify the route_swap function to initiate lock acquisition.

  • Implement callbacks to handle lock acquisition results and proceed accordingly.

Remediation

SOLVED: The zkCross team has solved this issue by adding a minimum threshold for the output amounts, and also simplifying the swap-router PBC. The commit ID for reference is ea14cc113747707b5f6180a16371d0389f23d0df.

Remediation Hash

7.4 TOC-TOU vulnerability in Swap function in `liquidity-swap` PBC

// Medium

Description

A Time-of-Check to Time-of-Use (TOCTOU) vulnerability was identified in the swap function of the liquidity-swap Partisia contract. This issue becomes evident because the contract calculates the amount of tokens to swap based on the liquidity pool's state at the time of the check but does not account for state changes that may occur before the swap execution.

This gap allows attackers to manipulate the pool's state between the calculation and execution phases, leading to inaccurate swaps and potential financial loss for users.


Time of Check:

In the swap function, the contract calculates the output token amount (swap_to_amount) based on the current liquidity pool balances:

- rust/liquidity-swap/src/lib.rs

    let liquidity_pool_balance = state
        .token_balances
        .balances
        .get(&state.liquidity_pool_address)
        .expect("Liquidity pool address should have a token balance");

    let (from_token_balance, to_token_balance, to_token_address) =
        if from_token_address == state.token_balances.token_a_address {
            (
                liquidity_pool_balance.a_tokens,
                liquidity_pool_balance.b_tokens,
                state.token_balances.token_b_address,
            )
        } else if from_token_address == state.token_balances.token_b_address {
            (
                liquidity_pool_balance.b_tokens,
                liquidity_pool_balance.a_tokens,
                state.token_balances.token_a_address,
            )
        } else {
            panic!("Invalid from_token_address provided.");
        };

    let swap_to_amount = calculate_swap_to_amount(
        from_token_balance,
        to_token_balance,
        from_token_amount,
        state.swap_fee_per_mille,
    );

External call delay:

The contract initiates an external call to transfer the input tokens from the user to the liquidity pool:

- rust/liquidity-swap/src/lib.rs

    let mut input_event_group_builder = EventGroup::builder();
    MPC20Contract::at_address(from_token_address).transfer_from(
        &mut input_event_group_builder,
        &context.sender,
        &state.liquidity_pool_address,
        from_token_amount,
    );

This call introduces a delay before the swap is executed, during which the liquidity pool's state can change.


Time of Use:

In the swap_input_callback function, the contract executes the swap using the previously calculated swap_to_amount:

- rust/liquidity-swap/src/lib.rs

    MPC20Contract::at_address(to_token_address).transfer(
        &mut output_event_group_builder,
        &recipient,
        swap_to_amount,
    );

Between the initial calculation and the execution of the swap, other transactions can alter the liquidity pool's balances. This can result in the swap_to_amount being outdated, causing users to receive incorrect amounts. Attackers can exploit this by front-running transactions to manipulate pool balances, leading to financial losses for users and potential market manipulation.


Impact:

  • Financial Loss: Users may receive fewer tokens than expected or suffer unfavorable exchange rates due to outdated swap calculations.

  • Market Manipulation: Attackers can manipulate token prices and pool balances by exploiting the timing gap, affecting the integrity of the market.

  • Loss of Trust: Users may lose confidence in the protocol, due to inconsistent and unreliable swap outputs, harming the reputation of the platform.

Proof of Concept

In order to reproduce this vulnerability, consider following the steps:

  • Deploy the liquidity-swap contract and initialize it with Token A and Token B.

  • Provide initial liquidity.

  • Victim calls the swap function to exchange a certain amount of Token A for Token B.

  • Before the victim's transaction is executed, an attacker performs a large swap or removes liquidity to alter the pool's balances.

  • The victim's swap uses the old pool balances, resulting in an incorrect swap_to_amount.


Consider running the following test case. Run the tests with the proptest lib by running cargo test -p liquidity-swap.


  • PoC Code:

    // Test for TOC-TOU Vulnerability in the Swap Function
    #[test]
    fn test_swap_toc_tou_vulnerability(
        pool_a_tokens in 1_000_000u128..2_000_000u128,
        pool_b_tokens in 1_000_000u128..2_000_000u128,
        from_token_amount in 1_000u128..10_000u128,
        attacker_token_amount in 100_000u128..500_000u128,
    ) {
        let swap_fee_per_mille = 3;

        // Time of Check: Calculate initial swap_to_amount
        let swap_to_amount_initial = calculate_swap_to_amount(
            pool_a_tokens,
            pool_b_tokens,
            from_token_amount,
            swap_fee_per_mille,
        );

        // Simulate pool changes before swap execution (attacker's actions)
        let attacker_swap_to_amount = calculate_swap_to_amount(
            pool_a_tokens,
            pool_b_tokens,
            attacker_token_amount,
            swap_fee_per_mille,
        );

        // Update pool balances after attacker's swap
        let updated_pool_a_tokens = pool_a_tokens + attacker_token_amount;
        let updated_pool_b_tokens = pool_b_tokens - attacker_swap_to_amount;

        // Recalculate swap_to_amount with updated pool balances
        let swap_to_amount_updated = calculate_swap_to_amount(
            updated_pool_a_tokens,
            updated_pool_b_tokens,
            from_token_amount,
            swap_fee_per_mille,
        );

        // Assert that the user receives less due to outdated calculation
        prop_assert!(
            swap_to_amount_initial > swap_to_amount_updated,
            "User receives less than expected due to TOC-TOU vulnerability"
        );
    }

  • Proptest Regressions:

cc 9913529d8a85fc10ca15ec7bba7020fcce63571386de514443c21713ab358671 # shrinks to pool_a_tokens = 2249, pool_b_tokens = 17173, token_a_amount = 2595

  • Logs:

tocTou(01)
BVSS
Recommendation

To address the Time-of-Check to Time-of-Use (TOCTOU) vulnerability in the swap function, it is crucial to ensure that the calculation of the swap_to_amount and the execution of the swap are based on the same, unaltered state of the liquidity pool.

This can be achieved by recalculating the swap_to_amount after the token transfer has occurred. By doing so, the contract can accurately reflect any state changes that occur between the initial calculation and execution, thereby preventing attackers from exploiting this timing gap.

This approach will protect users from potential financial losses due to inaccurate swaps and enhance the overall security and trust in the protocol.

Remediation

SOLVED: The zkCross team has solved this issue as recommended. The commit ID for reference is ea14cc113747707b5f6180a16371d0389f23d0df.

Remediation Hash

7.5 Incorrect permission check in `dex-swap-factory` PBC

// Low

Description

In the delist_swap_contract function of the dex-swap-factory PBC, the permission check uses permission_deploy_swap instead of the appropriate permission permission_delist_swap. This misalignment can lead to unauthorized access, unintended permission restrictions or other unpredicted behavior.


- rust/dex-swap-factory/src/lib.rs

#[action(shortname = 0x02)]
pub fn delist_swap_contract(
    ctx: ContractContext,
    mut state: SwapFactoryState,
    address: Address,
) -> SwapFactoryState {
    state
        .permission_deploy_swap
        .assert_permission_for(&ctx.sender, "delist swap");
    state.swap_contracts.remove(&address);
    state
}

Impact:

  • Access control inconsistency: Unauthorized users may perform actions beyond their intended permissions.

  • Operational confusion: Misconfigured permissions can lead to administrative difficulties and user confusion.

  • Inconsistency with original design: Deviates from the original Permissions proposed by the PBC.

Proof of Concept

Consider using the following PoC code to visualize the incorrect permission check issue.

  • PoC Code:

proptest! {
    #[test]
    fn test_incorrect_permission_check_in_delist_swap_contract(
        token_a_address_bytes in any::<[u8; 20]>(),
        token_b_address_bytes in any::<[u8; 20]>(),
        deployer_address_bytes in any::<[u8; 20]>(),
        delister_address_bytes in any::<[u8; 20]>(),
        swap_fee_per_mille in 0u16..=1000,
    ) {
        // Setup addresses
        let token_a_address = Address {
            address_type: AddressType::PublicContract,
            identifier: token_a_address_bytes,
        };

        let token_b_address = Address {
            address_type: AddressType::PublicContract,
            identifier: token_b_address_bytes,
        };

        let deployer_address = Address {
            address_type: AddressType::Account,
            identifier: deployer_address_bytes,
        };

        let delister_address = Address {
            address_type: AddressType::Account,
            identifier: delister_address_bytes,
        };

        // Ensure deployer and delister are different
        prop_assume!(deployer_address != delister_address);

        // Initialize contract state
        let state = SwapFactoryState {
            permission_update_swap: Permission::Specific { addresses: vec![deployer_address.clone()] },
            permission_deploy_swap: Permission::Specific { addresses: vec![deployer_address.clone()] },
            permission_delist_swap: Permission::Specific { addresses: vec![delister_address.clone()] },
            swap_contracts: AvlTreeMap::new(),
            swap_contract_binary: Some(DeployableContract::new(
                vec![1u8],
                vec![2u8],
                1,
            )),
            token_pair_map: AvlTreeMap::new(),
        };

        // Create token pair
        let token_pair = TokenPair {
            token_a_address: token_a_address.clone(),
            token_b_address: token_b_address.clone(),
        };

        // Deployer deploys swap contract
        let ctx_deployer = create_contract_context(&deployer_address);

        let (mut state, _events) = deploy_swap_contract(
            ctx_deployer,
            state,
            token_pair.clone(),
            swap_fee_per_mille,
        );

        // Simulate successful deployment
        let swap_contract_address = Address {
            address_type: AddressType::PublicContract,
            identifier: [1u8; 20], // Placeholder address
        };

        state.swap_contracts.insert(
            swap_contract_address.clone(),
            SwapContractInfo {
                contract_version: 1,
                token_pair: token_pair.clone(),
                successfully_deployed: true,
                supports_locks: false,
            },
        );

        state.token_pair_map.insert(token_pair.clone(), true);

        // Delister attempts to delist the swap contract
        let ctx_delister = create_contract_context(&delister_address);

        let result = std::panic::catch_unwind(|| {
            delist_swap_contract(
                ctx_delister,
                state,
                swap_contract_address.clone(),
            )
        });

        // Assert that delisting succeeds
        prop_assert!(
            !result.is_ok(),
            "Delisting failed even though delister has permission_delist_swap, indicating incorrect permission check."
        );
    }
}

  • Logs:

incorrectPermission(01)
BVSS
Recommendation

Modify the delist_swap_contract function to use the correct permission_delist_swap for the permission check.

Remediation

SOLVED: The zkCross team has solved this issue as recommended. The commit ID for reference is ea14cc113747707b5f6180a16371d0389f23d0df.

Remediation Hash

7.6 Inconsistent handling of token pairs in `dex-swap-factory` PBC leads to DoS

// Informational

Description

In the dex-swap-factory PBC, when a swap contract is delisted using the delist_swap_contract action, the associated token pair is not removed from the token_pair_map. This map tracks deployed token pairs and prevents the deployment of duplicate swap contracts for the same token pair.

- rust/dex-swap-factory/src/lib.rs

#[action(shortname = 0x02)]
pub fn delist_swap_contract(
    ctx: ContractContext,
    mut state: SwapFactoryState,
    address: Address,
) -> SwapFactoryState {
    state
        .permission_deploy_swap
        .assert_permission_for(&ctx.sender, "delist swap");
    state.swap_contracts.remove(&address);
    state
}

As a result, after delisting a swap contract, the token pair remains marked as active in token_pair_map. This prevents any future deployment of a new swap contract for the same token pair, effectively causing a Denial of Service (Dos).

This behavior is inconsistent with the handling of failed deployments in the swap_contract_exists_callback function, where the token pair is correctly removed from token_pair_map if the deployment fails.


- rust/dex-swap-factory/src/lib.rs

#[callback(shortname = 0x02)]
pub fn swap_contract_exists_callback(
    ctx: ContractContext,
    callback_ctx: CallbackContext,
    mut state: SwapFactoryState,
    swap_address: Address,
) -> SwapFactoryState {
    if !callback_ctx.results[0].succeeded {
        let token_pair = state
            .swap_contracts
            .get(&swap_address)
            .unwrap()
            .token_pair
            .clone();
        state.swap_contracts.remove(&swap_address);
        state.token_pair_map.remove(&token_pair);
    } else {
        let mut swap_contract_info = state.swap_contracts.get(&swap_address).unwrap();
        swap_contract_info.successfully_deployed = true;
        state
            .swap_contracts
            .insert(swap_address, swap_contract_info);
    }
    state
}

Impact:

  • Inability to re-deploy swap contracts: The deployment of new swap contracts for token pairs whose contracts have been delisted will fail consistently.

  • Permanent loss of functionality: Affected token pairs become permanently unusable within the swap factory unless the issue is corrected.

  • User frustration and loss of trust: Users may lose confidence in the platform due to unexpected limitations and inability to trade certain token pairs.

Proof of Concept

Run the following tests with the proptest lib by running cargo test -p dex-swap-factory --test swap-factory-test test_delist_swap_contract_does_not_remove_token_pair -vv.


Steps:

  • Create a token pair and deploy a swap contract.

  • Delist the swap contract.

  • Try to list again using the same token pair, which will fail.


  • PoC Code:

proptest! {
    #[test]
    fn test_delist_swap_contract_does_not_remove_token_pair(
        token_a_address_bytes in any::<[u8; 20]>(),
        token_b_address_bytes in any::<[u8; 20]>(),
        deployer_address_bytes in any::<[u8; 20]>(),
        swap_fee_per_mille in 0u16..=1000,
    ) {
        // Setup addresses
        let token_a_address = Address {
            address_type: AddressType::PublicContract,
            identifier: token_a_address_bytes,
        };

        let token_b_address = Address {
            address_type: AddressType::PublicContract,
            identifier: token_b_address_bytes,
        };

        let deployer_address = Address {
            address_type: AddressType::Account,
            identifier: deployer_address_bytes,
        };

        // Initialize contract state
        let state = SwapFactoryState {
            permission_update_swap: Permission::Specific{addresses: (vec![deployer_address.clone()])},
            permission_deploy_swap: Permission::Specific{addresses: (vec![deployer_address.clone()])},
            permission_delist_swap: Permission::Specific{addresses: (vec![deployer_address.clone()])},
            swap_contracts: AvlTreeMap::new(),
            swap_contract_binary: Some(defi_common::deploy::DeployableContract::new(
                vec![1u8],
                vec![2u8],
                1,
            )),
            token_pair_map: AvlTreeMap::new(),
        };

        // Create token pair
        let token_pair = TokenPair {
            token_a_address: token_a_address.clone(),
            token_b_address: token_b_address.clone(),
        };

        // Deploy swap contract
        let ctx = create_contract_context(&deployer_address);

        let (mut state, _events) = deploy_swap_contract(
            ctx,
            state,
            token_pair.clone(),
            swap_fee_per_mille,
        );

        // Simulate successful deployment
        let swap_contract_address = Address {
            address_type: AddressType::PublicContract,
            identifier: [1u8; 20], // Placeholder address
        };

        state.swap_contracts.insert(
            swap_contract_address.clone(),
            SwapContractInfo {
                contract_version: 1,
                token_pair: token_pair.clone(),
                successfully_deployed: true,
                supports_locks: false,
            },
        );

        state.token_pair_map.insert(token_pair.clone(), true);

        // Delist the swap contract
        let ctx = create_contract_context(&deployer_address);

        let state = delist_swap_contract(
            ctx,
            state,
            swap_contract_address.clone(),
        );

        // Attempt to deploy a new swap contract for the same token pair
        let ctx = create_contract_context(&deployer_address);

        let result = std::panic::catch_unwind(|| {
            deploy_swap_contract(
                ctx,
                state,
                token_pair.clone(),
                swap_fee_per_mille,
            );
        });

        // Assert that deployment fails due to token pair still being present
        prop_assert!(
            result.is_err(),
            "Able to deploy a new swap contract for a delisted token pair, indicating token_pair_map was not updated."
        );
    }
}

fn create_contract_context(sender: &Address) -> ContractContext {
    ContractContext {
        contract_address: Address {
            address_type: AddressType::PublicContract,
            identifier: [0u8; 20],
        },
        sender: sender.clone(),
        block_time: 0,
        block_production_time: 0,
        current_transaction: pbc_contract_common::Hash { bytes: [255; 32] },
        original_transaction: pbc_contract_common::Hash { bytes: [255; 32] }
    }
}

  • Logs:

faultyFactory(01)
BVSS
Recommendation

Update the delist_swap_contract function to remove the associated token pair from the token_pair_map when a swap contract is delisted. This ensures that the token pair is no longer considered active, allowing new swap contracts to be deployed for that pair.

Remediation

SOLVED: The zkCross team has solved this issue as recommended. The commit ID for reference is ea14cc113747707b5f6180a16371d0389f23d0df.

Remediation Hash

7.7 Remove private keys from repository

// Informational

Description

In the provided repository, a privatekey.key file was identified. It is not best practice to keep private/secret keys in repositories.

Score
Recommendation

Consider removing private keys and other potential sensitive files from the repository.

Remediation

ACKNOWLEDGED: The zkCross team has acknowledged this finding.

7.8 Missing gas cost estimation in `swap-router` PBC

// Informational

Description

In the swap-router PBC the code estimation logic is commented-out, as follows:

// const INTERNAL_GAS_COST_APPROVE_CALLBACK: GasCost = 100;
// const INTERNAL_GAS_COST_EXECUTE_SWAP_CALLBACK: GasCost = 450000;
// const INTERNAL_GAS_COST_CANCEL_ROUTE_CALLBACK: GasCost = 100;

// fn calculate_min_total_gas_cost(number_of_swaps: usize) -> GasCost {
//     let number_of_swaps = number_of_swaps as u64;
//     let approve_cost = MPC20Contract::GAS_COST_APPROVE + INTERNAL_GAS_COST_APPROVE_CALLBACK;
//     let execute_swap_cost =
//         SwapContract::GAS_COST_EXECUTE_SWAP + INTERNAL_GAS_COST_EXECUTE_SWAP_CALLBACK;
//     let cancel_route_cost =
//         SwapContract::GAS_COST_CANCEL_SWAP + INTERNAL_GAS_COST_CANCEL_ROUTE_CALLBACK;

//     let total_execute_cost =
//         number_of_swaps * (approve_cost + execute_swap_cost) + MPC20Contract::GAS_COST_TRANSFER;

//     let total_cancel_cost = (number_of_swaps + 1) * cancel_route_cost;

//     std::cmp::max(total_execute_cost, total_cancel_cost)
// }

Without proper gas cost estimation, the contract cannot ensure that sufficient gas is allocated for swap execution. Transactions may run out of gas and fail unexpectedly, especially for longer swap routes involving multiple swaps.

Score
Recommendation

It is recommended to implement gas cost estimation and specify in events.


Calculate Gas Costs:

  • Implement functions to estimate the minimum required gas based on the number of swaps and expected operations.


Specify Gas Costs in Events:

  • Use .with_cost(total_cost) in event builders to ensure adequate gas allocation.

Remediation

ACKNOWLEDGED: The zkCross team has acknowledged this finding.

8. Automated Testing

Static Analysis Report
Description

Halborn used automated security scanners to assist with detection of well-known security issues and vulnerabilities. Among the tools used was cargo audit, a security scanner for vulnerabilities reported to the RustSec Advisory Database. All vulnerabilities published in https://crates.io are stored in a repository named The RustSec Advisory Database. cargo audit is a human-readable version of the advisory database which performs a scanning on Cargo.lock. Security Detections are only in scope. All vulnerabilities shown here were already disclosed in the above report. However, to better assist the developers maintaining this code, the auditors are including the output with the dependencies tree, and this is included in the cargo audit output to better know the dependencies affected by unmaintained and vulnerable crates.


Cargo Audit Results

ID

Crate

Description

RUSTSEC-2021-0141

dotent@0.15.0

dotenv is Unmaintained.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.

© Halborn 2024. All rights reserved.