
Scv Scan
- 73 installs
- 475 repo stars
- Updated July 14, 2026
- trailofbits/skills-curated
Helps with ai & agent building tasks during AI-assisted development.
About
scv-scan is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- scv-scan
- AI & Agent Building
- AI-coding skill
Scv Scan by the numbers
- 73 all-time installs (skills.sh)
- +5 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #5,587 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/trailofbits/skills-curated --skill scv-scanAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 73 |
|---|---|
| repo stars | ★ 475 |
| Last updated | July 14, 2026 |
| Repository | trailofbits/skills-curated ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Smart Contract Vulnerability Auditor
Systematically audit a Solidity codebase for vulnerabilities using a four-phase approach that balances thoroughness with efficiency.
When to Use
- Auditing a Solidity codebase for security vulnerabilities before deployment
- Reviewing smart contract code for common exploit patterns (reentrancy, overflow, access control, etc.)
- Performing a structured vulnerability scan across an entire Solidity project
- Validating that a contract follows security best practices after modifications
When NOT to Use
- Auditing non-Solidity smart contracts (Vyper, Rust/Anchor, Move) — patterns are Solidity-specific
- Reviewing off-chain code (JavaScript, TypeScript backends) — use general security review instead
- When the user only wants gas optimization or code style feedback — this focuses on exploitable vulnerabilities
- For formal verification of invariants — use tools like Certora, Halmos, or Echidna instead
Rationalizations to Reject
- "The compiler version is >=0.8.0 so overflow isn't possible" —
uncheckedblocks, assembly, and type downcasts still wrap - "It uses OpenZeppelin so it's safe" — integration bugs, incorrect inheritance order, and missing modifiers on custom functions are common
- "That function is internal so it can't be exploited" — internal functions called from external entry points inherit the caller's context
- "There's no ETH involved so reentrancy doesn't apply" — ERC721
_safeMint, ERC1155 safe transfers, and ERC777 hooks all trigger callbacks - "The contract is upgradeable so we can fix it later" —
initialize()withoutinitializermodifier is itself a critical vulnerability
Reference Structure
references/
CHEATSHEET.md # Condensed pattern reference — always read first
reentrancy.md # Full reference files — read selectively in Phase 3
overflow-underflow.md
...Each full reference file in references/ has these sections:
- Preconditions — what must be true for the vulnerability to exist
- Vulnerable Pattern — annotated Solidity anti-pattern
- Detection Heuristics — step-by-step reasoning to confirm the vulnerability
- False Positives — when the pattern appears but isn't exploitable
- Remediation — how to fix it
Audit Workflow
Phase 1: Load the Cheatsheet
Before touching any Solidity files, read {baseDir}/skills/scv-scan/references/CHEATSHEET.md in full.
This file contains a condensed entry for every known vulnerability class: name, what to look for (syntactic and semantic), and default severity. Internalize these patterns — they are your detection surface for the sweep phase. Do NOT read any full reference files yet.
Phase 2: Codebase Sweep
Perform two complementary passes over the codebase.
Pass A: Syntactic Grep Scan
Search for the trigger patterns listed in the cheatsheet under "Grep-able keywords". Use grep or ripgrep to find matches.
For each match, record: file, line number(s), matched pattern, and suspected vulnerability type(s).
Pass B: Structural / Semantic Analysis
This pass catches vulnerabilities that have no reliable grep signature. Read through the codebase searching for any relevant logic similar to that explained in the cheatsheet.
For each finding in this pass, record: file, line number(s), description of the concern, and suspected vulnerability type(s).
Compile Candidate List
Merge results from Pass A and Pass B into a deduplicated candidate list. Each entry should look like:
- File: `path/to/file.sol` L{start}-L{end}
- Suspected: [vulnerability-name] (from CHEATSHEET.md)
- Evidence: [brief description of what was found]Phase 3: Selective Deep Validation
For each candidate in the list:
1. Read the full reference file for the suspected vulnerability type (e.g., {baseDir}/skills/scv-scan/references/reentrancy.md). Read it now — not before. 2. Walk through every Detection Heuristic step against the actual code. Be precise — trace variable values, check modifiers, follow call chains. 3. Check every False Positive condition. If any false positive condition matches, discard the finding and note why. 4. Cross-reference: one code location can match multiple vulnerability types. If the cheatsheet maps the same pattern to multiple references, read and validate against each. 5. Confirm or discard. Only confirmed findings go into the final report.
Phase 4: Report
For each confirmed finding, output:
### [Vulnerability Name]
**File:** `path/to/file.sol` L{start}-L{end}
**Severity:** Critical | High | Medium | Low | Informational
**Description:** What is vulnerable and why, in 1-3 sentences.
**Code:**// The vulnerable code snippet
**Recommendation:** Specific fix, referencing the Remediation section of the reference file.After all findings, include a summary section:
## Summary
| Severity | Count |
|----------|-------|
| Critical | N |
| High | N |
| Medium | N |
| Low | N |
| Info | N |Write the final report to scv-scan.md.
Severity Guidelines
- Critical: Direct loss of funds, unauthorized fund extraction, permanent freezing of funds
- High: Conditional fund loss, access control bypass, state corruption exploitable under realistic conditions
- Medium: Unlikely fund loss, griefing attacks, DoS on non-critical paths, value leak under edge conditions
- Low: Best practice violations, gas inefficiency, code quality issues with no direct exploit path
- Informational: Unused variables, style issues, documentation gaps
Key Principles
- Cheatsheet first, references on-demand. Never read all full reference files upfront. The cheatsheet gives you ambient awareness; full references are for validation only.
- Semantic > syntactic. The hardest bugs don't grep. Cross-function reentrancy, missing access control, incorrect inheritance — these require reading and reasoning, not pattern matching.
- Trace across boundaries. Follow state across function calls, contract calls, and inheritance chains. Hidden external calls (safe mint/transfer hooks, ERC-777 callbacks) are as dangerous as explicit
.call(). - One location, multiple bugs. A single line can be vulnerable to reentrancy AND unchecked return value. Check all applicable references.
- Version matters. Always check
pragma solidity— many vulnerabilities are version-dependent (e.g., overflow is checked by default in >=0.8.0). - False positives are noise. Be rigorous about checking false positive conditions. A shorter report with high-confidence findings is more valuable than a long one padded with maybes.
Write to Arbitrary Storage Location
Preconditions
- Contract has a dynamic array in storage
- User input controls the index used for writing to that array
- No bounds checking on the index before the write
- OR: assembly-level
sstorewith a user-controlled slot value
Vulnerable Pattern
uint256[] public data;
address public owner;
function write(uint256 index, uint256 value) external {
// User controls index — can compute an index that maps
// to any storage slot via the array's storage layout:
// array elements start at keccak256(slot_of_length)
// attacker calculates index to target owner's slot
data[index] = value;
}
// Assembly variant
function writeSlot(uint256 slot, uint256 value) external {
assembly {
sstore(slot, value) // Direct arbitrary storage write
}
}Detection Heuristics
1. Search for dynamic array writes where the index comes from user input or function parameters 2. Check if the index is bounds-checked before the write (e.g., require(index < data.length)) 3. Search for sstore in assembly blocks — check if the slot parameter is user-controlled 4. Search for .length assignments on dynamic arrays (Solidity <0.6.0 allowed array.length = X, enabling array expansion to reach any slot) 5. If an unbounded array write exists, verify whether the array's keccak256-based slot layout could collide with other state variable slots
False Positives
- Array index is validated against
array.lengthbefore writing - The array uses
push()only (indices are not user-controlled) - Assembly
sstoreuses a hardcoded or internally computed slot - Modern Solidity (>=0.6.0) prevents direct
.lengthmanipulation
Remediation
- Always bounds-check array indices:
require(index < data.length) - Use
push()andpop()instead of direct index assignment for dynamic arrays - Avoid exposing
sstorewith user-controlled slot values - Use mappings instead of arrays when random-access writes are needed
function safeWrite(uint256 index, uint256 value) external {
require(index < data.length, "out of bounds");
data[index] = value;
}Assert Violation
Preconditions
- Contract uses
assert()statements - The
assertcondition can be reached through valid program execution (not a true invariant) - OR:
assert()is used to validate user input or external call results instead ofrequire()
Vulnerable Pattern
function transfer(address to, uint256 amount) external {
// WRONG: assert used for input validation — should be require
// In Solidity <0.8.0, this consumes ALL remaining gas on failure
assert(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
function withdraw() external {
uint256 bal = balances[msg.sender];
balances[msg.sender] = 0;
(bool success,) = msg.sender.call{value: bal}("");
// WRONG: assert used to check external call result
assert(success);
}Detection Heuristics
1. Search for all assert( calls in the codebase 2. For each, determine whether the condition is a true invariant (mathematically impossible to violate in a correct contract) or an input/state validation 3. If the condition depends on user input, external call results, or mutable external state, flag it — it should be require() instead 4. Check Solidity version: in <0.8.0, failing assert uses the 0xfe INVALID opcode and consumes ALL remaining gas; in >=0.8.0, it reverts with Panic(uint256) error code 0x01 (refunds remaining gas but provides no custom message) 5. If an assert can be triggered by an attacker, check if the gas consumption creates a griefing vector
False Positives
- The
assertchecks a genuine invariant (e.g.,assert(totalSupply == sumOfAllBalances)after a provably correct operation) - The condition is mathematically unreachable given the contract's logic
- Used in test code, not production contracts
Remediation
- Use
require()for input validation and external call checks — it refunds remaining gas and accepts an error message - Reserve
assert()only for invariant checks that should never fail in a correct contract - In Solidity >=0.8.4, prefer custom errors with
requirefor gas-efficient error handling
// Correct: require for input validation
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
// assert is appropriate here: totalSupply should never change during transfer
assert(balances[msg.sender] + balances[to] == oldSum);
}Asserting Contract from Code Size
Preconditions
- Contract uses
extcodesizeoraddress.code.lengthto check whether an address is an EOA vs. a contract - This check gates access control, anti-bot logic, or security-sensitive operations
- The check assumes that code size == 0 means EOA
Vulnerable Pattern
modifier onlyEOA() {
// During constructor execution, extcodesize returns 0
// An attacker calling from their constructor bypasses this check
require(msg.sender.code.length == 0, "no contracts");
_;
}
function mint() external onlyEOA {
_mint(msg.sender, 1);
}
// Assembly variant
function isContract(address addr) internal view returns (bool) {
uint256 size;
assembly { size := extcodesize(addr) }
return size > 0; // Returns false during constructor
}Detection Heuristics
1. Search for extcodesize, .code.length, or helper functions named isContract 2. Check if the result is used to gate access (e.g., require(... == 0) to allow only EOAs) 3. If used for EOA-only enforcement, flag it — contracts calling from their constructor have code size 0 4. Also check for tx.origin == msg.sender as an alternative EOA check — flag it as incompatible with account abstraction (ERC-4337) and smart contract wallets 5. Check if the "no contracts" logic protects anything security-sensitive (minting limits, anti-bot, etc.)
False Positives
extcodesizeis used to check if a contract EXISTS at an address (not to distinguish EOA vs contract)- The check is combined with other protections that don't rely solely on code size
- The function has no security implications if called by a contract
Remediation
- There is no fully reliable on-chain method to distinguish EOAs from contracts
- Redesign logic to not depend on this distinction — make the system work correctly regardless of caller type
- If EOA-only behavior is truly needed, consider off-chain verification (e.g., signed messages from known EOAs)
- Remove
isContractchecks from security-critical paths
// Instead of gating by caller type, use per-address limits
mapping(address => bool) public hasMinted;
function mint() external {
require(!hasMinted[msg.sender], "already minted");
hasMinted[msg.sender] = true;
_mint(msg.sender, 1);
}Authorization Through tx.origin
Preconditions
- Contract uses
tx.originfor authorization or access control checks (e.g.,require(tx.origin == owner)) - A legitimate owner/admin may interact with untrusted external contracts
Vulnerable Pattern
contract Wallet {
address public owner;
function transferTo(address to, uint256 amount) external {
// tx.origin is the EOA that initiated the entire tx chain
// If owner calls MaliciousContract, which calls Wallet.transferTo,
// tx.origin is still the owner — check passes
require(tx.origin == owner, "not owner");
payable(to).transfer(amount);
}
}
contract Attacker {
Wallet wallet;
// Owner calls this (e.g., via a phishing link)
fallback() external {
// tx.origin == owner because owner initiated the tx
wallet.transferTo(address(this), address(wallet).balance);
}
}Detection Heuristics
1. Search for all instances of tx.origin in the codebase 2. If tx.origin is used in a require, if, or comparison for authorization purposes, flag it 3. Check if tx.origin is compared against privileged addresses (owner, admin, etc.) 4. Note: tx.origin == msg.sender used to verify EOA status is a different pattern (see asserting-contract-from-code-size) — still flag it but for different reasons (breaks with account abstraction)
False Positives
tx.originused only for logging or analytics, not for authorizationtx.originused in combination withmsg.senderchecks wheremsg.senderis the primary authorization mechanism
Remediation
- Replace
tx.originwithmsg.senderfor all authorization checks msg.senderreflects the immediate caller, not the transaction originator
function transferTo(address to, uint256 amount) external {
require(msg.sender == owner, "not owner"); // Immediate caller check
payable(to).transfer(amount);
}Vulnerability Cheatsheet
Quick-reference for identifying smart contract vulnerabilities during codebase scanning. Each section points to its full reference file for detailed analysis.
---
Arbitrary Storage Location
Reference: arbitrary-storage-location.md
User-controlled index on a dynamic array write (or sstore with user-controlled slot) allows overwriting any storage slot, including owner. The attacker computes an index that maps through the array's keccak256 layout to target critical slots.
data[index] = value; // index from user input, no bounds checkGrep-able keywords
sstore, .length =, data[, array[
---
Asserting Contract from Code Size
Reference: asserting-contract-from-code-size.md
Using extcodesize or .code.length == 0 to check if the caller is an EOA is bypassable -- contracts calling from their constructor have a code size of 0.
require(msg.sender.code.length == 0, "no contracts");Grep-able keywords
extcodesize, .code.length, isContract
---
Authorization Through tx.origin
Reference: authorization-txorigin.md
Using tx.origin for authorization allows phishing attacks: if the owner calls a malicious contract, that contract can call back into the victim contract and tx.origin will still be the owner. Use msg.sender instead.
require(tx.origin == owner, "not owner");Grep-able keywords
tx.origin
---
Delegatecall to Untrusted Callee
Reference: delegatecall-untrusted-callee.md
If the target of a delegatecall is user-controlled or set by an unprotected function, an attacker can execute arbitrary code in the context of the calling contract's storage, overwriting critical state like owner.
(bool success,) = callee.delegatecall(data); // callee from user inputGrep-able keywords
delegatecall, setImplementation, upgradeTo
---
DoS with Block Gas Limit
Reference: dos-gas-limit.md
Iterating over an unbounded dynamic array in a single transaction will eventually exceed the block gas limit as the array grows, permanently bricking the function. Replace push-payment with pull-payment or add batching/pagination.
for (uint256 i = 0; i < recipients.length; i++) {
payable(recipients[i]).transfer(reward);
}Grep-able keywords
for (, while (, .length, .push(
---
DoS with (Unexpected) Revert
Reference: dos-revert.md
A single reverting external call inside a loop blocks the entire function. Also: strict balance equality checks (address(this).balance ==) can be broken by force-sent ETH via selfdestruct, and unvalidated division denominators cause revert.
require(payable(recipients[i]).send(amounts[i]), "transfer failed"); // in loop
require(address(this).balance == expectedBalance); // broken by selfdestructGrep-able keywords
selfdestruct, .balance ==, .send(, .transfer(, require(success
---
Hash Collision with abi.encodePacked
Reference: hash-collision.md
When abi.encodePacked has two or more adjacent variable-length arguments (string, bytes, dynamic arrays), bytes can shift between arguments to produce the same encoding: encodePacked("a","bc") == encodePacked("ab","c"). Use abi.encode instead.
keccak256(abi.encodePacked(stringA, stringB)); // collision possibleGrep-able keywords
abi.encodePacked
---
Inadherence to Standards
Reference: inadherence-to-standards.md
Token implementations may deviate from ERC20/ERC721 specs (missing return values, missing events). Token integrations that use raw IERC20.transfer() instead of SafeERC20 break on non-compliant tokens (USDT). Hardcoding 18 decimals or ignoring fee-on-transfer is also a risk.
require(token.transfer(to, amount)); // reverts on USDT (no return value)Grep-able keywords
SafeERC20, safeTransfer, safeTransferFrom, .transfer(, .transferFrom(, .approve(, decimals
---
Incorrect Constructor Name
Reference: incorrect-constructor.md
In Solidity <0.4.22, constructors are named functions matching the contract name. A typo or case mismatch (e.g., owned() vs Owned) makes the constructor a regular public function anyone can call to seize ownership.
contract Owned {
function owned() public { owner = msg.sender; } // case mismatch!
}Grep-able keywords
pragma solidity 0.4, function Wallet, function owned
---
Insufficient Access Control
Reference: insufficient-access-control.md
State-changing functions (ownership transfer, fee setting, minting, pausing) that lack access control modifiers or require(msg.sender == ...) checks are callable by anyone. Also check that initialize() in upgradeable contracts has the initializer modifier.
function setOwner(address newOwner) external { owner = newOwner; } // no authGrep-able keywords
onlyOwner, onlyRole, msg.sender ==, initialize(, initializer
---
Insufficient Gas Griefing
Reference: insufficient-gas-griefing.md
In meta-transaction/relayer patterns, if replay protection (nonce marking) occurs before the sub-call and the relayer controls forwarded gas, the relayer can provide insufficient gas to silently fail the inner call while permanently consuming the nonce, censoring the action.
executed[nonce] = true; // marked before sub-call
(bool success,) = target.call{gas: gasLimit}(data); // may silently failGrep-able keywords
gasleft(), .call{gas:, executed[, nonce, meta-transaction, relayer
---
Lack of Precision
Reference: lack-of-precision.md
Division before multiplication truncates intermediate results and compounds rounding error. If the numerator is smaller than the denominator, the result truncates to zero. Always multiply first, then divide.
uint256 dailyRate = amount / 365; // truncates
uint256 fee = dailyRate * daysEarly; // wrong -- should be amount * daysEarly / 365Grep-able keywords
/ , * , WAD, RAY, 1e18, mulDiv
---
Missing Protection Against Signature Replay
Reference: missing-protection-signature-replay.md
If a signed message hash does not include a nonce, address(this), and block.chainid, signatures can be replayed on the same contract, across contracts, or across chains. Use EIP-712 with a domain separator.
bytes32 hash = keccak256(abi.encodePacked(to, amount)); // no nonce, no address, no chainidGrep-able keywords
ecrecover, ECDSA.recover, nonces, block.chainid, address(this), EIP712, domainSeparator
---
msg.value Reuse in Loops
Reference: msgvalue-loop.md
msg.value is constant for the entire transaction. Using it inside a loop allows a single payment to pass a require(msg.value >= price) check on every iteration, letting the caller buy N items for the price of one.
for (uint256 i = 0; i < ids.length; i++) {
require(msg.value >= price); // passes every iteration with one payment
_mint(msg.sender, ids[i]);
}Grep-able keywords
msg.value, multicall, delegatecall
---
Off-By-One Errors
Reference: off-by-one.md
Incorrect loop boundaries (< length - 1 skips last element, <= length goes out of bounds) and wrong comparison operators at thresholds (< vs <=) cause elements to be skipped, out-of-bounds access, or incorrect boundary enforcement.
for (uint256 i = 0; i < users.length - 1; i++) // skips last userGrep-able keywords
length - 1, <= length, < length
---
Outdated Compiler Version
Reference: outdated-compiler-version.md
Using an old Solidity version misses critical security features (e.g., <0.8.0 has no built-in overflow checks) and may contain known compiler bugs. Check pragma solidity against the latest stable release and the known bugs list.
Grep-able keywords
pragma solidity
---
Integer Overflow and Underflow
Reference: overflow-underflow.md
In Solidity <0.8.0, arithmetic wraps silently. In >=0.8.0, arithmetic inside unchecked {} or assembly {} blocks still wraps. Type downcasts (e.g., uint8(bigValue)) silently truncate in all versions.
unchecked { x += 1; } // wraps to 0 at max
uint8 small = uint8(256); // truncates to 0Grep-able keywords
unchecked, SafeMath, SafeCast, uint8(, uint16(, int8(, assembly
---
Reentrancy
Reference: reentrancy.md
If a contract makes an external call (.call(), .send(), .transfer(), _safeMint(), ERC777/ERC1155 hooks) before updating state, the callee can re-enter and exploit stale state. Follow checks-effects-interactions or use nonReentrant.
(bool success,) = msg.sender.call{value: bal}(""); // external call
balances[msg.sender] = 0; // state update AFTER -- vulnerableGrep-able keywords
.call{value, .send(, .transfer(, _safeMint, _safeTransfer, onERC721Received, onERC1155Received, tokensReceived, nonReentrant, ReentrancyGuard
---
Requirement Violation
Reference: requirement-violation.md
require() conditions that use > instead of >= (or vice versa) reject valid inputs or accept invalid ones. Also, require on external call return values may break on non-compliant tokens (e.g., USDT returns no bool).
require(balances[msg.sender] > amount); // should be >= to allow exact balanceGrep-able keywords
require(, assert(
---
Shadowing State Variables
Reference: shadowing-state-variables.md
In Solidity <0.6.0, a child contract can re-declare a state variable with the same name as a parent's, creating two separate storage slots. Parent functions read the parent's variable while child functions read the child's, causing inconsistent behavior.
contract Child is Base {
address public owner; // shadows Base.owner -- two different variables
}Grep-able keywords
is , override, virtual
---
Timestamp Dependence
Reference: timestamp-dependence.md
block.timestamp can be manipulated by validators within ~15 seconds. Using it for randomness is always exploitable. Using it in tight conditional windows (<=15s) allows validators to include/exclude transactions. Safe for large time windows (hours/days).
uint256 result = uint256(keccak256(abi.encodePacked(block.timestamp))) % 6;Grep-able keywords
block.timestamp, now, block.number
---
Transaction-Ordering Dependence (Frontrunning)
Reference: transaction-ordering-dependence.md
Functions whose outcome depends on transaction ordering (swaps without slippage protection, on-chain secret submissions, ERC20 approve race conditions) are vulnerable to frontrunning/sandwiching from mempool observers.
function swap(address tokenIn, address tokenOut, uint256 amountIn) external {
// no minAmountOut -- sandwich attack possible
}Grep-able keywords
minAmountOut, deadline, slippage, approve(, increaseAllowance, commit, reveal
---
Unchecked Return Values
Reference: unchecked-return-values.md
Low-level calls (.call(), .send(), .delegatecall()) return a boolean but do not revert on failure. If the return value is not checked, execution continues with state updates that assume success.
msg.sender.send(amount); // return value ignored -- silent failure
totalPaid += amount; // updated even if send failedGrep-able keywords
.call(, .send(, .delegatecall(, require(success
---
Unencrypted Private Data On-Chain
Reference: unencrypted-private-data-on-chain.md
The private visibility modifier only prevents other contracts from reading the variable. Anyone can read any storage slot via eth_getStorageAt. Never store plaintext secrets, passwords, or keys on-chain.
bytes32 private secretAnswer; // readable via eth_getStorageAtGrep-able keywords
private, secret, password, key, answer
---
Unexpected ecrecover Null Address
Reference: unexpected-ecrecover-null-address.md
ecrecover returns address(0) for invalid signatures. If the recovered address is not checked against address(0) and the expected signer is uninitialized (defaults to address(0)), the auth check passes for anyone. Use OpenZeppelin's ECDSA.recover.
address recovered = ecrecover(hash, v, r, s);
require(recovered == signer); // if signer is address(0), any invalid sig passesGrep-able keywords
ecrecover, address(0), ECDSA.recover
---
Uninitialized Storage Pointer
Reference: uninitialized-storage-pointer.md
In Solidity <0.5.0, local struct/array variables without an explicit memory or storage keyword default to storage at slot 0, silently overwriting early state variables (e.g., owner) on assignment.
User u; // defaults to storage slot 0 in <0.5.0
u.addr = _addr; // overwrites slot 0 (e.g., owner)Grep-able keywords
pragma solidity 0.4, storage, memory
---
Unsupported Opcodes
Reference: unsupported-opcodes.md
Contracts compiled with Solidity >=0.8.20 emit the PUSH0 opcode, which is unsupported on some chains. .transfer() and .send() use a 2300 gas stipend that is insufficient on chains like zkSync Era. Dynamic create/create2 with runtime bytecode fails on zkSync.
payable(msg.sender).transfer(amount); // 2300 gas -- fails on zkSync EraGrep-able keywords
pragma solidity 0.8.20, .transfer(, .send(, PUSH0, selfdestruct, create(, create2(
---
Use of Deprecated Functions
Reference: use-of-deprecated-functions.md
Deprecated Solidity keywords (suicide, sha3, block.blockhash, callcode, throw, msg.gas, constant as function modifier, var) may behave unexpectedly or fail to compile on newer versions. selfdestruct is also deprecated post-Dencun.
Grep-able keywords
suicide, sha3, block.blockhash, callcode, throw, msg.gas, selfdestruct, constant, var
---
Weak Sources of Randomness
Reference: weak-sources-randomness.md
Randomness derived from on-chain data (block.timestamp, block.prevrandao, blockhash, block.number) is deterministic and publicly visible. Another contract in the same transaction can compute the identical "random" value and only call when the outcome is favorable. Use Chainlink VRF.
uint256 random = uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))) % 100;Grep-able keywords
block.prevrandao, block.difficulty, blockhash, block.timestamp, keccak256, %
---
Assert Violation
Reference: assert-violation.md
assert() should only be used for invariants that can never fail in a correct contract. Using it for input validation or external call checks wastes all remaining gas on failure (<0.8.0) and provides no custom error message. Use require() instead.
assert(balances[msg.sender] >= amount); // wrong -- should be requireGrep-able keywords
assert(
---
Incorrect Inheritance Order
Reference: incorrect-inheritance-order.md
Solidity's C3 linearization gives precedence to the rightmost parent in the inheritance list. If two parents define the same function, the wrong order silently resolves to the unintended parent's implementation. Order from most base (left) to most derived (right).
contract Treasury is Governance, Ownable { } // Ownable.owner() wins (rightmost)Grep-able keywords
is , override(, virtual, super.
---
Unsecure Signatures (Composite)
Reference: unsecure-signatures.md
A composite vulnerability covering all signature anti-patterns: missing replay protection (no nonce/chainId/address), signature malleability (tracking by raw bytes), unchecked ecrecover null address, hash collisions from abi.encodePacked with dynamic types, and absence of EIP-712 structured signing.
bytes32 hash = keccak256(abi.encodePacked(to, amount)); // no nonce, no chainid
address recovered = ecrecover(hash, v, r, s); // no null check
require(!used[sig]); used[sig] = true; // malleable bypassGrep-able keywords
ecrecover, ECDSA.recover, abi.encodePacked, used[sig], EIP712, domainSeparator
---
Unbounded Return Data
Reference: unbounded-return-data.md
When .call() targets an untrusted address, Solidity automatically copies all return data into memory. A malicious callee can return megabytes of data, causing quadratic memory expansion costs and an out-of-gas revert. Use assembly to bound returndatacopy.
(bool success,) = callback.call(data); // attacker returns huge data, OOGGrep-able keywords
returndatasize, returndatacopy, ExcessivelySafeCall, .call(
---
Unused Variables
Reference: unused-variables.md
Unused state variables, parameters, or discarded return values may indicate dead code or missing logic (e.g., an unchecked transfer return value). Each unused variable should be evaluated: is it safe to remove, or does it signal a bug?
Grep-able keywords
Compiler warnings; no single keyword -- review declarations vs. references.
---
Signature Malleability
Reference: signature-malleability.md
For every ECDSA signature (r, s, v), a complementary signature (r, n-s, flipped_v) also recovers to the same address. If deduplication is done by raw signature bytes (mapping(bytes => bool)), an attacker can submit the malleable variant to bypass replay protection. Use OpenZeppelin's ECDSA library or track by nonce/hash.
mapping(bytes => bool) public usedSignatures; // malleable bypassGrep-able keywords
mapping(bytes =>, usedSignatures, ecrecover, ECDSA.recover
---
Unsafe Low-Level Call
Reference: unsafe-low-level-call.md
Low-level .call() to an address with no deployed code silently succeeds (the EVM treats it as a successful no-op). Unchecked return values compound the issue. Verify target has code (target.code.length > 0) and always check the return boolean.
(bool success,) = target.call(data); // succeeds even if target has no code
require(success); // passes -- no actual execution occurredGrep-able keywords
.call(, .delegatecall(, .staticcall(, .code.length, require(success
Delegatecall to Untrusted Callee
Preconditions
- Contract uses
delegatecall - The target address of the
delegatecallis derived from user input, function parameters, or a mutable state variable settable by non-admin users - OR: a proxy pattern where the implementation address can be changed by unauthorized parties
Vulnerable Pattern
// User-controlled delegatecall target
function forward(address callee, bytes calldata data) external {
// Attacker supplies callee = malicious contract
// Malicious contract overwrites storage (e.g., slot 0 = owner)
(bool success,) = callee.delegatecall(data);
require(success);
}
// Proxy with unprotected implementation setter
function setImplementation(address _impl) external {
// Missing: require(msg.sender == admin)
implementation = _impl;
}Detection Heuristics
1. Search for all delegatecall invocations in the codebase 2. For each, trace the target address: is it hardcoded, immutable, admin-only settable, or user-influenced? 3. If the target comes from a function parameter, calldata, or storage variable — check that only authorized roles can set it 4. For proxy contracts, verify that upgradeTo / setImplementation functions have proper access control 5. Check storage layout compatibility between the proxy and all possible implementation contracts 6. Flag any generic forwarding function that passes user-supplied addresses to delegatecall
False Positives
delegatecalltarget is hardcoded or immutable (e.g.,address immutable IMPL)- Target is set only in the constructor and cannot be changed
- Target is restricted to a whitelist of audited contracts
- Standard proxy patterns (EIP-1967, UUPS, TransparentProxy) with proper access control on upgrades
Remediation
- Restrict
delegatecalltargets to trusted, immutable, or admin-only-settable addresses - Use established proxy patterns (OpenZeppelin TransparentProxy, UUPS) with proper access control
- Never expose
delegatecallwith a user-supplied target address - Verify storage layout compatibility between proxy and implementation contracts using tools like OpenZeppelin's storage layout checker
// Safe: immutable implementation
address immutable implementation;
constructor(address _impl) {
implementation = _impl;
}
fallback() external payable {
(bool s,) = implementation.delegatecall(msg.data);
require(s);
}DoS with Block Gas Limit
Preconditions
- Contract iterates over a dynamic array or mapping whose size can grow unboundedly
- The iteration must complete in a single transaction (no batching/pagination)
- OR: time-sensitive logic where block stuffing by an attacker can delay transaction inclusion
Vulnerable Pattern
address[] public recipients;
function addRecipient(address r) external {
recipients.push(r); // Array grows without bound
}
// Push-payment: one tx must process all recipients
function distributeRewards() external {
for (uint256 i = 0; i < recipients.length; i++) {
// When recipients.length grows large enough,
// this loop exceeds block gas limit and ALWAYS reverts
payable(recipients[i]).transfer(reward);
}
}Detection Heuristics
1. Identify all loops (for, while) in the codebase 2. For each loop, check if the iteration count depends on a dynamic array or storage structure that can grow over time 3. If the loop is unbounded and must complete in a single transaction, flag it — it will eventually exceed the block gas limit 4. Check if the function supports batching or pagination (e.g., startIndex, batchSize parameters) — if not, flag it 5. For time-sensitive functions (auctions, deadlines, liquidations), check if an attacker could stuff blocks with high-gas transactions to delay inclusion
False Positives
- Loop iterates over a fixed-size or bounded array (e.g.,
uint256[10], array with a cappedmaxLength) - Function supports paginated/batched execution across multiple transactions
- Loop iteration count is controlled by the caller (e.g., batch size parameter with reasonable max)
- The array is admin-only appendable and has a practical maximum
Remediation
- Replace push-payment (contract sends to all) with pull-payment (recipients withdraw individually)
- If iteration is unavoidable, add batching/pagination with
startIndexandbatchSizeparameters - Cap array sizes with a maximum length check on push operations
- For time-sensitive logic, avoid designs where block stuffing can be profitable
// Pull-payment pattern
mapping(address => uint256) public pendingWithdrawals;
function claimReward() external {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}DoS with (Unexpected) Revert
Preconditions
- Critical contract logic depends on an external call succeeding
- A single revert in the external call blocks the entire function
- OR: strict equality checks on contract balance can be violated by force-sent ETH
- OR: division by zero is possible due to unvalidated denominators
Vulnerable Pattern
// Push-payment: one reverting recipient blocks all payments
function payAll() external {
for (uint256 i = 0; i < recipients.length; i++) {
// If ANY recipient reverts (e.g., contract with no receive()),
// the entire function reverts — no one gets paid
require(payable(recipients[i]).send(amounts[i]), "transfer failed");
}
}
// Strict balance check broken by force-sent ETH
function withdraw() external {
// Attacker sends ETH via selfdestruct, breaking this check
require(address(this).balance == expectedBalance, "invariant");
_processWithdrawal();
}
// Division by zero
function distribute(uint256 totalShares) external {
// If totalShares == 0, this reverts and blocks the function
uint256 perShare = totalRewards / totalShares;
}Detection Heuristics
1. Search for loops containing require or assert on external call results — one failure blocks all iterations 2. Search for push-payment patterns: contract iterating over recipients and sending ETH/tokens in one transaction 3. Search for strict balance equality checks (address(this).balance ==) — these can be broken by selfdestruct or coinbase rewards force-sending ETH 4. Search for division operations and check if the denominator can be zero 5. Check for require(success) after .send() or .call() inside loops — this turns a single recipient failure into a full DoS 6. Look for "highest bidder" or "king of the hill" patterns where the current leader's refund must succeed for a new leader to be set
False Positives
- Pull-payment pattern is used (each recipient withdraws individually)
- The external call target is a trusted, known contract that will not revert
- Division denominator is guaranteed non-zero by prior checks or invariants
- Balance checks use
>=instead of== - The function handles individual failures gracefully (try/catch, continue on failure)
Remediation
- Replace push-payment with pull-payment: let recipients withdraw individually
- Use
>=instead of==for balance checks to tolerate force-sent ETH - Validate all denominators before division:
require(totalShares > 0) - In loops, handle individual call failures without reverting the whole transaction
- Use try/catch for external calls where failure should not be fatal
// Pull-payment pattern
mapping(address => uint256) public pendingWithdrawals;
function claimPayment() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "nothing to claim");
pendingWithdrawals[msg.sender] = 0;
(bool success,) = msg.sender.call{value: amount}("");
require(success);
}Hash Collision with abi.encodePacked()
Preconditions
- Contract uses
abi.encodePacked()to encode data before hashing (typically withkeccak256) - Two or more adjacent arguments in the
encodePackedcall are variable-length types (strings, bytes, dynamic arrays) - The resulting hash is used for authentication, deduplication, or signature verification
Vulnerable Pattern
function verify(string memory a, string memory b, bytes memory sig) external {
// abi.encodePacked("a", "bc") == abi.encodePacked("ab", "c")
// Attacker shifts bytes between arguments to forge a valid hash
bytes32 hash = keccak256(abi.encodePacked(a, b));
require(ECDSA.recover(hash, sig) == trustedSigner);
_execute(a, b);
}
// Array variant:
// abi.encodePacked([addr1, addr2], [addr3])
// == abi.encodePacked([addr1], [addr2, addr3])Detection Heuristics
1. Search for abi.encodePacked( calls 2. Check how many arguments are variable-length types (string, bytes, dynamic arrays like address[], uint256[]) 3. If two or more adjacent arguments are variable-length, flag it — elements can be shifted between arguments to produce an identical encoding 4. Check if the packed result feeds into keccak256 for security-sensitive purposes (signature verification, access control, deduplication) 5. If only one argument is variable-length, or all arguments are fixed-length (address, uint256, bool), it is safe
False Positives
- Only one argument is a variable-length type (no adjacent dynamic types to shift between)
- All arguments are fixed-length types (address, uint256, bytes32, bool, etc.)
abi.encode()is used instead ofabi.encodePacked()(includes length prefixes, no collision)- The hash is not used for any security-sensitive purpose
Remediation
- Replace
abi.encodePacked()withabi.encode()— it includes length prefixes that prevent collisions - If
encodePackedmust be used for gas efficiency, ensure at most one argument is a variable-length type - Alternatively, separate variable-length arguments with fixed-length delimiters
// Safe: abi.encode includes length prefixes
bytes32 hash = keccak256(abi.encode(a, b));
// Also safe: only one variable-length argument
bytes32 hash = keccak256(abi.encodePacked(fixedAddr, dynamicString));Inadherence to Standards
Preconditions
- Contract claims to implement a standard (ERC20, ERC721, ERC1155, etc.) but deviates from the specification
- OR: contract integrates external tokens assuming strict standard compliance without handling common deviations
Vulnerable Pattern
// Non-compliant ERC20: missing return value on transfer
// (matches USDT, BNB behavior — breaks callers that check return)
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount;
balances[to] += amount;
// Missing: return true;
// Missing: emit Transfer(msg.sender, to, amount);
}
// Caller assumes strict compliance — breaks on non-compliant tokens
function depositToken(IERC20 token, uint256 amount) external {
// Reverts on tokens that don't return bool (USDT)
require(token.transfer(address(this), amount), "transfer failed");
deposits[msg.sender] += amount;
// Bug: doesn't account for fee-on-transfer tokens
// Actual received amount may be less than `amount`
}Detection Heuristics
1. For token implementations: check that all required functions, return values, and events match the standard exactly (e.g., ERC20 requires transfer returns bool and emits Transfer) 2. For token integrations: check if SafeERC20 is used for transfer/transferFrom/approve calls — raw IERC20 calls break on non-compliant tokens 3. Check for hardcoded assumptions: 18 decimals, no fee-on-transfer, no rebasing, no blocklists 4. For fee-on-transfer tokens: check if the contract uses balance-before/balance-after pattern to measure actual received amount 5. Check for missing safeTransfer/safeTransferFrom wrappers
False Positives
- The contract explicitly documents that it only supports fully compliant ERC20 tokens and enforces this via a whitelist
SafeERC20from OpenZeppelin is used, which handles missing return values- The contract checks balance differences to account for fee-on-transfer
Remediation
- For token implementations: strictly follow the standard — include all return values, events, and function signatures
- For token integrations: use OpenZeppelin's
SafeERC20for all token interactions - Use balance-before/balance-after pattern for fee-on-transfer support
- Don't hardcode decimals — read from the token contract
import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
function depositToken(IERC20 token, uint256 amount) external {
uint256 balBefore = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - balBefore;
deposits[msg.sender] += received;
}Incorrect Constructor Name
Preconditions
- Solidity version <0.4.22 where constructors are named functions matching the contract name
- The function name does not exactly match the contract name (typo, case mismatch, or contract renamed without updating the constructor)
Vulnerable Pattern
// Solidity <0.4.22: constructor is a named function
contract Owned {
address public owner;
// Typo: "owned" != "Owned" (case mismatch)
// This becomes a regular public function anyone can call
function owned() public {
owner = msg.sender;
}
}
// Contract renamed but constructor not updated
contract Treasury {
address public owner;
// Was "Wallet" before rename — now a regular public function
function Wallet() public {
owner = msg.sender;
}
}Detection Heuristics
1. Check the Solidity version: if >=0.4.22 and the constructor keyword is used, this vulnerability does not apply 2. For <0.4.22 contracts: find the function that sets initial state (owner, parameters) and verify its name exactly matches the contract name (case-sensitive) 3. Search for public/external functions that set owner or perform one-time initialization — these may be misnamed constructors 4. Check if the contract was renamed at any point (git history, comments) but the constructor function was not updated 5. Flag any named function that appears to perform initialization logic (sets owner, initializes critical state) but doesn't match the contract name
False Positives
- Solidity >=0.4.22 using the
constructorkeyword (enforced by compiler) - The function is intentionally a public initializer (e.g., in proxy patterns) with proper access control
Remediation
- Upgrade to Solidity >=0.4.22 and use the
constructorkeyword - For legacy contracts, verify the constructor function name exactly matches the contract name
// Modern Solidity: compiler-enforced constructor
contract Owned {
address public owner;
constructor() {
owner = msg.sender;
}
}Incorrect Inheritance Order
Preconditions
- Contract uses multiple inheritance (
is ContractA, ContractB, ...) - Two or more parent contracts define a function with the same name and signature
- The inheritance order (left-to-right) does not match the developer's intended resolution
Vulnerable Pattern
contract Ownable {
function owner() public view virtual returns (address) {
return _owner; // Returns EOA owner
}
}
contract Governance {
function owner() public view virtual returns (address) {
return governance; // Returns governance contract
}
}
// C3 linearization: rightmost (Ownable) takes precedence
// Developer intended Governance.owner() but gets Ownable.owner()
contract Treasury is Governance, Ownable {
// owner() resolves to Ownable (rightmost) — may not be intended
// Should be: is Ownable, Governance (if Governance should win)
}Detection Heuristics
1. Identify all contracts using multiple inheritance (is A, B, C) 2. For each inheritance chain, check if parent contracts define functions with the same name and signature 3. Verify the inheritance order follows general-to-specific (most base first, most derived last) — Solidity's C3 linearization gives precedence to the rightmost parent 4. Check override specifiers: override(A, B) should explicitly list which parents are being overridden 5. Trace the actual resolution order and compare against documented or intended behavior
False Positives
- Only one parent defines the function (no ambiguity)
- The contract explicitly overrides the function with
override(A, B)and provides its own implementation - The inheritance order is intentionally general-to-specific and produces the correct resolution
Remediation
- Order inheritance from most base to most derived (general-to-specific, left-to-right)
- Explicitly override conflicting functions and specify which parents:
override(ContractA, ContractB) - Test function resolution in complex hierarchies
// Correct: general-to-specific order
contract Treasury is Ownable, Governance {
function owner() public view override(Ownable, Governance) returns (address) {
return Governance.owner(); // Explicit resolution
}
}Insufficient Access Control
Preconditions
- Contract has functions that modify sensitive state (ownership, balances, fees, minting, pausing, protocol parameters)
- Those functions lack access control modifiers (
onlyOwner,onlyRole, etc.) orrequire(msg.sender == ...)checks - OR: access control exists but is incomplete (e.g., role assignments themselves are unprotected)
Vulnerable Pattern
address public owner;
uint256 public feeRate;
// Missing access control — anyone can call
function setFeeRate(uint256 newRate) external {
feeRate = newRate;
}
// Missing access control — anyone can take ownership
function setOwner(address newOwner) external {
owner = newOwner;
}
// Incomplete: role grant is unprotected
function grantRole(address user, bytes32 role) external {
// Missing: require(hasRole(ADMIN_ROLE, msg.sender))
_roles[role][user] = true;
}Detection Heuristics
1. Identify all external and public functions that modify state variables 2. For each, check if it has an access control modifier (onlyOwner, onlyRole, whenNotPaused, etc.) or an inline require(msg.sender == ...) check 3. If a state-changing function has no access control, flag it — especially if it modifies ownership, balances, fees, or addresses 4. Check that role/permission management functions themselves are protected (e.g., grantRole requires ADMIN_ROLE) 5. Check initialize() functions in upgradeable contracts — they must have an initializer modifier to prevent re-initialization
False Positives
- The function is intentionally permissionless by design (e.g.,
deposit(),claim()where anyone should be able to call) - Access control is enforced in an internal function that the external function always calls through
- The contract is an implementation behind a proxy, and access control is enforced at the proxy level
Remediation
- Add explicit access control to every state-changing function that should be restricted
- Use OpenZeppelin's
OwnableorAccessControlfor standardized role management - Protect
initialize()with theinitializermodifier - Apply the principle of least privilege: each function should require the minimum role necessary
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract Secure is Ownable {
function setFeeRate(uint256 newRate) external onlyOwner {
feeRate = newRate;
}
}Insufficient Gas Griefing
Preconditions
- Contract relays or forwards calls on behalf of users (meta-transactions, multisig execution, relayer patterns)
- The relayer/executor controls how much gas is forwarded to the sub-call
- Replay protection (nonce/hash marking) occurs before or regardless of sub-call success
- No minimum gas requirement is enforced before the sub-call
Vulnerable Pattern
function execute(address target, bytes calldata data, uint256 gasLimit) external {
// Replay protection BEFORE sub-call — marks as executed regardless
require(!executed[nonce], "already executed");
executed[nonce] = true;
nonce++;
// Relayer can provide just enough gas for the outer tx to succeed
// but insufficient gas for the inner call — it silently fails
(bool success,) = target.call{gas: gasLimit}(data);
// success is false, but the nonce is already consumed
// The action is permanently censored
}Detection Heuristics
1. Identify relayer/meta-transaction patterns: functions that execute calls on behalf of other users 2. Check if the function marks a nonce/hash as used BEFORE the sub-call succeeds — this enables permanent censorship 3. Check if there's a gasleft() validation before the sub-call (e.g., require(gasleft() >= requiredGas + overhead)) 4. Look for multisig execute functions where the executor controls gas forwarding 5. Check if .call{gas: X} is used where X comes from the caller — the caller can set it too low
False Positives
- Replay protection only marks the nonce after confirming sub-call success
- The function enforces a minimum gas requirement before the sub-call
- The sub-call failure is propagated (e.g.,
require(success)) so the outer tx also reverts, preserving the nonce - The gas parameter is fixed or validated against a minimum
Remediation
- Enforce minimum gas before sub-calls:
require(gasleft() >= gasLimit + OVERHEAD) - Only mark nonces/hashes as used AFTER confirming sub-call success
- Propagate sub-call failures to revert the outer transaction when appropriate
- Use EIP-150 rule awareness: the caller retains 1/64 of gas, so forward at least
gasLimit * 64/63
function execute(address target, bytes calldata data, uint256 gasLimit) external {
require(gasleft() >= gasLimit + 10000, "insufficient gas");
(bool success, bytes memory result) = target.call{gas: gasLimit}(data);
// Only mark as executed if sub-call succeeded
if (success) {
executed[nonce] = true;
nonce++;
}
}Lack of Precision
Preconditions
- Contract performs integer arithmetic (division, fee calculations, reward distributions)
- Division is performed before multiplication, OR
- Numerators can be smaller than denominators (producing zero), OR
- No fixed-point scaling (WAD/RAY) is used for fractional calculations
Vulnerable Pattern
function calculateFee(uint256 amount, uint256 daysEarly) external view returns (uint256) {
// Division BEFORE multiplication — truncates intermediate result
uint256 dailyRate = amount / 365; // Loses precision
uint256 fee = dailyRate * daysEarly; // Error compounds
// Correct: amount * daysEarly / 365 (multiply first)
return fee;
}
function distribute(uint256 reward, uint256 totalShares) external {
for (uint256 i = 0; i < holders.length; i++) {
// If reward < totalShares, this is always 0
uint256 share = reward / totalShares * balances[holders[i]];
_transfer(holders[i], share);
}
}Detection Heuristics
1. Search for division operations (/) in arithmetic expressions 2. Check if division appears before multiplication in the same expression — this loses precision 3. Check if the numerator can be smaller than the denominator — the result truncates to zero 4. Look for fee, reward, interest, or share calculations without scaling factors (e.g., 1e18) 5. Check rounding direction: does truncation favor the protocol or the user? In fee/debt calculations, rounding should favor the protocol; in reward/credit calculations, it should favor the user
False Positives
- Multiplication is performed before division in the correct order
- Fixed-point math libraries (WAD = 1e18, RAY = 1e27) are used to maintain precision
- The numerator is guaranteed to be larger than the denominator by prior validation
- The precision loss is intentionally accepted and documented (e.g., dust amounts)
Remediation
- Always multiply before dividing:
amount * rate / divisorinstead ofamount / divisor * rate - Use fixed-point math with scaling factors (1e18 for WAD, 1e27 for RAY)
- Round in favor of the protocol for fees/debts, in favor of users for rewards/credits
- Use
mulDivfrom OpenZeppelin or PRBMath for safe full-precision multiplication then division
// Correct: multiply first, then divide
uint256 fee = amount * daysEarly / 365;
// With scaling for precision
uint256 WAD = 1e18;
uint256 scaledRate = (amount * WAD) / totalSupply;
uint256 reward = (scaledRate * userBalance) / WAD;Missing Protection Against Signature Replay
Preconditions
- Contract verifies ECDSA signatures for authorization
- The signed message does not include a nonce, OR does not include the contract address, OR does not include the chain ID
- No mechanism tracks which signatures have been processed
Vulnerable Pattern
function executeWithSig(address to, uint256 amount, bytes memory sig) external {
// Missing: nonce, contract address, and chain ID in hash
// Same signature can be replayed on same contract, other contracts, or other chains
bytes32 hash = keccak256(abi.encodePacked(to, amount));
address signer = ECDSA.recover(hash, sig);
require(signer == authorizer, "invalid sig");
// No nonce tracking — same signature can be submitted repeatedly
_transfer(to, amount);
}Detection Heuristics
1. Search for ecrecover or ECDSA.recover usage 2. Examine what is included in the signed hash — check for:
- Nonce: is there a per-signer incrementing nonce? Flag if absent (enables same-contract replay)
- Contract address (
address(this)): flag if absent (enables cross-contract replay) - Chain ID (
block.chainid): flag if absent (enables cross-chain replay)
3. Check if processed signatures/hashes are tracked in a mapping to prevent reuse 4. Check if EIP-712 domain separator is used (it includes contract address and chain ID automatically) 5. Verify that the nonce is incremented BEFORE execution, not after (to prevent reentrancy-based replay)
False Positives
- EIP-712 domain separator is used with proper nonce tracking (covers address + chainId + nonce)
- The signed message includes all three: nonce, contract address, and chain ID
- The signature authorizes a one-time action that is inherently non-replayable (e.g., EIP-2612 permit with deadline and nonce)
Remediation
- Include nonce,
address(this), andblock.chainidin the signed message hash - Track processed nonces per signer in a mapping
- Use EIP-712 structured data signing with a domain separator
mapping(address => uint256) public nonces;
function executeWithSig(address to, uint256 amount, bytes memory sig) external {
uint256 nonce = nonces[msg.sender]++;
bytes32 hash = keccak256(abi.encodePacked(
to, amount, nonce, address(this), block.chainid
));
bytes32 ethHash = ECDSA.toEthSignedMessageHash(hash);
address signer = ECDSA.recover(ethHash, sig);
require(signer == authorizer, "invalid sig");
_transfer(to, amount);
}msg.value Reuse in Loops
Preconditions
msg.valueis referenced inside a loop (for,while) or in a function called multiple times within a single external call- The contract has an existing ETH balance or the logic assumes
msg.valueis "spent" per iteration
Vulnerable Pattern
function batchBuy(uint256[] calldata ids) external payable {
for (uint256 i = 0; i < ids.length; i++) {
// msg.value is the SAME on every iteration — it never decreases
// If price == 1 ETH and user sends 1 ETH, they can buy N items
require(msg.value >= price, "insufficient payment");
_mint(msg.sender, ids[i]);
}
// User paid 1 ETH but bought N items
}
// Payable multicall — same issue
function multicall(bytes[] calldata calls) external payable {
for (uint256 i = 0; i < calls.length; i++) {
// msg.value forwarded to each sub-call — reused each time
(bool s,) = address(this).delegatecall(calls[i]);
require(s);
}
}Detection Heuristics
1. Search for msg.value usage inside for, while, or do-while loops 2. Search for msg.value in functions that are called via delegatecall in a loop (multicall patterns) 3. Check if msg.value is used in a require check inside a loop — passes on every iteration after a single payment 4. Search for internal functions that reference msg.value and are called multiple times from a payable external function 5. Check if the contract subtracts from a local tracking variable instead of relying on msg.value directly
False Positives
- The function tracks remaining value in a local variable and decrements it per iteration (e.g.,
remaining -= price) msg.valueis only referenced once outside any loop- The loop is guaranteed to execute exactly once
- The function validates total cost against
msg.valuebefore the loop (e.g.,require(msg.value == price * ids.length))
Remediation
- Track remaining ETH in a local variable and decrement per operation
- Validate total cost upfront:
require(msg.value == price * count) - In multicall patterns, ensure
msg.valueis consumed only once, or use a tracking variable
function batchBuy(uint256[] calldata ids) external payable {
uint256 totalCost = price * ids.length;
require(msg.value >= totalCost, "insufficient payment");
for (uint256 i = 0; i < ids.length; i++) {
_mint(msg.sender, ids[i]);
}
// Refund excess
if (msg.value > totalCost) {
payable(msg.sender).transfer(msg.value - totalCost);
}
}Off-By-One Errors
Preconditions
- Contract uses loops with boundary conditions, comparison operators at thresholds, or array index calculations
- The boundary/comparison is off by exactly one from the intended behavior
Vulnerable Pattern
// Skips the last element
function processAll() external {
for (uint256 i = 0; i < users.length - 1; i++) {
// Should be i < users.length
// Last user is never processed
_distribute(users[i]);
}
}
// Off-by-one in threshold check
function liquidate(uint256 ratio) external {
// Should be ratio < MIN_RATIO (liquidate when below)
// Using <= means accounts AT the minimum are also liquidated
require(ratio <= MIN_RATIO, "healthy");
_liquidate();
}
// Out-of-bounds access
function getLastUser() external view returns (address) {
return users[users.length]; // Should be users.length - 1
}Detection Heuristics
1. For every loop, check the boundary condition: < length vs <= length vs < length - 1 2. < length - 1 skips the last element — flag unless intentional 3. <= length goes out of bounds on array access — flag always 4. For comparison operators at thresholds (>, >=, <, <=), verify the boundary matches the specification (e.g., "greater than" vs "greater than or equal to") 5. Check pagination logic for fence-post errors: does the first page start at 0 or 1? Does the last batch include the final element? 6. Look for length - 1 on arrays that could be empty — this underflows to type(uint256).max in unchecked contexts or reverts in checked contexts
False Positives
- The boundary is intentionally exclusive (e.g.,
< length - 1to skip the sentinel/last element by design) - The comparison operator matches the documented specification exactly
- The code is iterating over pairs (
i < length - 1to comparearr[i]witharr[i+1])
Remediation
- Verify each boundary condition against the specification or documented intent
- Be explicit about inclusive vs exclusive bounds in comments
- Guard against empty array underflow: check
length > 0beforelength - 1
function processAll() external {
for (uint256 i = 0; i < users.length; i++) {
_distribute(users[i]);
}
}
// Explicit about boundary semantics
function liquidate(uint256 ratio) external {
require(ratio < MIN_RATIO, "healthy"); // Strictly below = liquidatable
_liquidate();
}Outdated Compiler Version
Preconditions
- Contract is compiled with a Solidity version significantly behind the latest stable release
- The compiler version used has known bugs or is missing important security features
Vulnerable Pattern
// Old version missing built-in overflow checks
pragma solidity 0.7.6;
contract Token {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
// No overflow protection — 0.7.x lacks built-in checks
// Requires manual SafeMath usage
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
// Slightly newer but still has known bugs
pragma solidity 0.8.0;
// 0.8.0 had ABI encoding bugs fixed in later patchesDetection Heuristics
1. Check the pragma solidity version in all contract files 2. Compare against the latest stable Solidity release — flag if significantly outdated 3. Cross-reference the exact version against the Solidity known bugs list 4. Check if the contract misses key safety features from newer versions:
- <0.8.0: no built-in overflow/underflow checks
- <0.8.4: no custom errors (gas efficiency)
- <0.8.20: no PUSH0 opcode support
5. Flag if the version has known critical bugs (check https://solidity.readthedocs.io/en/latest/bugs.html)
False Positives
- The version is intentionally chosen for compatibility with a specific deployment target or toolchain
- The version is recent (within 1-2 minor versions of latest) and has no known critical bugs
- The project has documented reasons for the specific version choice
Remediation
- Upgrade to the latest stable Solidity version unless there's a specific compatibility constraint
- Cross-reference the current version against the known bugs list before and after upgrading
- When upgrading across major boundaries (e.g., 0.7 to 0.8), review all arithmetic for compatibility with built-in overflow checks
// Use latest stable version
pragma solidity 0.8.24;
contract Token {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount; // Built-in overflow check
balances[to] += amount;
}
}Integer Overflow and Underflow
Preconditions
- Solidity <0.8.0 without SafeMath, OR
- Arithmetic inside
unchecked { }blocks, OR - Arithmetic inside
assembly { }/ Yul blocks, OR - Type downcasting (e.g.,
uint8(uint256Var)), OR - Shift operators (
<<,>>) on values near type boundaries
Vulnerable Pattern
// Pre-0.8.0: wraps silently
uint256 balance = 0;
balance -= 1; // Underflows to 2^256 - 1
// Post-0.8.0 bypass via unchecked block
unchecked {
uint256 x = type(uint256).max;
x += 1; // Wraps to 0, no revert
}
// Type downcast truncation (all versions)
uint256 big = 256;
uint8 small = uint8(big); // Silently truncates to 0
// Assembly arithmetic (all versions)
assembly {
let x := sub(0, 1) // Underflows to max uint256
}Detection Heuristics
1. Check the Solidity version: if <0.8.0, flag ALL arithmetic operations not wrapped in SafeMath 2. If >=0.8.0, search for unchecked blocks and audit every arithmetic operation inside them 3. Search for assembly blocks and audit all add, sub, mul, div, shl, shr operations within 4. Search for type downcasts: patterns like uint8(, uint16(, int8(, etc. — verify the source value is bounds-checked before casting 5. Search for shift operations (<<, >>) and verify the operand cannot overflow the target type 6. Check if overflow-induced reverts on critical paths could cause DoS
False Positives
- Solidity >=0.8.0 arithmetic outside of
uncheckedandassemblyblocks (automatically checked) uncheckedblocks used only for loop counter increments (i++) where overflow is impossible due to bounded loop- Downcasts where the value is validated to fit the target type before casting (e.g.,
require(x <= type(uint8).max)) - Assembly arithmetic on values with proven bounds
Remediation
- For Solidity <0.8.0: use OpenZeppelin's
SafeMathfor all arithmetic - For >=0.8.0: minimize
uncheckedblocks to only provably safe operations (e.g., bounded loop counters) - Use OpenZeppelin's
SafeCastlibrary for all type downcasts - Validate bounds before assembly arithmetic
// Safe downcast
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
uint8 small = SafeCast.toUint8(big); // Reverts if big > 255Reentrancy
Preconditions
- Contract makes an external call (ETH transfer, token transfer,
.call(),.send(),.transfer(), callback hook) - State is modified after the external call, not before
- No reentrancy guard (
nonReentrantmodifier) on the function - For cross-function: two or more functions share state, and at least one makes an external call before updating that shared state
- For cross-contract: Contract B reads Contract A's state, and A makes an external call before updating it
- For read-only: Contract A has a reentrancy guard but updates state after an external call; Contract B reads A's state without sharing the same lock
Vulnerable Pattern
// Single-function reentrancy
function withdraw() external {
uint256 bal = balances[msg.sender];
// External call BEFORE state update
(bool success,) = msg.sender.call{value: bal}("");
require(success);
// State update AFTER external call — attacker reenters withdraw()
// and balances[msg.sender] is still the original value
balances[msg.sender] = 0;
}
// Hidden external calls that trigger callbacks:
// ERC721._safeMint() -> onERC721Received()
// ERC1155.safeTransferFrom() -> onERC1155Received()
// ERC777 token transfers -> tokensReceived() hookDetection Heuristics
1. Identify all external calls: .call(), .send(), .transfer(), token transfers, _safeMint(), _safeTransfer(), ERC777/ERC1155 safe transfers 2. For each external call, check if any state variable is written AFTER the call in the same function 3. If state is written after an external call, check if a nonReentrant guard is present on the function — flag if absent 4. Check for cross-function reentrancy: does the function share state with other functions that could be called during the reentrant window? 5. Check for cross-contract reentrancy: does any other contract read this contract's state that is stale during the external call? 6. Check for hidden callbacks: _safeMint, _safeTransfer, ERC777 hooks, ERC1155 hooks — these are external calls even though they don't look like .call()
False Positives
- State is updated BEFORE the external call (checks-effects-interactions pattern correctly followed)
nonReentrantmodifier is applied to the function- The external call target is a trusted, immutable contract (e.g., WETH) with no callback mechanism
- The function is
view/pureand cannot modify state - The only state read after reentry is already finalized (e.g., immutable variables)
Remediation
- Apply the checks-effects-interactions pattern: perform all state changes before any external call
- Add OpenZeppelin's
ReentrancyGuardwithnonReentrantmodifier to all functions that make external calls - For cross-contract reentrancy, use a shared reentrancy lock across contracts or ensure state is finalized before external calls
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
balances[msg.sender] = 0; // State update BEFORE external call
(bool success,) = msg.sender.call{value: bal}("");
require(success);
}Requirement Violation
Preconditions
- Contract uses
require()statements for input or state validation - The
requirecondition is overly restrictive (rejects valid inputs) or too loose (accepts invalid inputs) - OR:
require()validates return values from external contracts whose behavior doesn't match assumptions
Vulnerable Pattern
// Overly restrictive: blocks legitimate use case
function withdraw(uint256 amount) external {
// Fails if user has exactly the required amount (should be >=)
require(balances[msg.sender] > amount, "insufficient");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// External contract assumption mismatch
function processPayment(IERC20 token, uint256 amount) external {
// Assumes transfer returns true, but some tokens don't return a value
// require reverts on tokens like USDT
require(token.transfer(msg.sender, amount), "failed");
}
// Missing error message
function setRate(uint256 rate) external {
require(rate > 0); // No error message — difficult to debug
}Detection Heuristics
1. For each require(), verify the condition matches the documented business logic (e.g., > vs >=, < vs <=) 2. Check if require() validates an external call's return value — verify the external contract actually returns what's expected 3. Look for require() without error messages — while not a vulnerability, it makes debugging difficult 4. Check if overly strict requirements can DoS critical paths (e.g., withdrawals, liquidations) 5. Check chained contract calls: does an upstream contract provide inputs that could fail downstream require checks?
False Positives
- The
requirecondition exactly matches the specification - The strictness is intentional and documented
- The error message is omitted in a pre-0.8.4 contract for gas optimization (custom errors not yet available)
Remediation
- Verify each
requirecondition against the specification:>vs>=,<vs<= - Add descriptive error messages or use custom errors (Solidity >=0.8.4)
- For external call validations, use SafeERC20 or similar wrappers that handle non-standard return values
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// Solidity >=0.8.4: custom errors for gas efficiency
error InsufficientBalance(uint256 available, uint256 requested);
function withdrawV2(uint256 amount) external {
if (balances[msg.sender] < amount)
revert InsufficientBalance(balances[msg.sender], amount);
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}Shadowing State Variables
Preconditions
- Contract inherits from one or more parent contracts
- A state variable in the child contract has the same name as a state variable in a parent contract
- Solidity version <0.6.0 (>=0.6.0 disallows state variable shadowing with a compiler error)
- OR: function parameters or local variables shadow state variables (any Solidity version)
Vulnerable Pattern
contract Base {
address public owner;
constructor() {
owner = msg.sender;
}
}
// Solidity <0.6.0: this compiles without error
contract Child is Base {
address public owner; // Shadows Base.owner — creates a NEW variable
function setOwner(address _owner) external {
owner = _owner; // Sets Child.owner, NOT Base.owner
}
// Base's functions still read Base.owner (the original)
// Child's functions read Child.owner (the shadow)
// These are two different storage variables!
}
// Local variable shadowing (any version)
contract Example {
uint256 public value = 100;
function getValue() public view returns (uint256) {
uint256 value = 0; // Shadows state variable
return value; // Returns 0, not 100
}
}Detection Heuristics
1. Check Solidity version: if <0.6.0, search for state variables in child contracts that share names with parent contract variables 2. For any version, search for function parameters and local variables that share names with state variables 3. Check compiler warnings — modern Solidity warns about local shadowing even if it allows it 4. For each shadowed variable, trace which version (parent's or child's) each function reads/writes — inconsistency indicates a bug 5. Pay special attention to owner, admin, and other access-control variables being shadowed
False Positives
- Solidity >=0.6.0: state variable shadowing is a compiler error, so it can't happen
- Local variable shadowing where the intent is clear and the state variable is not needed in that scope (still bad practice but not exploitable)
- The shadowing is in a test file or non-production code
Remediation
- Upgrade to Solidity >=0.6.0 where state variable shadowing is a compiler error
- For local variable shadowing, use distinct names (e.g., prefix with
_for parameters) - Rename conflicting variables to be unique across the inheritance chain
contract Child is Base {
// Don't redeclare — use Base.owner directly
function setOwner(address _newOwner) external {
owner = _newOwner; // Modifies Base.owner
}
}Signature Malleability
Preconditions
- Contract uses ECDSA signatures for authorization or deduplication
- Signatures are tracked by their raw bytes (e.g.,
mapping(bytes => bool)) to prevent replay - No enforcement that the
svalue is in the lower half of the curve order
Vulnerable Pattern
mapping(bytes => bool) public usedSignatures;
function claimReward(bytes memory signature, uint256 amount) external {
// Deduplication by raw signature bytes
require(!usedSignatures[signature], "already used");
bytes32 hash = keccak256(abi.encodePacked(msg.sender, amount));
address signer = ecrecover(hash, v, r, s);
require(signer == trustedSigner);
usedSignatures[signature] = true; // Attacker submits (r, n-s, flipped_v)
// to bypass this check with a valid but different signature
_payout(msg.sender, amount);
}Detection Heuristics
1. Search for mapping(bytes => bool) or any mapping keyed by raw signature bytes 2. If signatures are used as mapping keys for deduplication, flag it — an attacker can compute the complementary (r, n-s) signature 3. Check if ecrecover is called directly without an s-value range check 4. Check if OpenZeppelin's ECDSA library is used (it enforces lower-s normalization) 5. If neither a library nor a manual s < secp256k1n/2 check is present, flag it
False Positives
- Signatures are deduplicated by message hash or nonce, not by raw signature bytes
- OpenZeppelin's
ECDSA.recoveris used, which rejects high-s signatures - Manual check enforces
s <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
Remediation
- Track used signatures by message hash or nonce, not by raw signature bytes
- Use OpenZeppelin's
ECDSA.recoverwhich enforcessin the lower half of the curve order - If using raw
ecrecover, add a manuals-value check
// Use nonce-based deduplication instead of signature bytes
mapping(address => uint256) public nonces;
function claimReward(uint8 v, bytes32 r, bytes32 s, uint256 amount) external {
uint256 nonce = nonces[msg.sender]++;
bytes32 hash = keccak256(abi.encodePacked(msg.sender, amount, nonce));
address signer = ECDSA.recover(hash, v, r, s); // Rejects malleable sigs
require(signer == trustedSigner);
_payout(msg.sender, amount);
}Timestamp Dependence
Preconditions
- Contract uses
block.timestamp(or the deprecatednowalias) for security-sensitive logic - The outcome of that logic can be influenced by a timestamp shift within the manipulation window (~15s on PoW chains, slot-fixed on PoS Ethereum but variable on L2s/sidechains)
- OR:
block.numberis used as a proxy for elapsed time
Vulnerable Pattern
// Timestamp as sole randomness source
function roll() external {
// Validator can manipulate block.timestamp to bias outcome
uint256 result = uint256(keccak256(abi.encodePacked(block.timestamp))) % 6;
if (result == 0) {
_payWinner(msg.sender);
}
}
// Tight time window vulnerable to manipulation
function claimBonus() external {
// 15-second window — validator can push timestamp to include/exclude
require(block.timestamp >= deadline && block.timestamp <= deadline + 15);
_sendBonus(msg.sender);
}Detection Heuristics
1. Search for block.timestamp and now (deprecated alias) usage 2. If used for randomness (e.g., fed into keccak256 for a random value), flag immediately — this is always exploitable 3. If used in conditional logic, check the time window: can a ~15-second manipulation affect the outcome? 4. Search for block.number used as a time proxy (e.g., block.number * 12 for seconds) — flag as fragile since block times change 5. For L2/sidechain deployments, check chain-specific timestamp guarantees — some have weaker constraints than mainnet PoS
False Positives
block.timestampused only for logging or non-critical display purposes- Time windows are large enough (hours/days) that a 15-second manipulation is irrelevant
- On PoS Ethereum mainnet, timestamps are fixed per 12-second slots — validator manipulation is constrained to slot boundaries, not arbitrary values
block.timestampused with a commit-reveal scheme where the timestamp alone doesn't determine the outcome
Remediation
- Never use
block.timestampfor randomness — use Chainlink VRF or another verifiable randomness oracle - For time-dependent logic, ensure the acceptable window is significantly larger than the manipulation range
- Avoid
block.numberas a time proxy; useblock.timestampwith appropriate tolerance - On L2s/sidechains, verify chain-specific timestamp constraints before relying on
block.timestamp
// Safe: large time window where 15s manipulation is irrelevant
require(block.timestamp >= vestingEnd, "still vesting");
// vestingEnd is months/years away — manipulation doesn't matterTransaction-Ordering Dependence (Frontrunning)
Preconditions
- Transaction inputs are visible in the public mempool before block inclusion
- The outcome of the function depends on transaction ordering
- An attacker can profit by observing and front-running (or sandwiching) the victim's transaction
Vulnerable Pattern
// DEX swap without slippage protection
function swap(address tokenIn, address tokenOut, uint256 amountIn) external {
// No minimum output amount — attacker sandwiches:
// 1. Front-run: buy tokenOut (price goes up)
// 2. Victim's swap executes at worse price
// 3. Back-run: sell tokenOut (profit from price impact)
uint256 amountOut = getAmountOut(amountIn);
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
IERC20(tokenOut).transfer(msg.sender, amountOut);
}
// On-chain secret submission
function submitAnswer(bytes32 answer) external {
// Answer visible in mempool — anyone can copy it
require(keccak256(abi.encodePacked(answer)) == targetHash);
_reward(msg.sender);
}
// ERC20 approval race condition
// User approves 100, wants to change to 50
// Attacker sees approve(50) in mempool, spends 100, then spends 50 = 150 totalDetection Heuristics
1. Search for DEX/swap functions: check for slippage protection (minAmountOut parameter) and deadline parameters — flag if absent 2. Search for on-chain submissions of secrets, answers, or bids — these are observable in the mempool 3. Search for ERC20 approve patterns: check if the contract sets allowance to zero before setting a new value 4. Look for auction/bidding logic where observing others' bids provides an advantage 5. Identify any function where the order of execution matters and inputs are publicly visible before inclusion
False Positives
- Transaction is submitted via a private mempool (Flashbots Protect, MEV Blocker)
- Commit-reveal scheme is used: commitment hash submitted first, reveal in a later block
- Slippage protection with
minAmountOutanddeadlineparameters are present - The function's outcome is order-independent (e.g., simple deposit into a vault at a fixed rate)
Remediation
- For DEX operations: require
minAmountOutanddeadlineparameters for slippage protection - For secret submissions: use commit-reveal schemes (hash commitment first, reveal later)
- For ERC20 approvals: use
increaseAllowance/decreaseAllowanceor set to zero first - Consider Flashbots or private transaction relays for MEV-sensitive operations
function swap(
address tokenIn, address tokenOut, uint256 amountIn,
uint256 minAmountOut, // Slippage protection
uint256 deadline // Time protection
) external {
require(block.timestamp <= deadline, "expired");
uint256 amountOut = getAmountOut(amountIn);
require(amountOut >= minAmountOut, "slippage");
// ... execute swap
}Unbounded Return Data
Preconditions
- Contract makes a low-level
.call()to an untrusted or user-specified address - Solidity's automatic return data copying is used (default behavior up to at least v0.8.26)
- No assembly-level restriction on
returndatacopysize - The function is on a critical path where revert would lock funds (withdrawals, undelegation)
Vulnerable Pattern
function unstake(address callback) external {
uint256 amount = stakes[msg.sender];
stakes[msg.sender] = 0;
// Solidity automatically copies ALL return data into memory
// Attacker's callback contract returns megabytes of data
// Memory expansion cost grows quadratically — causes out-of-gas
(bool success,) = callback.call(
abi.encodeWithSignature("onUnstake(uint256)", amount)
);
// Even with limited gas stipend, the return data copy
// happens in the CALLER's gas context
require(success, "callback failed");
}Detection Heuristics
1. Search for .call(, .delegatecall(, .staticcall( to addresses that could be attacker-controlled 2. Check if the return data is handled by Solidity's default (captured as bytes memory or discarded but still copied) 3. If the call target is untrusted and no assembly-level return data size limit is used, flag it 4. Check if the function is on a critical path — can a revert here lock user funds? 5. Look for callback patterns (delegation hooks, unstaking callbacks, flash loan callbacks) where the callee is attacker-controlled
False Positives
- The call target is a trusted, known contract (not user-controlled)
- Assembly is used to limit
returndatacopyto a bounded size (e.g., max 32 bytes) ExcessivelySafeCallor similar library is used for bounded return data- The function doesn't revert on call failure (uses try/catch or ignores success)
Remediation
- Use assembly to limit return data size instead of Solidity's automatic copy
- Use Nomad's
ExcessivelySafeCalllibrary for calls to untrusted addresses - Bound
returndatacopyto only the bytes you need (typically 0 or 32)
// Assembly-bounded return data copy
function safeCall(address target, bytes memory data) internal returns (bool success) {
assembly {
success := call(gas(), target, 0, add(data, 0x20), mload(data), 0, 0)
// Only copy up to 32 bytes of return data
let rdsize := returndatasize()
if gt(rdsize, 0) {
if gt(rdsize, 32) { rdsize := 32 }
returndatacopy(0, 0, rdsize)
}
}
}Unchecked Return Values
Preconditions
- Contract uses low-level calls:
.call(),.send(), or.delegatecall() - The boolean return value indicating success/failure is not checked
- State changes occur after the unchecked call, assuming it succeeded
Vulnerable Pattern
function withdraw(uint256 amount) external {
// .send() returns false on failure but does NOT revert
msg.sender.send(amount); // Return value ignored
balances[msg.sender] -= amount; // State updated even if send failed
}
function payout(address to, uint256 amount) external {
// .call() return value captured but never checked
(bool success,) = to.call{value: amount}("");
// success could be false, but execution continues
totalPaid += amount;
}Detection Heuristics
1. Search for all .call(, .send(, .delegatecall( invocations 2. For each, check whether the returned boolean is captured AND checked (e.g., require(success), if (!success) revert) 3. If the return value is captured but never referenced again, flag it 4. If the return value is not captured at all (e.g., bare addr.send(amount);), flag it 5. Check for state changes after unchecked calls — these create inconsistent state on silent failure
False Positives
- Return value is checked with
require(success)or equivalent - The call is intentionally fire-and-forget (documented, no state depends on success) — rare but valid
- Using Solidity's high-level function calls (e.g.,
IERC20(token).transfer(...)) which auto-revert on failure
Remediation
- Always check the return value:
require(success, "call failed") - For ETH transfers to untrusted recipients, consider a pull-payment pattern to avoid DoS if the recipient deliberately reverts
- Prefer high-level Solidity calls when interacting with known interfaces
function withdraw(uint256 amount) external {
balances[msg.sender] -= amount;
(bool success,) = msg.sender.call{value: amount}("");
require(success, "ETH transfer failed");
}Unencrypted Private Data On-Chain
Preconditions
- Sensitive data (passwords, secrets, private keys, game answers) is stored in contract storage
- The developer relies on the
privatevisibility modifier for confidentiality - OR: sensitive data is passed as transaction calldata (publicly visible)
Vulnerable Pattern
contract SecretGame {
// `private` only prevents OTHER CONTRACTS from reading
// Anyone can read this via eth_getStorageAt(address, slot)
bytes32 private secretAnswer;
string private password;
constructor(bytes32 _answer, string memory _pwd) {
secretAnswer = _answer; // Visible in deployment tx calldata
password = _pwd; // Readable from storage slot
}
function guess(bytes32 _guess) external {
// Attacker reads secretAnswer from storage first
require(_guess == secretAnswer, "wrong");
_reward(msg.sender);
}
}Detection Heuristics
1. Search for state variables storing passwords, secrets, keys, answers, or seeds — regardless of visibility modifier 2. Check if any private variable is relied upon for confidentiality (not just access control) 3. Look for game/lottery logic where hidden information is stored on-chain before a reveal phase 4. Check constructor parameters and transaction calldata for sensitive values — these are publicly visible on block explorers 5. Search for comments like "secret", "hidden", "private key", "password" near storage declarations
False Positives
- Data is encrypted or hashed before storage (e.g., commitment hash in a commit-reveal scheme)
- The
privatemodifier is used correctly for access control between contracts, not for data confidentiality - The "sensitive" data is actually public information (e.g., a contract address)
Remediation
- Never store plaintext secrets on-chain — all storage is publicly readable
- Use commit-reveal schemes for hidden inputs: store
keccak256(secret || salt)first, reveal later - For truly private data, keep it off-chain and only store hashes/commitments on-chain
- Consider zero-knowledge proofs for verifiable computation on private data
// Commit-reveal scheme
mapping(address => bytes32) public commitments;
function commit(bytes32 hash) external {
// User submits keccak256(answer, salt) — answer stays private
commitments[msg.sender] = hash;
}
function reveal(bytes32 answer, bytes32 salt) external {
require(commitments[msg.sender] == keccak256(abi.encodePacked(answer, salt)));
_processAnswer(msg.sender, answer);
}Unexpected ecrecover Null Address
Preconditions
- Contract uses
ecrecoverdirectly (not via OpenZeppelin's ECDSA library) - The recovered address is not checked against
address(0) - The expected signer variable could be uninitialized (defaults to
address(0))
Vulnerable Pattern
contract Vault {
address public signer; // Uninitialized — defaults to address(0)
function withdrawWithSig(uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
bytes32 hash = keccak256(abi.encodePacked(msg.sender, amount));
// ecrecover returns address(0) for invalid signatures
// (e.g., v != 27 && v != 28)
address recovered = ecrecover(hash, v, r, s);
// If signer is uninitialized (address(0)) and recovered is address(0),
// this check passes — anyone can withdraw
require(recovered == signer, "invalid signature");
_withdraw(msg.sender, amount);
}
}Detection Heuristics
1. Search for ecrecover( calls in the codebase 2. Check if the returned address is validated against address(0) — flag if not 3. Check the variable that the recovered address is compared against — can it ever be address(0)? (uninitialized, never set, cleared by admin) 4. Check if OpenZeppelin's ECDSA.recover is used instead — it reverts on null recovery automatically 5. In upgradeable contracts, check if the signer is set during initialize() — if initialize is never called, signer remains address(0)
False Positives
require(recovered != address(0))check is present afterecrecover- OpenZeppelin's
ECDSA.recoveris used (handles null address internally) - The expected signer is set in the constructor or initializer and can never be
address(0)(validated on set)
Remediation
- Always check
require(recovered != address(0), "invalid signature")afterecrecover - Use OpenZeppelin's
ECDSA.recoverwhich reverts on invalid signatures and null recovery - Validate that signer variables cannot be
address(0)at any point
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
function withdrawWithSig(uint256 amount, bytes memory sig) external {
bytes32 hash = keccak256(abi.encodePacked(msg.sender, amount));
bytes32 ethHash = ECDSA.toEthSignedMessageHash(hash);
address recovered = ECDSA.recover(ethHash, sig); // Reverts if address(0)
require(recovered == signer, "invalid signature");
_withdraw(msg.sender, amount);
}Uninitialized Storage Pointer
Preconditions
- Solidity version <0.5.0
- Local variables of complex types (structs, arrays) are declared without an explicit
storageormemorydata location - The variable defaults to
storage, pointing to slot 0
Vulnerable Pattern
// Solidity <0.5.0 only
pragma solidity ^0.4.24;
contract Registry {
address public owner; // Stored in slot 0
uint256 public totalUsers; // Stored in slot 1
struct User {
address addr;
uint256 balance;
}
User[] public users;
function addUser(address _addr, uint256 _balance) external {
// No data location specified — defaults to storage
// Points to slot 0 (owner) and slot 1 (totalUsers)
User u; // u.addr aliases slot 0 (owner)
u.addr = _addr; // Overwrites owner!
u.balance = _balance; // Overwrites totalUsers!
users.push(u);
}
}Detection Heuristics
1. Check the Solidity version: if >=0.5.0, this vulnerability is impossible (compiler requires explicit data location) 2. For <0.5.0: search for local struct or array variable declarations without storage or memory keyword 3. If a local variable of a complex type has no data location, it defaults to storage at slot 0 — writes to it overwrite the first state variables 4. Check which state variables occupy slots 0, 1, 2, etc. — these are the ones at risk of overwrite 5. Look for struct field assignments on local variables that could alias storage
False Positives
- Solidity >=0.5.0 (compiler enforces explicit data location — this can't happen)
- The variable is explicitly declared as
memory(e.g.,User memory u) - The variable is explicitly declared as
storageand intentionally points to a known storage location
Remediation
- Upgrade to Solidity >=0.5.0 where explicit data locations are compiler-enforced
- For legacy contracts, add explicit
memoryorstorageto all local complex-type declarations
function addUser(address _addr, uint256 _balance) external {
User memory u; // Explicit memory — no storage aliasing
u.addr = _addr;
u.balance = _balance;
users.push(u);
}Unsafe Low-Level Call
Preconditions
- Contract uses
.call(),.delegatecall(),.staticcall(), or.send()for external interactions - The return value is not checked, OR
- The target address may not have deployed code (user-provided, destroyed, or never deployed)
Vulnerable Pattern
function payout(address to, uint256 amount) external {
// Unchecked return value — silent failure
to.call{value: amount}("");
totalPaid += amount; // Updated even if call failed
}
function interact(address target, bytes calldata data) external {
// Call to non-existent contract "succeeds" silently
// EVM treats call to codeless address as successful
(bool success,) = target.call(data);
require(success); // Passes even if target has no code!
_markComplete();
}Detection Heuristics
1. Search for .call(, .send(, .delegatecall(, .staticcall( in the codebase 2. For each, check if the returned bool is captured AND checked (e.g., require(success)) 3. If the return value is not captured or not checked, flag it — execution continues after failure 4. For calls to user-supplied addresses, check if target.code.length > 0 is verified before the call — the EVM silently succeeds on calls to addresses with no code 5. Note: address.code.length check can be bypassed during constructor execution (code size is 0) 6. Check if state changes after the call assume it succeeded
False Positives
- Return value is properly checked with
require(success) - High-level Solidity calls are used (e.g.,
IERC20(token).transfer(...)) which include automaticextcodesizechecks and revert on failure - The call is intentionally fire-and-forget with no state depending on success (rare, must be documented)
Remediation
- Always check return values:
require(success, "call failed") - Verify target has code before low-level calls:
require(target.code.length > 0) - Prefer high-level Solidity calls for known interfaces — they include automatic code existence checks
- For critical integrations, combine both checks
function payout(address to, uint256 amount) external {
require(to.code.length > 0 || to == tx.origin, "no code at target");
(bool success,) = to.call{value: amount}("");
require(success, "transfer failed");
totalPaid += amount;
}Unsecure Signatures
Preconditions
- Contract uses ECDSA signatures for authorization, authentication, or message verification
- One or more of the following sub-vulnerabilities are present:
- Signature malleability (tracking by raw bytes)
- Missing replay protection (no nonce, chainId, or contract address)
- Unchecked ecrecover null address return
- Hash collisions from
abi.encodePackedwith multiple dynamic types - No EIP-712 structured data signing
Vulnerable Pattern
// Combines multiple signature anti-patterns
function execute(bytes memory sig, address to, uint256 amount) external {
// 1. No nonce — replay attack
// 2. No address(this) — cross-contract replay
// 3. No block.chainid — cross-chain replay
bytes32 hash = keccak256(abi.encodePacked(to, amount));
// 4. Raw ecrecover — no null address check
address recovered = ecrecover(hash, v, r, s);
// 5. No s-value malleability check
require(recovered == signer);
// 6. Signature tracked by bytes — malleable bypass
require(!used[sig]);
used[sig] = true;
_transfer(to, amount);
}Detection Heuristics
1. Search for ecrecover or ECDSA.recover — this indicates signature usage 2. Check each sub-vulnerability in order:
- Malleability: is deduplication done by raw signature bytes? Flag if yes
- Replay: does the signed hash include nonce +
address(this)+block.chainid? Flag any missing - Null address: is the recovered address checked against
address(0)? Flag if not - Hash collision: is
abi.encodePackedused with multiple dynamic types? Flag if yes - EIP-712: is structured typed data signing used? Flag if not (lower severity)
3. Check if OpenZeppelin's ECDSA library is used — it handles malleability and null address automatically 4. Check for front-running risk: can a signed message be observed in the mempool and submitted by someone else?
False Positives
- OpenZeppelin's ECDSA library is used with EIP-712 domain separator, nonce tracking, and proper hash construction — all sub-vulnerabilities are addressed
- EIP-2612 permit pattern is used correctly (includes nonce, deadline, domain separator)
- The contract is a simple forwarder where signature security is handled by a downstream contract
Remediation
- Use OpenZeppelin's ECDSA library for signature recovery (handles malleability + null address)
- Implement EIP-712 structured data signing with domain separator (covers chainId + contract address)
- Track nonces per signer to prevent replay
- Use
abi.encodeinstead ofabi.encodePackedfor hash construction
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract SecureSig is EIP712("SecureSig", "1") {
mapping(address => uint256) public nonces;
bytes32 constant EXECUTE_TYPEHASH =
keccak256("Execute(address to,uint256 amount,uint256 nonce)");
function execute(address to, uint256 amount, bytes memory sig) external {
uint256 nonce = nonces[msg.sender]++;
bytes32 structHash = keccak256(abi.encode(EXECUTE_TYPEHASH, to, amount, nonce));
bytes32 hash = _hashTypedDataV4(structHash);
address recovered = ECDSA.recover(hash, sig);
require(recovered == signer, "invalid sig");
_transfer(to, amount);
}
}Unsupported Opcodes on EVM-Compatible Chains
Preconditions
- Contract is intended for deployment on an EVM-compatible chain other than Ethereum mainnet (zkSync Era, Arbitrum, Optimism, Polygon, BNB Chain, etc.)
- The contract uses opcodes or patterns that are not supported or behave differently on the target chain
Vulnerable Pattern
// PUSH0 opcode: Solidity >=0.8.20 emits PUSH0
// Not supported on all chains — contract fails to deploy
pragma solidity 0.8.20;
contract Token {
// Compiled bytecode contains PUSH0 — reverts on chains without support
}
// .transfer() on zkSync Era — 2300 gas stipend insufficient
function withdraw() external {
// transfer() forwards only 2300 gas
// On zkSync Era, basic operations cost more — this always reverts
// Gemholic lost 921 ETH to this exact issue
payable(msg.sender).transfer(amount);
}
// Dynamic create on zkSync Era
function deploy(bytes memory bytecode) external {
assembly {
// zkSync requires bytecode known at compile time
// Runtime create from arbitrary bytecode fails
let addr := create(0, add(bytecode, 0x20), mload(bytecode))
}
}Detection Heuristics
1. Check the Solidity version: if >=0.8.20, verify the target chain supports the PUSH0 opcode 2. Search for .transfer() and .send() — these use a 2300 gas stipend that may be insufficient on chains with different gas cost structures (especially zkSync Era) 3. Search for assembly create / create2 with runtime-supplied bytecode — this fails on zkSync Era 4. Search for selfdestruct — deprecated and non-functional on some chains post-Dencun 5. If the project targets multiple chains, cross-reference all opcodes used against each target chain's compatibility (use evmdiff.com)
False Positives
- Contract is only deployed on Ethereum mainnet
- Solidity version is <0.8.20 (no PUSH0 emitted)
.call{value: amount}("")is used instead of.transfer()(forwards all available gas)- The project explicitly documents which chains are supported and tests against them
Remediation
- Use
.call{value: amount}("")instead of.transfer()or.send()for ETH transfers - For multi-chain deployments, compile with Solidity <0.8.20 or use
--evm-version paristo avoid PUSH0 - On zkSync Era, use compile-time known bytecode for contract creation
- Test deployments on each target chain before mainnet launch
- Use evmdiff.com to verify opcode compatibility per chain
// Safe ETH transfer for all EVM chains
function withdraw(uint256 amount) external {
(bool success,) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
// Avoid PUSH0: compile with Paris EVM target
// solc --evm-version parisPresence of Unused Variables
Preconditions
- Contract declares state variables, local variables, function parameters, or imports that are never referenced
- OR: return values from function calls are silently discarded
Vulnerable Pattern
contract Vault {
uint256 public totalDeposits;
uint256 public unusedCounter; // Declared but never read or written
function deposit(uint256 amount, bytes memory data) external {
// `data` parameter never used — possible missing validation
totalDeposits += amount;
}
function process() external {
// Return value from transfer silently discarded
// This may indicate missing success check
IERC20(token).transfer(recipient, amount);
uint256 result = _calculate();
// `result` computed but never used — missing logic?
}
}Detection Heuristics
1. Search for state variables that are never referenced in any function (only declared) 2. Search for function parameters that are never used in the function body 3. Search for local variables that are assigned but never read 4. Check for return values from external calls that are not captured or checked 5. For each unused variable, determine: is it dead code (safe to remove) or does it indicate missing logic (a bug)? 6. Check compiler warnings for unused variable alerts
False Positives
- The variable is part of an interface implementation and must be declared for signature compatibility even if unused
- The variable is used in a commented-out or conditional compilation path
- The variable is reserved for future use and documented as such
- Function parameters prefixed with
_to explicitly mark as unused (e.g.,function hook(uint256 /* _amount */))
Remediation
- For dead code: remove the unused variable entirely
- For missing logic: implement the intended use (e.g., check the return value, use the parameter for validation)
- For interface-required but unused parameters: use the unnamed parameter syntax
// Remove dead state variables
// uint256 public unusedCounter; — DELETE
// Unnamed parameters for interface compliance
function onERC721Received(
address, // operator — unused
address, // from — unused
uint256, // tokenId — unused
bytes memory // data — unused
) external pure returns (bytes4) {
return this.onERC721Received.selector;
}
// Check return values — use SafeERC20 for non-compliant tokens
bool success = IERC20(token).transfer(recipient, amount);
require(success);Use of Deprecated Functions
Preconditions
- Contract uses Solidity functions, keywords, or language features that have been deprecated or removed
- The deprecated feature may behave differently than expected or may not compile on newer Solidity versions
Vulnerable Pattern
pragma solidity ^0.4.24;
contract Legacy {
function destroy() external {
// suicide is deprecated — renamed to selfdestruct
// selfdestruct itself is now deprecated post-Dencun
suicide(msg.sender);
}
function getHash(bytes memory data) external view returns (bytes32) {
return sha3(data); // Deprecated — use keccak256
}
function getBlockHash(uint256 n) external view returns (bytes32) {
return block.blockhash(n); // Deprecated — use blockhash(n)
}
function getRemainingGas() external view returns (uint256) {
return msg.gas; // Deprecated — use gasleft()
}
}| Deprecated | Replacement |
|---|---|
suicide(address) | selfdestruct(address) (also deprecated) |
block.blockhash(uint) | blockhash(uint) |
sha3(...) | keccak256(...) |
callcode(...) | delegatecall(...) |
throw | revert() |
msg.gas | gasleft() |
constant (function modifier) | view |
var | Explicit type name |
Detection Heuristics
1. Search for each deprecated keyword: suicide, sha3, block.blockhash, callcode, throw, msg.gas, constant (as function modifier), var 2. Search for selfdestruct — while it's the replacement for suicide, it is itself deprecated post-Dencun and non-functional on some chains 3. Check compiler warnings for deprecation notices 4. Flag any usage and recommend the modern replacement
False Positives
- The deprecated function appears in comments or documentation, not in executable code
- The contract is intentionally targeting an old Solidity version where the deprecated feature is still standard
- Interface definitions that reference deprecated patterns for backward compatibility
Remediation
- Replace each deprecated function with its modern equivalent (see table above)
- For
selfdestruct: remove reliance entirely, as it is deprecated and non-functional on some chains post-Dencun - Upgrade the Solidity version to benefit from compiler enforcement of modern syntax
// Modern equivalents
pragma solidity 0.8.24;
contract Modern {
function getHash(bytes memory data) external pure returns (bytes32) {
return keccak256(data);
}
function getBlockHash(uint256 n) external view returns (bytes32) {
return blockhash(n);
}
function getRemainingGas() external view returns (uint256) {
return gasleft();
}
}Weak Sources of Randomness from Chain Attributes
Preconditions
- Contract generates "random" values using on-chain data:
block.timestamp,blockhash,block.difficulty/block.prevrandao,block.number, or combinations thereof - The random value determines outcomes with economic value (lotteries, games, minting, distributions)
Vulnerable Pattern
function drawLottery() external {
// All inputs are deterministic and publicly visible
// Another contract can compute the same value in the same tx
uint256 random = uint256(keccak256(abi.encodePacked(
block.timestamp,
block.prevrandao,
msg.sender
))) % 100;
if (random < 5) {
_payWinner(msg.sender);
}
}
// Attacker contract
contract Exploit {
function attack(Lottery target) external {
// Compute the same "random" value before calling
uint256 random = uint256(keccak256(abi.encodePacked(
block.timestamp,
block.prevrandao,
address(this)
))) % 100;
// Only call if we'll win
if (random < 5) {
target.drawLottery();
}
}
}Detection Heuristics
1. Search for block.timestamp, block.prevrandao, block.difficulty, blockhash, block.number used as inputs to keccak256 or arithmetic operations producing a "random" value 2. Check if the resulting value determines an outcome with economic impact (winner selection, token distribution, NFT rarity, game outcome) 3. If randomness is derived exclusively from on-chain data, flag it — a contract in the same transaction can compute the identical value 4. Check if blockhash is used for a future block (returns 0 for blocks not yet mined) or a block older than 256 blocks (also returns 0) 5. Check if validators/miners can influence the inputs to bias the outcome
False Positives
- Chainlink VRF or another verifiable randomness oracle is used
- On-chain data is combined with an off-chain commit-reveal scheme (the on-chain part alone doesn't determine the outcome)
- The randomness doesn't determine anything with economic value
block.prevrandaoon PoS Ethereum provides sufficient entropy for the specific use case (not for high-value outcomes)
Remediation
- Use Chainlink VRF (Verifiable Random Function) for provably fair randomness
- Implement a commit-reveal scheme: users commit hashed choices, reveal in a later block
- Never use
block.timestamp,blockhash, orblock.prevrandaoalone for randomness in high-value contexts
import {VRFConsumerBaseV2} from "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
contract FairLottery is VRFConsumerBaseV2 {
function requestRandom() external {
requestRandomWords(keyHash, subId, confirmations, gasLimit, 1);
}
function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
uint256 winner = randomWords[0] % participants.length;
_payWinner(participants[winner]);
}
}