Prepared by:
HALBORN
Last Updated 07/23/2024
Date of Engagement by: May 23rd, 2024 - June 21st, 2024
100% of all REPORTED Findings have been addressed
All findings
4
Critical
1
High
1
Medium
0
Low
1
Informational
1
Entangle Labs
team engaged Halborn
to conduct a security assessment on their ngl-core, ngl-bridge and gorples-bridge Solana programs beginning on May 23rd, 2024 and ending on June 21st, 2024. The security assessment was scoped to the Solana Programs provided in ngl-core, ngl-bridge , and gorples-bridge GitHub repositories. Commit hashes and further details can be found in the Scope section of this report.
Ngl-core is a token owner program, used by ngl-bridge
program to mint and burn tokens
Ngl-bridge is a bridge program, called by Photon CCM endpoint, to receive tokens from other networks or the user to send tokens to other networks.
Gorples Bridge program is a token bridge compatible with Entangle CCM
Halborn
was provided 4 weeks for the engagement and assigned one full-time security engineer to review the security of the Solana Programs in scope. The engineer is a blockchain and smart contract security expert with advanced smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the assessment is to:
Identify potential security issues within the Solana Programs.
Ensure that smart contract functionality operates as intended.
In summary, Halborn
identified some improvements to reduce the likelihood and impact of multiple risks, which has been partially addressed by Entangle Labs team
. The main ones were the following:
Gorples Bridge Program initializer can be front-run
Fee Collector Vault check missing can lead to DoS in PhotonMsg
Note: Testing is a crucial component of our methodology for conducting security assessments. It enables us to identify potential security risks and program malfunctions, as well as to emulate the exploitation of these risks.
However, during this security assessment, the Entangle Labs team
did not provide a test suite that facilitates interaction with the in-scope bridge programs. This significantly limited our ability to perform security tests on certain parts of the program, such as bridge and photon msg. Consequently, we were unable to execute our methodology fully and were restricted to conducting only an exhaustive code review of these components. This limitation has prevented us from guaranteeing 100% code security.
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 (`solana-test-framework`)
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL 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 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL 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 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
1
High
1
Medium
0
Low
1
Informational
1
Security analysis | Risk level | Remediation Date |
---|---|---|
GORPLES BRIDGE PROGRAM INITIALIZER CAN BE FRONT-RUN | Critical | Solved - 06/23/2024 |
FEE COLLECTOR VAULT CHECK MISSING CAN LEAD TO DOS IN PHOTONMSG | High | Partially Solved - 07/17/2024 |
UNVALIDATED BRIDGE AUTHORITY COULD LEAD TO IRREVERSIBLE ERRORS AND DOS | Low | Risk Accepted |
NEW ADMIN NOT VALIDATED | Informational | Acknowledged |
// Critical
The current implementation of the Initialize
instruction in the gorples-bridge
program lacks restrictions to ensure that it is signed by a known and trusted address, such as the program update authority.
This lack of validation could allow an attacker to execute the initialization process prematurely, potentially configuring the program with accounts under their control. This unauthorized access to the program configuration can have serious consequences, including:
Setting the bridge_router_address
to an invalid account, causing users to lose the tokens provided in the bridge call. This happens because the new message initiated through the photon program will not reach the Bridge Router contract in Entangle Oracle Bridge, preventing the subsequent message to the destination chain where tokens are minted to the specified address.
Configuring the fee_collector_vault
account to an account under their control, calling SetBridgeConfig
instruction to collect commissions earned in PhotonMsg
.
Additionally, the program lacks a mechanism for ownership transfer by a known higher authority, making such an attack irreversible.
pub struct Initialize<'info> {
/// Bridge admin
#[account(mut, signer)]
pub admin: Signer<'info>,
/// Bridge config
#[account(init, payer = admin, space = Config::LEN, seeds = [BRIDGE_ROOT, b"CONFIG"], bump)]
pub config: Box<Account<'info, Config>>,
pub system_program: Program<'info, System>,
}
pub fn handle_initialize(
ctx: Context<Initialize>,
bridge_router_address: Vec<u8>,
min_bridge_amount: u64,
) -> Result<()> {
ctx.accounts.config.bridge_router_address = bridge_router_address.try_into().unwrap();
ctx.accounts.config.admin = ctx.accounts.admin.key();
ctx.accounts.config.min_bridge_amount = min_bridge_amount;
Ok(())
set_bridge_config.rs
pub struct SetBridgeConfig<'info> {
/// Bridge admin
#[account(mut, signer, address = config.admin)]
pub admin: Signer<'info>,
/// Bridge config
#[account(seeds = [BRIDGE_ROOT, b"CONFIG"], bump)]
pub config: Box<Account<'info, Config>>,
}
pub fn handle_set_bridge_config(
ctx: Context<SetBridgeConfig>,
bridge_router_address: Vec<u8>,
min_bridge_amount: u64,
fee_collector_vault: Pubkey,
) -> Result<()> {
ctx.accounts.config.bridge_router_address = bridge_router_address.try_into().unwrap();
ctx.accounts.config.min_bridge_amount = min_bridge_amount;
ctx.accounts.config.fee_collector_vault = fee_collector_vault;
Ok(())
A random user calls Initialize providing an invalid bridge_router_address
The user calls SetBridgeConfig providing an account under its control as fee_collector_vault
To address this issue, it is recommended to implement the following measures:
Ensure that the Initialize instruction can only be executed by a known and trusted address, such as the program upgrade authority.
Introduce a mechanism for ownership transfer, allowing a higher authority to reclaim and reassign control in case of unauthorized or malicious configurations.
Implement a validation for bridge_router_address.
SOLVED: The Entangle Labs team solved this issue by modifying the initialization instruction to ensure that it can only be called by the deployer. The deployer's address is now hardcoded, guaranteeing that the deployer is responsible for securing and providing a valid and correct bridge_router_address address. This modification prevents the described vulnerability by restricting access and ensuring proper initialization.
// High
The Initialize
statement of the gorples-bridge
program allows the initialization of the bridge config account. This requires two parameters: bridge_router_address
and min_bridge_amount
.
initialize.rs
pub struct Initialize<'info> {
/// Bridge admin
#[account(mut, signer)]
pub admin: Signer<'info>,
/// Bridge config
#[account(init, payer = admin, space = Config::LEN, seeds = [BRIDGE_ROOT, b"CONFIG"], bump)]
pub config: Box<Account<'info, Config>>,
pub system_program: Program<'info, System>,
}
pub fn handle_initialize(
ctx: Context<Initialize>,
bridge_router_address: Vec<u8>,
min_bridge_amount: u64,
) -> Result<()> {
ctx.accounts.config.bridge_router_address = bridge_router_address.try_into().unwrap();
ctx.accounts.config.admin = ctx.accounts.admin.key();
ctx.accounts.config.min_bridge_amount = min_bridge_amount;
Ok(())
}
The SetBridgeConfig
instruction enables the bridge config administrator to modify the bridge config by providing values for the same fields as in the initialization, along with an additional fee_collector_vault
address.
set_bridge_config.rs
pub struct SetBridgeConfig<'info> {
/// Bridge admin
#[account(mut, signer, address = config.admin)]
pub admin: Signer<'info>,
/// Bridge config
#[account(seeds = [BRIDGE_ROOT, b"CONFIG"], bump)]
pub config: Box<Account<'info, Config>>,
}
pub fn handle_set_bridge_config(
ctx: Context<SetBridgeConfig>,
bridge_router_address: Vec<u8>,
min_bridge_amount: u64,
fee_collector_vault: Pubkey,
) -> Result<()> {
ctx.accounts.config.bridge_router_address = bridge_router_address.try_into().unwrap();
ctx.accounts.config.min_bridge_amount = min_bridge_amount;
ctx.accounts.config.fee_collector_vault = fee_collector_vault;
Ok(())
The fee_collector_vault
is intended to be the account where fees obtained in photon_msg
will be collected. However, this address, like the other two, is not validated. As a result, it is possible to provide an invalid account or a token account without the corresponding mint associated to match the mint of the core config. This issue could prevent fee collection in photon_msg
, causing the user to be unable to receive mined tokens, effectively resulting in a denial of service for this instruction until a valid fee_collector_vault
is assigned.
photon_msg.rs
/// User vault for token
#[account(
init_if_needed,
payer = executor,
associated_token::authority = user,
associated_token::mint = mint,
associated_token::token_program = token_program
)]
pub user_vault: Box<InterfaceAccount<'info, TokenAccount>>,
/// Fee collector vault for token
#[account(
mut,
address = config.fee_collector_vault,
token::mint = mint,
token::token_program = token_program
)]
pub fee_collector_vault: Box<InterfaceAccount<'info, TokenAccount>>,
By default, this scenario occurs if SetBridgeConfig
is not called to assign a correct address, as the zero address is assigned during initialization.
In the ngl-bridge program, this address is also not validated but is provided during initialization and managed by the deployer, thus mitigating the level of security risk. However, since gorples-bridge
is more susceptible to front-running attacks as it is explained in HAL-01, the following security risks are possible:
Invalid fee_collector_vault addresses could lead to uncollected fees and disrupted operations.
Users may experience denial of service until the configuration is corrected.
Security risks increase if malicious actors exploit the non-validated fields.
Initialize bridge config
Initialize Core Config
Set bridge config with an invalid account as fee collector vault or with a token account whose mint does not match the core config mint
To address this issue, it is recommended to implement the following measures:
Include the fee_collector_vault
address as a parameter at initialization to prevent it from being assigned to the default address.
Add a validation to ensure that fee_collector_vault
is a token account with an associated mint that matches the mint in the core configuration.
PARTIALLY SOLVED: The Entangle Labs team partially solved this issue by modifying the initialization instruction handler. Now, it requires the fee_collector_vault
address to be provided as a parameter, ensuring it is not initialized by default. This change ensures that the address is properly configured. Following the remediation plan for HAL_01, the deployer is responsible for ensuring and providing a valid and correct address.
// Low
The Initialize
instruction of the ngl-core
program allows the deployer to initialize the core config account. To achieve this, the bridge_authority
address must be provided as a parameter along with other values. However, this address is not validated, which could lead to the accidental setting of an unintentional or invalid address. Since the ngl-core
program does not have a functionality to change the core config account values (except for setting a new admin via SetAdmin
), this mistake would be irreversible.
This could result in denial of service for mining and token burning operations if the bridge_authority
was invalid, or misuse of Mint operations if an incorrect address was assigned.
lib.rs (ngl-core)
pub fn initialize(
ctx: Context<Initialize>,
bridge_authority: Pubkey,
name: String,
symbol: String,
uri: String,
) -> Result<()> {
ctx.accounts.config.admin = ctx.accounts.admin.key();
ctx.accounts.config.contracts[0] = bridge_authority;
ctx.accounts.config.mint = ctx.accounts.mint.key();
#[derive(Accounts)]
pub struct MintToken<'info> {
/// Mint authority
/// CHECK: not loaded
#[account(
signer,
constraint =
mint_authority.key() != Pubkey::default() && config.contracts.contains(&mint_authority.key())
@ CustomError::Unauthorized
)]
pub mint_authority: Signer<'info>,
/// Token authority
/// CHECK: not loaded
#[account(seeds = [ROOT, b"AUTHORITY"], bump)]
pub authority: UncheckedAccount<'info>,
lib.rs (ngl-core)
#[derive(Accounts)]
pub struct BurnToken<'info> {
#[account(signer)]
pub vault_owner: Signer<'info>,
/// Burn authority
/// CHECK: not loaded
#[account(
signer,
constraint =
burn_authority.key() != Pubkey::default() && config.contracts.contains(&burn_authority.key())
@ CustomError::Unauthorized
)]
pub burn_authority: Signer<'info>,
On the other hand, the Bridge
and PhotonMsg
instructions use as mint_authority and burn_authority the token authority, so if this authority is not the one provided as bridge_authority in the initialization of the core config, it will result in a denial of service in both instructions.
bridge.rs
pub struct Bridge<'info> {
/// Sender wallet
#[account(signer)]
pub sender: Signer<'info>,
/// Token authority
/// CHECK: not loaded
#[account(seeds = [ROOT, b"AUTHORITY"], bump)]
pub authority: AccountInfo<'info>,
/// Token mint
#[account(
mut,
address = core_config.mint @ CustomError::InvalidMint,
mint::token_program = token_program
)]
pub mint: Box<InterfaceAccount<'info, Mint>>,
/// Sender vault
#[account(
mut,
token::mint = mint,
token::authority = sender,
token::token_program = token_program
)]
pub sender_vault: Box<InterfaceAccount<'info, TokenAccount>>,
/// Bridge config
#[account(seeds = [ROOT, b"CONFIG"], bump)]
pub config: Box<Account<'info, Config>>,
/// Core config
pub core_config: Box<Account<'info, ngl_core::Config>>,
require_gte!(amount, ctx.accounts.config.min_bridge_amount, CustomError::AmountTooLow);
let bump = &[ctx.bumps.authority];
let seed = &[ROOT, &b"AUTHORITY"[..], bump][..];
let seeds = &[seed];
// Burn
let accounts = BurnToken {
vault_owner: ctx.accounts.sender.to_account_info(),
burn_authority: ctx.accounts.authority.to_account_info(),
config: ctx.accounts.core_config.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
vault: ctx.accounts.sender_vault.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
};
photon_msg.rs
pub struct PhotonMsg<'info> {
/// Executor wallet
#[account(mut, signer)]
pub executor: Signer<'info>,
/// Protocol call authority (from photon program)
#[account(
signer,
seeds = [entangle_photon_sol/*photon*/::photon::ROOT, b"CALL_AUTHORITY", NGL_PROTOCOL_ID],
bump,
seeds::program = Photon::id()
)]
pub call_authority: Signer<'info>,
/// Provided by photon program
pub op_info: Box<Account<'info, OpInfo>>,
/// Token authority
/// CHECK: not loaded
#[account(seeds = [ROOT, b"AUTHORITY"], bump)]
pub authority: UncheckedAccount<'info>,
/// Token mint (checked by core)
#[account(mut)]
pub mint: Box<InterfaceAccount<'info, Mint>>,
/// CHECK: User wallet
pub user: UncheckedAccount<'info>,
/// User vault for token
#[account(
init_if_needed,
payer = executor,
associated_token::authority = user,
associated_token::mint = mint,
associated_token::token_program = token_program
)]
pub user_vault: Box<InterfaceAccount<'info, TokenAccount>>,
/// Fee collector vault for token
#[account(
mut,
address = config.fee_collector_vault,
token::mint = mint,
token::token_program = token_program
)]
pub fee_collector_vault: Box<InterfaceAccount<'info, TokenAccount>>,
/// Bridge config
#[account(seeds = [ROOT, b"CONFIG"], bump)]
pub config: Box<Account<'info, Config>>,
/// Core config
/// CHECK: by core program
pub core_config: UncheckedAccount<'info>,
let accounts = MintToken {
mint_authority: ctx.accounts.authority.to_account_info(),
authority: ctx.accounts.core_authority.to_account_info(),
config: ctx.accounts.core_config.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
vault: ctx.accounts.fee_collector_vault.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.core_program.to_account_info(),
accounts,
seeds,
);
mint_token(cpi_ctx, fee)?;
// Mint for user
let bump = &[ctx.bumps.authority];
let seed = &[ROOT, &b"AUTHORITY"[..], bump][..];
let seeds = &[seed];
let accounts = MintToken {
mint_authority: ctx.accounts.authority.to_account_info(),
authority: ctx.accounts.core_authority.to_account_info(),
config: ctx.accounts.core_config.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
vault: ctx.accounts.user_vault.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
};
Conversely, in the initialize
instruction handler of the ngl-bridge
program, the values passed by parameters are also not validated. However, this program includes a SetBridgeConfig
instruction that allows the administrator (initially the deployer) to correct any values of the bridge config at any time in case of an error, although those values are not validated in it either.
initialize.rs (ngl-bridge)
#[derive(Accounts)]
pub struct Initialize<'info> {
/// Bridge admin
#[account(mut, signer, address = DEPLOYER.parse().expect("Deployer key not set"))]
pub admin: Signer<'info>,
/// Bridge config
#[account(init, payer = admin, space = Config::LEN, seeds = [ROOT, b"CONFIG"], bump)]
pub config: Box<Account<'info, Config>>,
pub system_program: Program<'info, System>,
}
pub fn handle_initialize(
ctx: Context<Initialize>,
bridge_router_address: Vec<u8>,
min_bridge_amount: u64,
fee_collector_vault: Pubkey,
) -> Result<()> {
ctx.accounts.config.bridge_router_address = bridge_router_address.try_into().unwrap();
ctx.accounts.config.admin = ctx.accounts.admin.key();
ctx.accounts.config.min_bridge_amount = min_bridge_amount;
ctx.accounts.config.fee_collector_vault = fee_collector_vault;
Ok(())
To address this issue, the following modifications are recommended:
Change the authority used in bridge
and photon_msg
of the ngl-bridge
program that will be used as burn_authority and mint_authority to be a pda with the corresponding bridge authority seeds.
Add a check in the initialize
instruction handler of the ngl-core
program to verify that the provided bridge_authority address is not an invalid address and that it corresponds to the expected bridge_authority pda which should match the one from the previous point.
It is also recommended to validate the values passed by parameter in the instruction handler initialize
and set_bridge_config
of the ngl-bridge
program to check that they are valid to avoid a situation like the one described in HAL-01 and HAL-02 by error.
RISK ACCEPTED: The Entangle Labs team accepted the risk of this finding.
// Informational
The SetAdmin
instruction in the ngl-bridge
and ngl-core
programs allows the deployer to set a new config admin once the config is initialized, provided as a parameter. This new admin will hold critical privileges for managing the config account. However, the provided admin address is not validated to ensure it is a valid address. Additionally, the instruction handler uses a one-step procedure for admin delegation.
set_admin.rs
pub struct SetAdmin<'info> {
/// Deployer address
#[account(signer, address = DEPLOYER.parse().expect("Deployer key not set"))]
pub deployer: Signer<'info>,
/// Bridge config
#[account(mut, seeds = [ROOT, b"CONFIG"], bump)]
pub config: Box<Account<'info, Config>>,
}
pub fn handle_set_admin(ctx: Context<SetAdmin>, admin: Pubkey) -> Result<()> {
ctx.accounts.config.admin = admin;
Ok(())
}
lib.rs
pub fn set_admin(ctx: Context<SetAdmin>, admin: Pubkey) -> Result<()> {
ctx.accounts.config.admin = admin;
Ok(())
}
This may lead to issues where the MintByAdmin
and SetBridgeConfig
statements cannot be called if the admin address is invalid. Moreover, mistakenly setting an incorrect address could allow unauthorized use of high privileges by the new malicious admin, posing security risks until the deployer calls SetAdmin
again to set a valid and trusted administrator.
Although this issue does not pose an immediate security risk, as the deployer can reassign a new config admin, it is advisable to verify that the new admin address provided is valid to prevent potential issues.
ACKNOWLEDGED: The Entangle Labs team acknowledged this finding.
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 | Desccription |
---|---|---|
RUSTSEC-2022-0093 | ed25519-dalek | Double Public Key Signing Function Oracle Attack on |
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.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed