handle_deposit acepta mensajes de cualquier contrato Ethereum sin validar el campo from_address. Cualquier atacante puede desplegar un contrato L1 malicioso y enviar mensajes al bridge, minteando tokens L2 arbitrarios sin depositar fondos reales.
// bridge.cairo:145 — VULNERABLE #[l1_handler] fn handle_deposit( ref self: ContractState, from_address: felt252, // ← NO validado! user: ContractAddress, amount: u256 ) { let current_balance = self.balances.read(user); self.balances.write(user, current_balance + amount); // mint sin control }
starknetCore.sendMessageToL2(l2Bridge, selector, [attacker_l2, 1_000_000_000])#[l1_handler] fn handle_deposit( ref self: ContractState, from_address: felt252, user: ContractAddress, amount: u256 ) { // Validar remitente L1 autorizado let authorized_l1 = self.l1_bridge_address.read(); assert(from_address == authorized_l1, 'Unauthorized L1 sender'); let current_balance = self.balances.read(user); self.balances.write(user, current_balance + amount); }
caracal detect src/ --detectors unchecked-l1-handler-fromfelt252. Este tipo no tiene comportamiento de overflow/underflow definido en Cairo 1.0 — las operaciones aritmeticas pueden producir resultados inesperados al aproximarse al limite del campo primo (P = 2^251 + 17*2^192 + 1), permitiendo manipulacion de balances.
// token.cairo:78 — VULNERABLE #[storage] struct Storage { balances: LegacyMap::<ContractAddress, felt252>, // ← usar u256 total_supply: felt252, // ← usar u256 } fn transfer(ref self: ContractState, to: ContractAddress, amount: felt252) { let sender_balance = self.balances.read(get_caller_address()); // Sin verificacion de saldo suficiente — underflow posible self.balances.write(get_caller_address(), sender_balance - amount); let recipient_balance = self.balances.read(to); self.balances.write(to, recipient_balance + amount); }
transfer(victim, 1)felt252 hace 0 - 1 = P - 1 (wrap-around al primo del campo)#[storage] struct Storage { balances: LegacyMap::<ContractAddress, u256>, // overflow seguro total_supply: u256, // overflow seguro } fn transfer(ref self: ContractState, to: ContractAddress, amount: u256) { let sender_balance = self.balances.read(get_caller_address()); assert(sender_balance >= amount, 'Insufficient balance'); self.balances.write(get_caller_address(), sender_balance - amount); let recipient_balance = self.balances.read(to); self.balances.write(to, recipient_balance + amount); }
execute_withdrawal verifica una firma ECDSA para autorizar retiros de la boveda, pero no implementa tracking de nonces. Una firma valida puede reutilizarse indefinidamente para drenar la boveda.
// vault.cairo:201 — VULNERABLE fn execute_withdrawal( ref self: ContractState, amount: u256, signature: (felt252, felt252) ) { let msg_hash = pedersen_hash(amount.into(), get_caller_address().into()); // Sin nonce — misma firma sirve infinitamente let is_valid = self.owner.read().verify_signature(msg_hash, signature); assert(is_valid, 'Invalid signature'); self.transfer_tokens(get_caller_address(), amount); }
execute_withdrawal(10000, signature) repetidamente#[storage] struct Storage { nonces: LegacyMap::<ContractAddress, u64>, // tracking por usuario } fn execute_withdrawal( ref self: ContractState, amount: u256, nonce: u64, signature: (felt252, felt252) ) { let caller = get_caller_address(); let current_nonce = self.nonces.read(caller); assert(nonce == current_nonce, 'Invalid nonce'); let msg_hash = pedersen_hash_multi([amount, nonce, caller]); let is_valid = self.owner.read().verify_signature(msg_hash, signature); assert(is_valid, 'Invalid signature'); self.nonces.write(caller, current_nonce + 1); // incrementar ANTES del transfer self.transfer_tokens(caller, amount); }
felt252 para uso en StarkNet, no se valida que el valor resultante sea menor que el primo del campo de StarkNet. Aunque las direcciones ETH de 20 bytes son menores que P por definicion, la funcion acepta felt252 arbitrario como input, permitiendo valores invalidos que causen comportamiento indefinido o fondos enviados a la direccion cero.
assert(addr < STARKNET_FIELD_PRIME, 'Address out of range') antes de cualquier conversion. Usar el tipo EthAddress nativo de StarkNet que enforza el rango automaticamente.send_message_to_l2 pueden quedar bloqueados si el handler L2 revierte (gas insuficiente, contrato pausado, etc.). El contrato L1 no implementa cancelL1ToL2Message, dejando los fondos atrapados permanentemente en el core de StarkNet.
cancelL1ToL2Message, los fondos quedan bloqueados indefinidamentecancelDeposit() que llame a starknetCore.cancelL1ToL2Message() despues del periodo de espera (5 dias). Almacenar el hash del mensaje en storage para poder cancelarlo.receive_l1_message impone una restriccion demasiado estricta: compara la direccion L1 del remitente con un hash hardcodeado en lugar del valor almacenado en storage. Si se actualiza la direccion del bridge L1 (por upgrade), todos los mensajes comenzaran a rechazarse, bloqueando deposits y retiros.
self.l1_bridge.read()) y exponer una funcion set_l1_bridge() protegida por owner para actualizarla sin necesidad de redeploy del contrato L2.