Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x1A |
| Mnemonic | BYTE |
| Gas | 3 |
| Stack Input | i, x |
| Stack Output | (x >> (248 - i*8)) & 0xFF |
| Behavior | Extract the ith byte from a 256-bit value x, where i=0 is the most significant (leftmost) byte. Returns 0 if i >= 32. |
Threat Surface
BYTE extracts a single byte from a 256-bit word using big-endian byte ordering. This is the EVM’s native byte order, but it conflicts with the little-endian conventions used by most modern CPUs and many developer mental models. The i=0 position refers to the most significant byte (bits 248-255), not the least significant byte that most programmers would expect.
This big-endian convention is the root of most BYTE-related vulnerabilities. Developers who think in little-endian (or who work with Solidity’s abi.encodePacked which produces big-endian output) frequently extract the wrong byte:
- Address extraction: An Ethereum address occupies bytes 12-31 of a 32-byte word (right-aligned).
BYTE(0, addr)returns 0 (the padding), not the first byte of the address. The correct first address byte is atBYTE(12, addr). - Hash analysis: Keccak256 produces big-endian output.
BYTE(0, hash)returns the most significant byte, which is the first byte displayed in hex notation. This is correct but unintuitive. - Selector extraction: Function selectors are the first 4 bytes of calldata. They occupy bytes 0-3, so
BYTE(0)throughBYTE(3)on a CALLDATALOAD(0) correctly extracts them.
The secondary threat surface is BYTE’s silent zero-return for out-of-range indices. BYTE(32, x) or BYTE(MAX_UINT256, x) returns 0 without any error or revert. Code that uses BYTE with a user-controlled index can be tricked into reading “zero” from any out-of-range position, potentially bypassing checks that expect non-zero bytes.
Smart Contract Threats
T1: Big-Endian Byte Ordering Confusion (High)
The most common BYTE error: developers expect byte 0 to be the least significant byte (little-endian convention) when it is actually the most significant. This produces systematically wrong extractions:
// Want: extract the least significant byte of x
// Wrong: BYTE(0, x) returns the MOST significant byte
// Right: BYTE(31, x) returns the least significant byte
assembly {
let lsb := byte(31, x) // Correct: byte 31 is the rightmost
let msb := byte(0, x) // Correct: byte 0 is the leftmost
}For addresses (which are right-aligned in 32-byte words):
- The address
0xdead...beefstored as a uint256 is0x000000000000000000000000dead...beef BYTE(0)throughBYTE(11)return 0 (padding)BYTE(12)returns the first byte of the actual addressBYTE(31)returns the last byte of the address
T2: Out-of-Range Index Returns Zero (High)
BYTE silently returns 0 for any index >= 32. This creates a dangerous silent failure mode when the index is:
- Computed from user input and not bounds-checked
- The result of an arithmetic operation that overflows
- A value from an untrusted source
assembly {
let idx := calldataload(0) // User-controlled index
let b := byte(idx, someValue) // If idx >= 32, returns 0
// Zero might bypass a != 0 check, or match an expected zero byte
}T3: Wrong Bytes Extracted from Addresses and Hashes (High)
Contract logic that extracts specific bytes from addresses or keccak256 hashes for routing, sharding, or vanity-address verification must use the correct byte positions. Errors here can:
- Route tokens to the wrong shard/chain (if using address byte for chain selection)
- Accept invalid vanity addresses (if checking the wrong byte for zero)
- Produce incorrect address checksums
// Checking if an address starts with zero bytes (CREATE2 vanity mining)
assembly {
let firstByte := byte(0, addr) // Bug: this is the padding, always 0
// Should be: byte(12, addr) for the first actual address byte
}T4: BYTE for Signature Parsing (Medium)
Some contracts use BYTE to extract v, r, s components from packed ECDSA signatures. If the byte positions are wrong, the extracted components are incorrect, producing either invalid signatures (which revert safely) or, worse, valid signatures for a different signer.
T5: Nibble Extraction Errors (Low)
Contracts that extract individual nibbles (4-bit values) from bytes using BYTE combined with AND and SHR can produce incorrect nibble ordering if the byte position is wrong:
assembly {
let b := byte(idx, value)
let highNibble := shr(4, b) // Upper 4 bits
let lowNibble := and(b, 0x0f) // Lower 4 bits
// If idx is wrong, both nibbles come from the wrong byte
}Protocol-Level Threats
P1: No DoS Vector (Low)
BYTE costs a fixed 3 gas regardless of operand values. It operates purely on the stack.
P2: Consensus Safety (Low)
BYTE is deterministic: the same (i, x) pair always produces the same result across all EVM implementations. The big-endian convention is unambiguous in the specification. No consensus bugs have occurred due to BYTE.
P3: zkEVM Constraint Gaps (Medium)
The PSE/Scroll zkEVM had a missing constraint vulnerability related to byte-level operations. In zero-knowledge EVM implementations, each opcode must be constrained to produce the correct output for all possible inputs. Byte extraction operations require careful constraint generation because the “which byte” selection involves position-dependent masking. A missing or incorrect constraint could allow a malicious prover to claim an incorrect BYTE result was correct.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
BYTE(0, x) | Returns most significant byte | Big-endian; not the “first” byte in little-endian sense |
BYTE(31, x) | Returns least significant byte | This is x & 0xFF |
BYTE(32, x) | Returns 0 | Silent zero; no revert or error |
BYTE(MAX_UINT256, x) | Returns 0 | Any index >= 32 returns 0 |
BYTE(i, 0) | Returns 0 for all i | Zero has no non-zero bytes |
BYTE(0, MAX_UINT256) | Returns 0xFF | All bytes of MAX_UINT256 are 0xFF |
BYTE(12, address) | Returns first byte of a right-aligned address | Correct way to get byte 0 of a 20-byte address in a 32-byte word |
BYTE(i, hash) | Returns byte i of keccak256 | Direct extraction; keccak256 output is already big-endian |
| Negative index (as uint256) | Returns 0 (index >> 31) | Interpreted as very large unsigned number |
Real-World Exploits
Exploit 1: PSE/Scroll zkEVM Missing Constraint in Byte Operations (2023)
Root cause: Missing or incomplete constraints in the zero-knowledge circuit for byte-level operations, allowing a malicious prover to potentially forge incorrect execution results.
Details: In the PSE (Privacy and Scaling Explorations) and Scroll zkEVM implementations, byte extraction and manipulation operations required careful constraint generation. A vulnerability was found where the constraints for byte-level operations were incomplete, meaning a malicious prover could potentially construct a valid proof for an incorrect BYTE operation result. This would allow forging state transitions in the ZK rollup.
BYTE’s role: Byte extraction operations require position-dependent masking in ZK circuits. Each possible byte position (0-31) must be properly constrained, and the out-of-range (>=32) case must return zero. Missing constraints for any of these cases creates a soundness bug.
Impact: No exploitation occurred; the bug was found during audit and fixed before mainnet deployment.
References:
Exploit 2: Address Byte Extraction Errors in Vanity Address Verification (Recurring Pattern)
Root cause: Checking byte 0 instead of byte 12 when verifying that an address has specific byte patterns (e.g., leading zeros for CREATE2 vanity addresses).
Details: Vanity address mining (particularly for CREATE2-deployed contracts) involves finding salt values that produce addresses with specific byte patterns — typically leading zero bytes for gas-efficient address comparison. Contracts that verify these patterns on-chain sometimes check BYTE(0, address) instead of BYTE(12, address), failing to account for the 12-byte right-padding of addresses in 256-bit words.
Since the leading 12 bytes of a properly-encoded address are always zero, checking BYTE(0, addr) always passes (returns 0), making the vanity address verification meaningless. Any address would be accepted.
BYTE’s role: The bug is a direct misuse of BYTE’s big-endian indexing applied to right-aligned address data.
Impact: No specific public exploit reported, but the pattern has been flagged in multiple private audits. Warden findings in Code4rena and Sherlock competitions have identified this pattern in address verification logic.
Exploit 3: Function Selector Extraction Errors in Proxy Contracts (Recurring Pattern)
Root cause: Incorrect BYTE extraction of the 4-byte function selector from calldata, causing proxy contracts to route calls to wrong implementation functions.
Details: Proxy contracts that route calls based on the function selector must extract bytes 0-3 from calldata. In assembly, this is done by loading the first 32 bytes (CALLDATALOAD(0)) and extracting the selector using either SHR(224, ...) or BYTE(0..3). When BYTE is used, extracting at the wrong position produces an incorrect selector, causing the proxy to match the wrong function or fail to match any function (falling through to the fallback).
BYTE’s role: Incorrect byte position in selector extraction produces wrong 4-byte values, silently misrouting calls.
Attack Scenarios
Scenario A: Address Byte Check Bypass
contract VanityVerifier {
function verifyLeadingZeros(address addr, uint8 requiredZeros) external pure returns (bool) {
for (uint8 i = 0; i < requiredZeros; i++) {
uint8 b;
assembly {
// Bug: byte(0..n) reads padding, which is ALWAYS zero
b := byte(i, addr)
}
if (b != 0) return false;
}
return true; // Always returns true for requiredZeros <= 12!
}
// Correct: start from byte 12 for the actual address bytes
// b := byte(add(12, i), addr)
}Attack: Any address passes the vanity verification because the check reads zero-padding instead of actual address bytes.
Scenario B: Out-of-Range Index Zeroing
contract ByteRouter {
function route(uint256 data, uint256 byteIndex) external {
uint8 routeId;
assembly {
routeId := byte(byteIndex, data) // If byteIndex >= 32, routeId = 0
}
if (routeId == 0) {
// "Default" route -- perhaps a privileged path
_handleDefault(data);
} else {
_handleRoute(routeId, data);
}
}
}Attack: Pass byteIndex = 32 or higher. BYTE returns 0. Attacker accesses the default route regardless of the actual data content.
Scenario C: Wrong Hash Byte Extraction for Difficulty Verification
contract ProofOfWork {
function verifyWork(bytes32 hash, uint8 difficulty) external pure returns (bool) {
// Check that the first 'difficulty' bytes of hash are zero
for (uint8 i = 0; i < difficulty; i++) {
// Correct for keccak256 output (big-endian): byte(i, hash) IS the ith byte
// But wrong if hash was produced by a little-endian system
if (uint8(hash[i]) != 0) return false;
}
return true;
}
}Scenario D: Signature Component Extraction Error
function extractV(bytes32 packed) internal pure returns (uint8 v) {
assembly {
// Bug: byte(0) is MSB of packed, not the v value
// v is typically stored in the LSB (byte 31) or at a specific offset
v := byte(0, packed) // Returns wrong byte
}
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Endianness confusion | Document byte positions explicitly; use Solidity’s bytes32[i] syntax instead of assembly | uint8(hash[0]) instead of byte(0, hash) in assembly |
| T1: Address bytes | Remember: address byte 0 is at BYTE(12), not BYTE(0) | Comment with byte layout: // [0-11: padding][12-31: address] |
| T2: Out-of-range index | Validate index < 32 before BYTE extraction | require(idx < 32, "out of range") |
| T2: User-controlled index | Never pass user input directly as BYTE index | Sanitize: idx := and(idx, 0x1f) (mod 32) |
| T3: Address/hash extraction | Use Solidity’s type system for byte extraction | bytes20(address), bytes32(hash) with array indexing |
| T4: Signature parsing | Use OpenZeppelin’s ECDSA library | ECDSA.recover() handles byte extraction correctly |
| T5: Nibble extraction | Test with values containing non-zero nibbles at all positions | Fuzz with random 256-bit values |
Best Practices for Byte Extraction
- Use Solidity when possible:
bytes32(value)[i]uses the same big-endian convention as BYTE but is type-checked. - Prefer SHR over BYTE for multi-byte extraction:
SHR(248, x)extracts the MSB;AND(x, 0xFF)extracts the LSB. Both are clearer than BYTE for common patterns. - Document the byte layout: When working with packed data, include a comment showing which bytes correspond to which fields.
- Test with non-zero padding: Include test cases where the “padding” bytes (0-11 for addresses) are non-zero to catch extraction position errors.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | High | High | Widespread endianness confusion in assembly |
| T2 | Smart Contract | High | Medium | Silent zero return bypasses checks |
| T3 | Smart Contract | High | Medium | Address byte extraction errors in audits |
| T4 | Smart Contract | Medium | Low | Signature parsing bugs |
| T5 | Smart Contract | Low | Low | Nibble extraction in specialized contracts |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
| P3 | Protocol | Medium | Low | PSE/Scroll zkEVM constraint gaps |
Related Opcodes
| Opcode | Relationship |
|---|---|
| AND (0x16) | AND(x, 0xFF) extracts the least significant byte; BYTE extracts by position. Both used for byte-level extraction |
| SHR (0x1C) | SHR(248, x) extracts the most significant byte, equivalent to BYTE(0, x). SHR is more flexible for multi-byte extraction |
| SHL (0x1B) | Positions bytes before extraction; BYTE(i, SHL(n, x)) extracts shifted bytes |
| CALLDATALOAD (0x35) | Loads 32 bytes from calldata; BYTE extracts individual bytes from the loaded word |
| SIGNEXTEND (0x0B) | Extends the sign bit of a sub-256-bit value; BYTE can extract the sign byte for manual sign extension |
| MLOAD (0x51) | Loads 32 bytes from memory; BYTE extracts individual bytes from loaded words |
| KECCAK256 (0x20) | Produces 32-byte hashes; BYTE extracts individual bytes of the hash for difficulty checks or routing |