Encoding and Low-Level Pitfalls
A grab-bag of vulnerability classes that all share a common root: Solidity is a thin abstraction over the EVM, and the boundary leaks. Type conversions, ABI encoding, calldata layout, gas semantics, and selector derivation each carry footguns that the type system does not catch.
Unsafe Typecast
Solidity allows explicit casts between numeric types. Casts to smaller types truncate silently in pre-0.8 contracts, and even in 0.8+ they can produce unintended values because the conversion is well-defined but not always intended.
uint256 big = 2**128 + 5;
uint128 small = uint128(big); // small == 5; no revert
Common variants:
- Down-casting size.
uint256→uint128,uint128→uint64, etc. Used for storage packing; loses data if the source exceeds the destination range. - Signed/unsigned conversion.
int256(uint256 x)reinterprets the bits. A largeuint256becomes a large negativeint256. The Compound governance bug class includes variants of this. uint160↔address. Allowed and idiomatic, but any extra bits above 160 are dropped silently. An attacker who can influence the sourceuintmay craft a value that casts to a specific address.bytesN↔bytesM. Casts pad or truncate from the right (least-significant bytes), the opposite of integer casts. Easy to confuse during code review.
Detection. Grep for uint(, int(, address(uint160(, bytes32(, and similar explicit casts. For each, verify (a) the source range is bounded below the destination type's max, or (b) the contract reverts on overflow before casting. SafeCast (OpenZeppelin) provides toUint128, toInt256, etc., that revert on out-of-range conversion.
Remediation. Use SafeCast for any width-narrowing or signedness-changing conversion. For storage packing, document the assumed bounds and enforce them at the input layer.
Dirty Higher-Order Bits
EVM memory and storage values are 256 bits wide. When a smaller type (e.g., uint128, bool, address) is stored or returned, the higher-order bits should be zero, but inline assembly and certain ABI-decoding edge cases can leave them dirty.
The historical concern: a function returns a bool written by hand-rolled assembly. The high 248 bits are non-zero. A caller reading the result as uint256 and comparing to 1 produces an unexpected value. The compiler's automatic masking on type-respecting reads usually saves the day; assembly bypasses it.
function isAllowed(address u) external view returns (bool ok) {
assembly {
let v := sload(...) // v may have dirty high bits
ok := v // return value has dirty bits
}
}
A caller doing if (isAllowed(u)) evaluates v != 0 and works. A caller doing uint256 r = uint256(isAllowed(u)) and comparing to a specific number breaks.
Remediation. In assembly, explicitly mask values you write to slots or return registers (and(v, 0xff...) for the appropriate width). Treat all assembly-produced values as potentially dirty. The Solidity compiler emits cleanupTruncation calls automatically only for type-respecting code paths.
Floating-Point Arithmetic
There is no floating point in the EVM. Every "decimal" in Solidity is fixed-point: an integer scaled by some implicit factor (1e18 for ETH, 1e6 for USDC, 1e8 for BTC pricing, etc.). Bugs arise from:
- Division before multiplication.
a * b / crounds at the divide; ifb > cand the multiplication overflows pre-0.8 contracts, or if the auditor mistakenly writesa / c * b, precision is lost. See §4.11.10. - Mismatched scaling factors. Multiplying an 18-decimal token amount by a 6-decimal token amount without normalizing produces results off by 12 orders of magnitude.
- Rounding direction. Standard integer division rounds toward zero. For protocols that mint shares against deposits, rounding in the protocol's favor is required to prevent inflation attacks (the ERC-4626 inflation/donation attack). Rounding toward the user instead lets attackers extract value through dust deposits.
- Edge cases at zero. Computing
x * y / zwhenzis intended to betotalSupplyandtotalSupply == 0reverts; protocols handling first-deposit edge cases incorrectly are a frequent finding.
Remediation. Use a fixed-point math library (PRBMath, Solady's FixedPointMathLib, OpenZeppelin's Math) with explicit mulDiv that handles overflow via 512-bit intermediate arithmetic and explicit rounding direction. Document the assumed decimals at every interface boundary. Cover edge cases (zero, max, single-unit) with unit tests.
Hash Collisions with Multi Variable-Length Arguments
abi.encodePacked concatenates values without length prefixes or padding. When two or more variable-length arguments are packed together, distinct logical inputs can produce identical encoded bytes, and therefore identical hashes.
keccak256(abi.encodePacked("a", "bc")) // 0x...
keccak256(abi.encodePacked("ab", "c")) // same 0x...
In any code that uses such a hash for signature recovery, commitment, replay-protection nonce, or deduplication, this is an exploitable collision.
Remediation. Use abi.encode (length-prefixed, padded) whenever any of the arguments is variable-length and the encoded bytes will be hashed or used for identity. Reserve abi.encodePacked for tight encoding where all variable-length arguments are unambiguous (e.g., a single string), or where the output is consumed by a parser that imposes its own delimiters. The compiler warning was extended in recent versions; do not suppress it.
Function Selector Abuse
A function selector is the first four bytes of keccak256("name(types)"). With ~4 billion possible selectors and arbitrary function names available, two different functions in two different contracts (or even the same contract, given inheritance) can collide.
Proxy Method Clashes
In a transparent proxy, both the proxy admin and the implementation are addressable through the same calldata. If a function on the implementation has the same selector as a proxy admin function (e.g., upgradeTo), calls to that function may be routed to the proxy admin instead, bypassing the implementation's logic. OpenZeppelin's transparent proxy mitigates this by routing calls from the proxy admin separately from calls from end users — but the underlying selector collision is the reason that complexity is necessary.
Diamond Storage Selector Collisions
In ERC-2535 Diamond contracts, every facet contributes its function selectors to a shared lookup table. Two facets with colliding selectors cannot both be installed; worse, an upgrade that adds a facet whose selector collides with an existing one will be rejected (good) or, in a buggy diamond implementation, will silently overwrite the existing mapping (bad).
Crafted Selector Attacks
If a contract uses msg.sig to dispatch — common in proxies, routers, and generic forwarders — an attacker may search the function-name space for a selector that hashes to a desired 4-byte value and route execution to an unintended handler.
Detection. Run forge inspect Contract methods (or equivalent) on every facet, every proxy implementation, and every router. Cross-reference selectors across the system. For any router that dispatches by msg.sig, enumerate the reachable selectors and confirm there is no path for an attacker to inject a function with a controlled selector.
Remediation. Use the proxy admin separation pattern (or beacon proxies) to avoid implementation/admin collisions. Use a Diamond library that rejects collisions on install. Avoid generic msg.sig dispatch unless the input space is constrained.
Short Address / Parameter Attack
The original short-address attack exploited an early-2017 client-side ABI encoder that did not pad the recipient address in ERC-20 transfer calls. If a user supplied a 19-byte address (missing the final byte), the encoder packed it short, and the EVM's right-pad behavior shifted the amount left by a byte — multiplying it by 256. The recipient address effectively had the trailing zero from the amount appended.
transfer(0x00...AB, 0x...0064) // 100 tokens to 0xAB00
// encoded short:
// selector | 0x00...0A | B0...000064
// EVM right-pads address with the first byte of amount:
// selector | 0x00...0AB0 | 0x...006400 → 100 * 256 = 25600 tokens
The vector was closed by stricter client-side and EVM-level calldata length validation, but the underlying class — trusting the length of decoded calldata when the caller controls the encoding — recurs whenever contracts:
- Use low-level
calland decode the return manually without checking the returndatasize. - Forward arbitrary calldata between contracts via
call(data)without validating the length against the expected ABI shape. - Implement custom calldata parsing in assembly without bounds checks.
Detection. Every call, staticcall, delegatecall, and every assembly block that touches calldataload or returndatacopy must validate that the available data covers the expected layout. Look for returndatasize() checks; their absence with subsequent abi.decode is suspicious.
Remediation. Use Solidity's high-level call syntax (Contract(addr).func(args)) which validates layouts. In assembly, check sizes explicitly before reading.
Message Call with Hardcoded Gas Amount
The pre-2019 recommendation to use address.transfer(amount) or address.send(amount) to forward ether to an address was based on the 2300-gas stipend, which was believed to prevent reentrancy. Two things broke that recommendation:
- EIP-1884 (Istanbul, December 2019) repriced
SLOADfrom 200 to 800 gas. Any recipient fallback that performed a singleSLOAD(for example, a proxy doing a delegatecall lookup) was suddenly over budget, causing previously-workingtransfercalls to revert. - EIP-2929 (Berlin, April 2021) introduced cold/warm access pricing. Cold-storage and cold-account access now costs significantly more than 2300 gas alone.
The combination means transfer and send can fail for legitimate recipients — proxies, smart wallets, account-abstraction wallets, and anything with a non-trivial fallback. Meanwhile, the 2300-gas stipend was never an absolute guarantee against reentrancy; it merely made the simple variant difficult.
// FRAGILE — may revert on legitimate recipients
payable(recipient).transfer(amount);
// PREFERRED
(bool ok, ) = recipient.call{value: amount}("");
require(ok, "ETH transfer failed");
Remediation. Use low-level call{value:}(""), check the return value, and protect the surrounding function with a reentrancy guard. Hardcoded gas amounts in any other context (call{gas: X} with a fixed X) carry the same fork-risk: any future repricing can make X too small or, less commonly, too large in ways that interact with other gas accounting.
Insufficient User Input Validation
The catch-all category. Patterns observed across audits:
- Zero-address checks. Accepting
address(0)for owner, fee recipient, oracle, token, or strategy addresses can brick the contract or burn fees. - Zero-value checks. Accepting
amount == 0in deposit, withdraw, mint, or transfer paths can either revert in unexpected places or, worse, succeed with side effects (events emitted, state mutated, callbacks fired). - Maximum bound checks. Accepting parameters at
type(uint256).maxor near it where the contract later does arithmetic — guaranteed overflow on the next operation. - Array length checks. Two parallel arrays passed in expected to have equal length; missing the equality check produces silent index-mismatch bugs.
- Identity checks. Passing the same address as both source and destination (or as both tokens in a swap, both counterparties in a settlement) — frequently produces double-accounting bugs.
- Range and unit checks. A fee parameter accepted as basis points but documented as percent; a deadline accepted in seconds but treated as milliseconds; a token amount in raw units treated as decimal-scaled.
Detection. For every external function, enumerate every parameter and ask: what is the valid range? Is the contract checking it? What happens at the boundary (zero, max, equal-to-other-parameter)? This is dull, mechanical work. It also finds bugs.
Remediation. Add explicit require checks at function entry. Use custom errors for gas efficiency. Test boundary conditions in unit tests, including parameter pairs (deadline=now, amount=0, src=dst).
Auditor Checklist
-
Every numeric cast uses
SafeCastor has a documented and enforced bound. - Inline assembly that returns values explicitly masks higher-order bits.
-
Fixed-point math uses a vetted library (
mulDivor equivalent) with documented rounding direction; ERC-4626-style share calculations round in the protocol's favor. -
abi.encodePackedis not used to hash multiple variable-length arguments. - Function selectors across proxies, facets, and routers reviewed for collisions; admin/implementation separation in place for transparent proxies.
-
Every
call,staticcall,delegatecall, and assembly calldata read validates sizes. -
Native ETH sends use low-level
callwith checked return value, nottransfer/send; no other hardcoded gas amounts. - Every external function validates: zero-address, zero-value, max-bound, array-length-match, and identity (src ≠ dst where relevant).
- Unit tests cover boundary conditions for every parameter.