Foundational methodologies and mental models for conducting effective, structured security audits of smart contract code.
Manual Smart Contract Review Techniques
Core Principles of Manual Review
Trust Boundaries and Assumptions
Trust boundaries define where external, untrusted data enters the system. A core principle is to explicitly document and then challenge all assumptions about these inputs and the actors involved.
- Map all entry points: external calls, user inputs, oracle data.
- Question assumptions about privilege (e.g., "only the owner can call this").
- This matters because unvalidated assumptions at trust boundaries are a primary source of critical vulnerabilities like access control flaws.
Business Logic Consistency
Business logic consistency ensures the contract's implementation perfectly aligns with its intended economic and operational rules. Reviewers must reconstruct the specification from the code itself.
- Trace value flows for tokens and ETH to ensure no unintended leakage.
- Verify state transitions adhere to the protocol's whitepaper or documentation.
- This is critical to prevent logic errors that can lead to financial loss, such as incorrect interest calculation in a lending protocol.
State Machine Invariants
Invariants are conditions that must always hold true for the contract's state, regardless of user interactions. Manual review involves identifying and testing these invariants under all possible execution paths.
- Example: total supply of a token must equal the sum of all balances.
- Check for reentrancy violations that can temporarily break invariants.
- Maintaining invariants is fundamental to the contract's correctness and security guarantees.
Gas and Execution Limits
Analyzing gas consumption and execution limits (block gas limit, loops) is essential for robustness. Code must operate efficiently and cannot exceed environmental constraints.
- Identify unbounded loops that could cause out-of-gas failures.
- Review storage operations, as SSTORE is extremely costly.
- This matters for usability and security, as gas griefing or block stuffing can be used in denial-of-service attacks.
Upgradeability and Proxy Patterns
Reviewing upgradeable contracts requires scrutinizing the storage layout, proxy initialization, and admin privilege controls to prevent storage collisions and unauthorized upgrades.
- Verify storage gaps are used correctly to preserve layout.
- Ensure the
initializermodifier prevents re-initialization. - This is vital because flaws in upgrade mechanisms can lead to permanent loss of funds or contract hijacking.
External Integration Risks
External integrations with other contracts (oracles, DEX routers, other protocols) introduce dependency risks. Manual review must assess the security of these external calls and their failure modes.
- Check for proper handling of
callreturn values and low-level calls. - Analyze oracle freshness and manipulation resistance.
- This matters as the contract's security can be compromised by a vulnerability in a trusted, integrated dependency.
The Manual Review Process
A systematic approach to manually auditing smart contract code for security vulnerabilities and logical flaws.
Establish the Review Scope and Context
Define the audit's boundaries and gather all necessary artifacts.
Detailed Instructions
Begin by defining the audit scope. This includes the specific contract files, their versions (e.g., commit hash a1b2c3d), and any inherited libraries or interfaces. Review the project's documentation, whitepaper, and specifications to understand the intended functionality and business logic. Gather the Application Binary Interface (ABI) and deployment addresses if available. Set up a local environment with the correct compiler version (e.g., Solidity 0.8.20) and testing framework. This preparatory step ensures you have the correct context to evaluate the code against its stated purpose and dependencies.
- Sub-step 1: Clone the repository and verify the commit hash matches the audit target.
- Sub-step 2: Examine the
package.jsonorhardhat.config.jsfor compiler settings and dependencies. - Sub-step 3: List all external contract interactions, noting addresses for mainnet and testnet.
solidity// Example of checking compiler pragma for scope pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
Tip: Create a checklist of core functions (e.g.,
deposit,withdraw,swap) to ensure comprehensive coverage.
Perform Architectural and Code Pattern Analysis
Analyze the contract's high-level design and identify common patterns or anti-patterns.
Detailed Instructions
Conduct a high-level read-through of the codebase without executing it. Map out the contract architecture, identifying the relationships between contracts, inheritance trees, and key state variables. Look for common design patterns like Checks-Effects-Interactions, access control (e.g., OpenZeppelin's Ownable), and pause mechanisms. Simultaneously, flag anti-patterns such as complex monolithic contracts, excessive gas costs in loops, or risky low-level calls. Pay special attention to privilege escalation vectors and centralization risks, like a single private key controlling all admin functions. This step identifies systemic risks before diving into line-by-line analysis.
- Sub-step 1: Diagram the flow of funds and data between user calls and contract functions.
- Sub-step 2: Identify all
owneroradminroles and their associated permissions. - Sub-step 3: Note any use of
delegatecall,selfdestruct, or assembly blocks (assembly { ... }).
solidity// Example of Checks-Effects-Interactions pattern function withdraw(uint256 amount) public { // Check require(balances[msg.sender] >= amount, "Insufficient balance"); // Effect balances[msg.sender] -= amount; // Interaction (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); }
Tip: Use tools like
slither-print inheritance-graphto generate visual diagrams of contract relationships.
Execute Line-by-Line Static Analysis
Manually trace through each function's logic to uncover vulnerabilities.
Detailed Instructions
This is the core of the manual review. Trace the execution path of each function, paying meticulous attention to state changes and external calls. Manually check for classic vulnerability categories: reentrancy, integer overflows/underflows, timestamp dependence, and access control violations. For each state variable write, verify the preceding checks and authorization. For each external call (e.g., token.transfer(...)), assess trust assumptions and potential for failure. Explicitly calculate possible values for arithmetic operations to catch overflows that might be missed by SafeMath if it's not used. This meticulous process often catches subtle logic errors automated tools miss.
- Sub-step 1: For every
require/assert, verify the condition is sufficient and cannot be bypassed. - Sub-step 2: Trace the flow of user-supplied inputs through the function to all state changes.
- Sub-step 3: Confirm that functions handling funds properly track balances before and after transfers.
solidity// Manual check for reentrancy vulnerability function vulnerableWithdraw() public { uint256 amount = balances[msg.sender]; // Dangerous: Interaction before Effects (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); balances[msg.sender] = 0; // State update after external call }
Tip: Use a notepad to track variable states at different points in complex functions, especially within loops.
Analyze Business Logic and Economic Incentives
Evaluate if the contract's logic correctly implements its intended economic model.
Detailed Instructions
Move beyond generic vulnerabilities to assess protocol-specific logic. This involves understanding the economic incentives and ensuring they align with the system's goals. Check reward calculations in staking contracts, exchange rates in AMMs, liquidation thresholds in lending protocols, and fee distributions. Look for edge cases in calculations that could be exploited for economic attacks like donation attacks, flash loan manipulations, or griefing. Verify that oracle price feeds are used correctly and have proper freshness checks. This step requires thinking like an attacker to find profitable, albeit non-obvious, ways to break the system's financial assumptions.
- Sub-step 1: Calculate maximum/minimum possible values for key metrics like interest rates or LP token shares.
- Sub-step 2: Simulate multi-transaction attack sequences, such as flash loan arbitrage.
- Sub-step 3: Verify that all fee calculations use the intended denominator and cannot be gamed to zero.
solidity// Example: Checking a vulnerable exchange rate calculation function getPrice() public view returns (uint256) { // Vulnerable if reserves can be manipulated (e.g., via a donation) return (reserveA * 1e18) / reserveB; }
Tip: Create simple spreadsheets to model economic outcomes under various market conditions and user actions.
Document Findings and Verify Fixes
Systematically record all issues and review any proposed remediations.
Detailed Instructions
Create a structured report for each finding. Each entry should include a title, severity (Critical, High, Medium, Low, Informational), location (file and line number), description of the vulnerability, proof of concept steps or code snippet demonstrating the issue, and a recommendation for fixing it. After the development team provides patches, perform a remediation review. This is not a full re-audit but a targeted verification that the fix correctly addresses the reported issue without introducing new vulnerabilities or breaking existing functionality. Confirm that the fix is deployed in the correct contract version.
- Sub-step 1: For each finding, write a simple test in Foundry or Hardhat that triggers the vulnerable condition.
- Sub-step 2: In the remediation review, perform a diff (
git diff) between the old and new code. - Sub-step 3: Re-run the specific proof-of-concept test against the patched code to confirm it fails.
solidity// Example documentation comment for a finding // Severity: High - Reentrancy in Vault.withdraw() // File: contracts/Vault.sol, Line 45 // Description: External call to user before updating balance... // Recommendation: Apply the Checks-Effects-Interactions pattern.
Tip: Use a consistent severity classification matrix (e.g., based on impact and likelihood) to ensure objective ratings.
Common Vulnerability Categories
Foundational Security Flaws
Understanding the most frequent and dangerous vulnerabilities is the first step in manual review. These categories represent patterns that have led to significant financial losses in DeFi.
Key Points
- Reentrancy: A function that makes an external call before updating its own state can be exploited. An attacker's contract recursively calls back into the vulnerable function, draining funds. The infamous DAO hack was due to this.
- Access Control: Missing or incorrect permission checks allow unauthorized users to perform sensitive actions, like withdrawing funds or upgrading a contract. Always verify
msg.senderand use modifiers likeonlyOwner. - Integer Overflow/Underflow: In older Solidity versions, arithmetic operations could wrap around, making a large balance become zero or a small one become huge. Modern compilers (0.8+) have built-in checks.
- Unchecked Return Values: Failing to check the success of low-level
call()orsend()operations can make the contract logic proceed as if a transfer succeeded when it failed.
Example
When interacting with a lending protocol like Aave, a reentrancy bug in a withdraw function could let an attacker repeatedly borrow assets without collateral, destabilizing the pool.
Manual Analysis Tools and Techniques
Comparison of static analysis tools for smart contract security review.
| Analysis Feature | Slither | Mythril | Manual Code Review |
|---|---|---|---|
Detection Method | Static Analysis (SSA) | Symbolic Execution & Taint Analysis | Human Pattern Recognition |
Vulnerability Coverage | ~70 common issue types | ~50 issue types, strong on arithmetic | Unlimited, context-dependent |
Speed for 1k LOC | < 10 seconds | 30 seconds to 5 minutes | 1-4 hours |
False Positive Rate | Moderate (requires triage) | High (requires expert validation) | Low (expert-driven) |
Gas Optimization Checks | Yes, 20+ detectors | Limited | Yes, deep contextual analysis |
Integration | CLI, CI/CD, Foundry/Forge plugin | CLI, Docker, GitHub Actions | IDE, Code Review Platforms |
Key Strength | Fast pattern matching, Solidity-specific | Finds complex logical flaws | Understands business logic & intent |
Primary Limitation | Misses novel/logic-based bugs | State explosion for large contracts | Time-intensive, skill-dependent |
Secure and Insecure Code Patterns
Identifying common vulnerability patterns and their secure counterparts is fundamental to manual review. This section contrasts exploitable code with robust implementations.
Reentrancy
Reentrancy occurs when an external call allows an attacker to re-enter the function before state updates complete. The classic pattern is a withdrawal function that calls an untrusted contract.
- Insecure: State update (
balances[msg.sender] = 0) occurs after the externalcall.value(). - Secure: Use Checks-Effects-Interactions: update all state before making external calls.
- Advanced: Employ reentrancy guards like OpenZeppelin's
ReentrancyGuardmodifier. This prevents fund drainage, as seen in the DAO hack.
Access Control
Access control flaws arise when sensitive functions lack proper authorization checks, allowing any user to execute privileged operations.
- Insecure: A function like
changeOwner(address newOwner)with noonlyOwnermodifier. - Secure: Use function modifiers (e.g.,
onlyOwner,onlyRole) to enforce permissions. - Implementation: Leverage established libraries like OpenZeppelin's
OwnableorAccessControl. Proper access control is critical for administrative functions and protocol parameter changes.
Integer Overflow/Underflow
Integer overflow/underflow happens when arithmetic operations exceed a variable's storage bounds, wrapping around to unexpected values.
- Insecure: Using vanilla arithmetic (
balance - amount) without bounds checking in Solidity <0.8. - Secure: Use Solidity 0.8+'s built-in overflow checks or SafeMath libraries for older versions.
- Example: An underflow in a balance check could make a user's balance appear huge, enabling unauthorized withdrawals. This pattern was prevalent before compiler-enforced checks.
Unchecked Call Return Values
This pattern involves making low-level calls (e.g., call, send, delegatecall) without verifying their success, leading to silent failures.
- Insecure: Using
addr.send(amount)oraddr.call.value(amount)()and not checking the returned boolean. - Secure: Always check the return value. Use
addr.call{value: amount}('')and require it succeeds, or usetransfer()in limited contexts. - Impact: A failed transfer could be treated as successful, breaking contract logic and causing loss of funds.
Front-Running
Front-running exploits the transparent mempool, where an attacker observes a pending transaction and submits their own with a higher gas fee to execute first.
- Insecure: A DEX swap that reveals the exact trade size and slippage tolerance in the transaction.
- Mitigation: Use commit-reveal schemes, slippage protection, or batch auctions.
- Sub-pattern: Sandwich attacks specifically target AMM trades, placing orders before and after the victim's transaction. This is a systemic issue in public blockchain transaction ordering.
Improper Event Emission
Event emission is critical for off-chain tracking. Insecure patterns emit events that do not accurately reflect on-chain state changes or lack crucial data.
- Insecure: Emitting a
Transferevent without actually transferring tokens (a "fake log"). - Secure: Emit events after state changes, including all relevant parameters like
from,to, andvalue. - Importance: Wallets and indexers rely on events; incorrect logs break UI displays and off-chain logic, eroding trust.
Documenting Findings and Reporting
Process for structuring and communicating audit findings.
Structure Findings with a Consistent Template
Create a standardized format for each vulnerability.
Detailed Instructions
Use a template to ensure clarity and reproducibility for every finding. Start with a clear title (e.g., "Incorrect Access Control in withdrawFunds()") and a severity classification (Critical/High/Medium/Low/Informational). Include a vulnerability location specifying the contract name, file path, and line numbers. Provide a concise description of the flaw, explaining the root cause and potential impact on funds or logic. This structured approach allows developers and other auditors to quickly understand the issue's scope and priority.
- Sub-step 1: Define a severity matrix (e.g., Critical = direct loss of funds, High = conditional loss)
- Sub-step 2: For each finding, note the exact function signature and line numbers (e.g.,
Vault.sol:L42-L58) - Sub-step 3: Write a one-sentence summary of the impact (e.g., "Allows any user to drain the contract's ETH balance.")
solidity// Example location reference // File: contracts/Vault.sol function withdrawFunds(address beneficiary, uint256 amount) public { // L45: Missing access control modifier balances[msg.sender] -= amount; (bool success, ) = beneficiary.call{value: amount}(""); require(success, "Transfer failed"); }
Tip: Use a linter or script to automatically extract and format code snippets with line numbers for accuracy.
Provide Detailed Proof of Concept (PoC)
Demonstrate the exploit with executable test cases or transaction traces.
Detailed Instructions
A Proof of Concept is essential to prove exploitability. Write a step-by-step scenario, preferably as a Foundry or Hardhat test, that triggers the vulnerability. Include specific transaction parameters, caller addresses, and state changes. For state-based issues, show the pre- and post-condition balances or storage values. For logic errors, trace the execution path. This concrete evidence removes ambiguity and allows the development team to verify the issue immediately. A good PoC often includes the expected versus actual outcome.
- Sub-step 1: Set up the initial contract state (e.g., fund the contract with 10 ETH)
- Sub-step 2: Execute the malicious transaction from an unauthorized address (e.g.,
attacker.call{value: 0}(abi.encodeWithSignature("withdrawFunds(address,uint256)", attacker, 10 ether))) - Sub-step 3: Assert the final state (e.g.,
assertEq(attacker.balance, 10 ether)andassertEq(address(vault).balance, 0))
solidity// Foundry test example for the access control flaw function test_unauthorizedWithdraw() public { // Setup address attacker = makeAddr("attacker"); vault.deposit{value: 10 ether}(); uint256 initialAttackerBalance = attacker.balance; // Attack vm.prank(attacker); // Switch msg.sender to attacker vault.withdrawFunds(attacker, 10 ether); // Verification assertEq(attacker.balance, initialAttackerBalance + 10 ether); assertEq(address(vault).balance, 0); }
Tip: Include the exact command to run the PoC test (e.g.,
forge test --match-test test_unauthorizedWithdraw -vvv).
Assess Impact and Recommend Fixes
Evaluate the risk and propose concrete remediation strategies.
Detailed Instructions
Go beyond identifying the bug; analyze its real-world impact. Consider the attack's cost, likelihood, and effect on protocol users and treasury. For a Centralization Risk, evaluate the trust assumptions and potential for abuse. Then, provide actionable recommendations. The best fixes are specific and include code patches. Suggest adding a modifier like onlyOwner, using a checks-effects-interactions pattern, or implementing a time-lock. Also, consider alternative mitigations if a direct fix is complex. This section should enable developers to understand the risk level and implement a solution efficiently.
- Sub-step 1: Quantify the maximum potential loss (e.g., "Entire contract balance of X ETH")
- Sub-step 2: Identify the vulnerable pattern (e.g., missing access control, reentrancy, integer overflow)
- Sub-step 3: Propose a code change with a diff or a new function implementation
solidity// Recommended fix for the withdrawal function function withdrawFunds(address beneficiary, uint256 amount) public { // Add an access control modifier require(msg.sender == owner, "Unauthorized"); // Follow checks-effects-interactions require(balances[msg.sender] >= amount, "Insufficient balance"); balances[msg.sender] -= amount; (bool success, ) = beneficiary.call{value: amount}(""); require(success, "Transfer failed"); }
Tip: For complex governance issues, recommend a phased mitigation, such as deploying a new contract with a migration plan.
Compile a Formal Report with Executive Summary
Aggregate findings into a final report for stakeholders.
Detailed Instructions
Consolidate all findings into a single, well-organized document. Start with an Executive Summary that provides a high-level overview: total issues found, breakdown by severity, and overall protocol security posture. Include a scope section listing the audited commit hash (e.g., a1b2c3d), contract addresses, and review dates. Present findings in a prioritized table with ID, title, severity, and status. The main body should contain each detailed finding from previous steps. Conclude with a disclaimer clarifying the report's limitations (e.g., scope, time-boxed nature). This report is the primary deliverable for the client and should be professionally formatted.
- Sub-step 1: Create a summary table:
| ID | Severity | Title | Status | - Sub-step 2: List all audited components (e.g.,
Vault.sol,0x742d35Cc6634C0532925a3b844Bc9e...) - Sub-step 3: Append appendices for tool configurations, test commands, and reference materials
code## Executive Summary * **Audit Scope:** Commit `a1b2c3d`, contracts/Vault.sol * **Critical Findings:** 1 * **High Findings:** 2 * **Medium Findings:** 3 * **Overall Assessment:** Protocol contains several high-risk vulnerabilities requiring immediate attention. ## Findings List | ID | Severity | Title | Status | |-----|----------|--------------------------------------------|---------| | MFR-01 | Critical | Unauthorized Withdrawal in Vault.withdrawFunds | Open | | MFR-02 | High | Missing Slippage Protection in Swap Router | Open |
Tip: Use a version-controlled template (e.g., in LaTeX or Markdown) to ensure consistency across all audit reports.
Communicate Findings and Track Remediation
Engage with the development team and monitor fix implementation.
Detailed Instructions
Communication is critical after report delivery. Schedule a kickoff meeting to walk through high-severity findings. Use a tracking system (like a spreadsheet or issue tracker) to monitor the status of each finding (Open, Acknowledged, Fixed, Rejected). For each fix submitted by developers, perform a remediation review. Verify that the code changes adequately address the vulnerability without introducing new issues. Re-run your PoC tests against the patched code to confirm they now fail. This iterative process ensures all identified risks are properly mitigated before the code is deployed to mainnet. Maintain clear records of all communications and review cycles.
- Sub-step 1: Share the report and request initial acknowledgment from the client
- Sub-step 2: For each "Fixed" status, review the pull request or code diff in the repository
- Sub-step 3: Re-execute the relevant test suite and PoC to validate the fix
bash# Example command to re-test a specific fix forge test --match-contract VaultTest --match-test test_unauthorizedWithdraw # Expected output: test should fail/revert after the fix is applied
Tip: Establish a clear timeline for re-audit phases and define what constitutes "fix verification" to avoid scope creep.
Manual Review FAQs
Further Resources and References
Ready to Start Building?
Let's bring your Web3 vision to life.
From concept to deployment, ChainScore helps you architect, build, and scale secure blockchain solutions.