Comprehensive smart contract security auditing skill. Covers static analysis tooling (Slither, Mythril, Aderyn), fuzz testing (Foundry, Echidna), vulnerability detection with Solidity code examples, proxy/upgrade safety, DeFi-specific audit patterns, gas optimization, and structured audit report generation.
pip3 install slither-analyzer
slither . --filter-paths "node_modules|lib"
slither . --print human-summary
slither . --detect reentrancy-eth,reentrancy-no-eth,arbitrary-send-erc20
slither . --print contract-summary # function visibility overview
pip3 install mythril
myth analyze contracts/Vault.sol --solv 0.8.20 --execution-timeout 300
myth analyze contracts/Vault.sol --max-depth 30 -o jsonv2
cargo install aderyn
aderyn . # outputs report.md by default
aderyn . --output aderyn-report.json
forge test --fuzz-runs 10000
forge test --fuzz-runs 50000 --match-test testFuzz
forge test --fuzz-seed 42 --fuzz-runs 10000 # reproducible
Foundry fuzz test example:
function testFuzz_withdraw(uint256 amount) public {
amount = bound(amount, 1, address(vault).balance);
vault.deposit{value: amount}();
uint256 pre = address(this).balance;
vault.withdraw(amount);
assertEq(address(this).balance, pre + amount);
}
brew install echidna # or download binary
echidna . --contract VaultEchidna --test-mode assertion --test-limit 50000
Echidna invariant example:
contract VaultEchidna is Vault {
function echidna_total_balance_matches() public view returns (bool) {
return address(this).balance >= totalDeposited;
}
}
Vulnerable:
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] -= amount; // STATE AFTER CALL — reentrancy
}
Fixed (CEI Pattern):
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // EFFECTS first
(bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTION last
require(ok);
}
Cross-function reentrancy: check if any two functions share state and one has an external call before state update.
Vulnerable (spot price):
function getPrice() public view returns (uint256) {
(uint112 r0, uint112 r1, ) = pair.getReserves();
return (uint256(r1) * 1e18) / uint256(r0); // manipulable in same tx
}
Fixed (Chainlink + staleness check):
function getPrice() public view returns (uint256) {
(, int256 answer, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(answer > 0, "invalid price");
require(block.timestamp - updatedAt < 3600, "stale price");
return uint256(answer);
}
Also consider TWAP for on-chain pricing:
// Uniswap V3 TWAP — use OracleLibrary.consult(pool, twapInterval)
Audit checks:
Guard pattern:
mapping(address => uint256) public lastDepositBlock;
function deposit() external {
lastDepositBlock[msg.sender] = block.number;
// ...
}
function vote() external {
require(block.number > lastDepositBlock[msg.sender], "same block");
// ...
}
Problem: Proxy and implementation share storage. Misaligned slots corrupt data.
// Implementation V1
contract V1 {
uint256 public value; // slot 0
address public owner; // slot 1
}
// Implementation V2 — WRONG: inserted variable shifts slots
contract V2 {
uint256 public value; // slot 0
uint256 public newVar; // slot 1 — COLLISION with owner!
address public owner; // slot 2
}
// Implementation V2 — CORRECT: append only
contract V2 {
uint256 public value; // slot 0
address public owner; // slot 1
uint256 public newVar; // slot 2 — safe, appended
}
Use forge inspect ContractName storage-layout to verify slot alignment between versions.
Vulnerable swap:
function swap(uint256 amountIn) external {
router.swapExactTokensForTokens(amountIn, 0, path, msg.sender, block.timestamp);
// amountOutMin = 0 allows sandwich
}
Fixed:
function swap(uint256 amountIn, uint256 minOut, uint256 deadline) external {
require(block.timestamp <= deadline, "expired");
router.swapExactTokensForTokens(amountIn, minOut, path, msg.sender, deadline);
}
For sensitive operations, use commit-reveal:
mapping(bytes32 => uint256) public commits;
function commit(bytes32 hash) external { commits[hash] = block.number; }
function reveal(uint256 value, bytes32 salt) external {
bytes32 h = keccak256(abi.encodePacked(value, salt, msg.sender));
require(commits[h] > 0 && block.number > commits[h] + 1, "too early");
delete commits[h];
_execute(value);
}
Vulnerable (tx.origin):
function withdraw() external {
require(tx.origin == owner); // phishing attack via malicious contract
}
Fixed:
function withdraw() external {
require(msg.sender == owner); // or use OpenZeppelin Ownable/AccessControl
}
Check for:
onlyOwner but are publicPre-0.8.0 (vulnerable):
// Solidity <0.8.0
uint8 balance = 255;
balance += 1; // wraps to 0 silently
// Fix: use SafeMath
balance = balance.add(1); // reverts on overflow
Post-0.8.0: Built-in overflow checks. But unchecked {} blocks bypass them:
unchecked {
uint8 x = 255;
x += 1; // wraps to 0 — intentional? Audit this.
}
Audit every unchecked block. Verify the math genuinely cannot overflow.
Vulnerable:
payable(to).send(amount); // return value ignored — funds may not arrive
token.transfer(to, amount); // non-standard tokens may return false
Fixed:
(bool ok, ) = payable(to).call{value: amount}("");
require(ok, "ETH transfer failed");
// For ERC20:
SafeERC20.safeTransfer(token, to, amount);
Also check: delegatecall return values, low-level call without length check.
Unbounded loop (gas griefing):
// VULNERABLE: attacker adds thousands of entries
function distributeRewards() external {
for (uint i = 0; i < recipients.length; i++) {
token.transfer(recipients[i], rewards[i]); // OOG if array is huge
}
}
Fixed (pull pattern):
mapping(address => uint256) public pendingRewards;
function claimReward() external {
uint256 amount = pendingRewards[msg.sender];
pendingRewards[msg.sender] = 0;
token.safeTransfer(msg.sender, amount);
}
Other DoS vectors:
receive() / fallback()| Aspect | UUPS | Transparent |
|---|---|---|
| Upgrade logic | In implementation | In proxy |
| Gas (user calls) | Lower | Higher (admin check) |
| Risk | Forgetting _authorizeUpgrade = bricked | More complex proxy |
| Recommended | Yes (OpenZeppelin default) | Legacy |
contract VaultV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public fee;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() { _disableInitializers(); }
function initialize(uint256 _fee) external initializer {
__Ownable_init(msg.sender);
__UUPSUpgradeable_init();
fee = _fee;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
uint256[50] private __gap; // reserve 50 slots for future vars
forge inspect V1 storage-layout vs forge inspect V2 storage-layout and diffk = reserveA * reserveB must hold after every swapmodifier noFlashLoan() {
require(lastActionBlock[msg.sender] < block.number, "same block");
_;
lastActionBlock[msg.sender] = block.number;
}
Check: Can a flash loan be used to manipulate governance, oracle prices, or collateral ratios within a single transaction?
// BEFORE: 3 slots (96 bytes)
uint256 amount; // slot 0
uint128 timestamp; // slot 1
bool active; // slot 2
// AFTER: 2 slots (64 bytes)
uint128 timestamp; // slot 0 (16 bytes)
bool active; // slot 0 (1 byte) — packed!
uint256 amount; // slot 1
// BEFORE: copies array to memory (~expensive)
function process(uint256[] memory ids) external { ... }
// AFTER: reads directly from calldata (~cheap, read-only)
function process(uint256[] calldata ids) external { ... }
// BEFORE
for (uint256 i = 0; i < len; i++) { ... } // overflow check on i each iteration
// AFTER
for (uint256 i = 0; i < len; ) {
...
unchecked { ++i; } // safe: i < len guarantees no overflow
}
// BEFORE: stores string in bytecode
require(amount > 0, "Amount must be greater than zero"); // ~24 bytes
// AFTER: 4-byte selector only
error ZeroAmount();
if (amount == 0) revert ZeroAmount(); // 4 bytes
// BEFORE: 3 SLOAD operations
function calc() external view returns (uint256) {
return baseRate + baseRate * multiplier / baseRate;
}
// AFTER: 1 SLOAD
function calc() external view returns (uint256) {
uint256 _baseRate = baseRate;
return _baseRate + _baseRate * multiplier / _baseRate;
}
// Put cheap check first
require(amount > 0 && balances[msg.sender] >= amount); // SLOAD only if amount > 0
| Severity | Definition |
|---|---|
| Critical | Direct loss of funds or permanent contract bricking. Exploit requires no special permissions. |
| High | Indirect fund loss, significant protocol disruption, or privilege escalation. |
| Medium | Limited fund risk, griefing potential, or state inconsistency under specific conditions. |
| Low | Best practice violation, informational, minor gas inefficiency. |
| Gas | Gas optimization opportunity with no functional impact. |
### [S-01] Title of Finding
**Severity:** Critical / High / Medium / Low / Gas
**Status:** Open / Acknowledged / Fixed
**File:** src/Vault.sol#L42-L58
**Description:**
One paragraph explaining the vulnerability and root cause.
**Impact:**
What can go wrong. Quantify if possible (e.g., "attacker drains all ETH in contract").
**Proof of Concept:**
```solidity
// Foundry test demonstrating the exploit
function test_exploit() public {
// setup
// attack
// assert funds stolen
}
Recommendation: Specific code fix with diff or replacement code.
Team Response: (filled by the audited team)
### Report Structure
1. Executive Summary (scope, duration, findings count by severity)
2. Scope (contracts, commit hash, lines of code)
3. Methodology (tools used, manual review areas)
4. Findings (ordered by severity)
5. Gas Optimizations
6. Informational / Best Practices
7. Appendix (tool output, coverage report)
---
## 7. Tool Commands Reference
```bash
# Static analysis
slither .
slither . --detect reentrancy-eth,unprotected-upgrade
slither . --print human-summary
# Symbolic execution
myth analyze src/Contract.sol --solv 0.8.24 --execution-timeout 600
# Aderyn
aderyn . --output report.md
# Foundry
forge test --fuzz-runs 10000
forge test --fuzz-runs 50000 -vvvv --match-test testFuzz
forge coverage --report lcov
forge inspect Contract storage-layout
forge selectors list
# Echidna
echidna . --contract TestContract --test-mode assertion --test-limit 100000
# Coverage
forge coverage --report summary
forge coverage --report lcov && genhtml lcov.info -o coverage/
forge coverage --report summary
# Target: >95% line coverage, >90% branch coverage
# Critical paths (withdraw, liquidate, upgrade): 100% branch coverage
Priority targets for fuzz testing:
Define protocol invariants that must always hold:
function invariant_totalSupplyMatchesBalances() public view {
uint256 sum = 0;
for (uint i = 0; i < holders.length; i++) {
sum += token.balanceOf(holders[i]);
}
assert(sum == token.totalSupply());
}
function invariant_vaultSolvent() public view {
assert(address(vault).balance >= vault.totalDeposited());
}
bound() to constrain inputs to realistic ranges# .github/workflows/audit.yml
- run: forge test --fuzz-runs 10000
- run: forge coverage --report summary
- run: slither . --sarif output.sarif
- run: aderyn .