Solidity for bug hunters: a read-first primer on visibility, msg.sender vs tx.origin, call vs delegatecall, value transfers, and CEI — with tiny snippets only. Local forks and CTFs for any ex
Learn how to install Kali Linux dual boot alongside Windows 10/11. Complete step-by-step guide with partitioning, GRUB setup, troubleshooting, and safety warnings to prevent data loss.
13 min read
Transparent vs UUPS, uninitialized implementations, and storage-layout collisions — local forge inspect lab. No live upgrades.
35 min read
Solidity for bug hunters is not the same skill as shipping a DeFi app. You need to read strangers’ contracts — modifiers, visibility, auth checks, external calls — and predict how they break under adversarial callbacks. This Andrax tutorial — by Syed Zada Abrar — covers ~70% theory, ~30% tiny snippets.
Prerequisites: How to Start Web3 Bug Hunting in 2026; Wallet, RPC & Block Explorer Setup for Smart Contract Hunters. Hub: Web3 Bug Hunting Learning Path 2026.
Ethics: snippets below are for understanding. Any exploit experiment belongs on Remix local VM, Foundry/Anvil, or a public CTF — never as a confirmation tx against live user funds (Immunefi rules).
Primary references: Solidity docs — latest, Security Considerations, Units and Globally Available Variables, Contracts.
When you open verified source on Etherscan/Sourcify, skim in this order:
balances, owner, implementation)?onlyOwner, roles, tx.origin (smell), initializerscall, delegatecall, token hooks, callbacksYou can write Hello-World Solidity later. Hunters who invert the order ship noisy AI reports and miss real auth bugs.
msg.sender, and tx.originOn Ethereum, code runs because of a transaction (or a message call from another contract). Solidity exposes several globals (docs); hunters live in these three:
| Global | Meaning | Auth use? |
|---|---|---|
msg.sender | Immediate caller (EOA or contract) for this call frame | Yes — standard authorization |
tx.origin | Original EOA that started the whole transaction chain | No for authorization — Solidity docs warn explicitly |
msg.value | Wei sent with this call | Check consistency with accounting |
tx.origin auth is a classic bugSolidity’s own Security Considerations — tx.origin shows a wallet that checks require(tx.origin == owner) and an attack contract that, when it receives Ether, calls back into transferTo. Because tx.origin is still the victim EOA, the drain succeeds. If the wallet had checked msg.sender, the attacker contract address would fail the check.
Hunter takeaway: grep for tx.origin. Treat it as High-severity candidate when used for auth — then prove impact on a local fork/CTF, not mainnet.
Tiny pattern (buggy — for reading only):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ILLUSTRATIVE BUG — do not deploy as a real wallet
contract BadAuth {
address public owner;
constructor() { owner = msg.sender; }
function withdraw(address payable to, uint256 amount) external {
require(tx.origin == owner, "not owner"); // BUG: use msg.sender
to.transfer(amount);
}
}
Function visibility (Contracts docs) controls the call surface hunters map first:
| Visibility | Callable from | Hunter note |
|---|---|---|
external | Outside only (also via this.f()) | Primary attack surface for entry points |
public | Outside + inside | Same, plus internal use |
internal | This contract + children | Not directly from outside; still reachable via inherited/public wrappers |
private | This contract only | Not secret — state marked private is still on-chain readable |
State variable visibility matters too: private hides the name from derived contracts, not the data from chain observers. Solidity’s security docs state plainly that everything used in a contract is publicly visible, including private variables.
Hunter habit: list every external/public function that (a) transfers value, (b) changes owner/roles, (c) sets oracles/prices, (d) upgrades logic, (e) touches approvals.
call vs delegatecall vs staticcall (mental model)Low-level calls are where many “wait, whose storage?” bugs hide.
| Method | Code executed | Storage / context | Typical use |
|---|---|---|---|
addr.call(data) | Callee’s code | Callee’s storage; msg.sender = caller contract | Generic external calls, ETH sends with gas |
addr.delegatecall(data) | Callee’s code | Caller’s storage; msg.sender preserved from original caller | Proxies, libraries (carefully) |
addr.staticcall(data) | Callee’s code | Read-only; state edits revert | Safe-ish views / some checks |
Proxy intuition: if implementation code runs via delegatecall in a proxy, writing owner = … writes the proxy’s storage slot layout. Storage collision and uninitialized implementation bugs are later spokes — for now, whenever you see delegatecall, ask: “whose storage, whose auth?”
Tiny sketch (not a production proxy):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TinyProxy {
address public implementation; // slot 0 — layout must match impl
function setImplementation(address impl) external {
implementation = impl; // AUTH MISSING on purpose for teaching smell
}
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
As a hunter: missing auth on setImplementation is often game-over — prove it locally later with Foundry, not by hijacking a live proxy.
Solidity’s security docs stress that any interaction with another contract can hand over control — including ETH transfers. Classic anti-pattern: external call before zeroing balances. Preferred pattern: Checks → Effects → Interactions (CEI).
Illustrative vulnerable vs safer sketch (from the same docs’ teaching tradition; run only in Remix VM / Foundry):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// VULNERABLE PATTERN — local lab only
contract NaiveBank {
mapping(address => uint256) public balances;
function deposit() external payable { balances[msg.sender] += msg.value; }
function withdraw() external {
uint256 bal = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: bal}(""); // interacts first
require(ok);
balances[msg.sender] = 0; // effect too late
}
}
contract SaferBank {
mapping(address => uint256) public balances;
function deposit() external payable { balances[msg.sender] += msg.value; }
function withdraw() external {
uint256 bal = balances[msg.sender];
balances[msg.sender] = 0; // effect first
(bool ok, ) = msg.sender.call{value: bal}("");
require(ok);
}
}
Full reentrancy taxonomy (classic, cross-function, read-only) is a later spoke. Also see OWASP SC08 Reentrancy.
require, and “silent” success flagsrequire / custom error — expected user/contract failure pathsassert — should mean invariant broken (don’t abuse for input checks)call returns (bool success, bytes memory data) — ignoring success is a recurring finding(bool success, ) = token.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount));
require(success, "token transfer failed");
Also remember: some ERC-20s return false instead of reverting; others are non-standard. Hunters read the token code, not the interface wishful thinking.
Classic contracts set owner = msg.sender in a constructor. Upgradeable proxies often replace constructors with initialize() functions protected by an initializer modifier. Hunters should immediately ask:
initialize be called twice?initialize on the impl and take it over?upgradeTo / _authorizeUpgrade?You do not need the full UUPS spoke yet — you need the reflex: constructor logic that moved to initialize is an auth surface. Flag it in notes; prove it later on a local OpenZeppelin proxy lab.
safe, view, and “only”Function names are marketing. safeTransfer can still be unsafe against weird tokens. view functions can still be used in read-only reentrancy compositions against other contracts’ state. onlyKeeper-style modifiers sometimes check a stale allowlist. Always read the modifier body and the external calls inside the function — never trust the identifier.
BadAuth or NaiveBank above.NaiveBank, deploy a tiny attacker with receive() { NaiveBank(msg.sender).withdraw(); } only on the VM; observe CEI failure mode.msg.sender), and why you did not try this on mainnet.When Foundry is installed (upcoming spoke), recreate the same test as forge test.
You do not need to memorize every ABI quirk on day one, but you must not confuse locations:
| Location | Lifetime | Hunter relevance |
|---|---|---|
| storage | Persistent contract state | Slot layout bugs, proxy collisions, uninitialized gaps |
| memory | Temporary for a call | Expensive copies; mistaken memory vs storage aliases in older patterns |
| calldata | Read-only external input | Cheaper for external args; cannot modify in place |
When reading a function that does balances[msg.sender] = …, you are looking at storage. When a proxy delegatecalls into implementation code that writes owner, it writes whatever slot that variable occupies in the proxy’s storage layout — even if the identifier was declared in the implementation file. That single sentence prevents a whole class of “I patched the impl but the proxy still owns the bug” confusion.
Also remember: dynamic types (string, bytes, arrays) pack differently than uint256. You will measure slots with cast storage / forge inspect in later spokes — for now, treat “I don’t know this slot” as a reason to slow down, not to guess Critical.
Events are not auth. Emitters can lie relative to state if the code is wrong — but in healthy protocols, events are how indexers and hunters reconstruct history on Etherscan’s Logs tab.
Custom error types (Solidity 0.8+) make reverts cheaper and clearer. When a public report says “call reverts with Unauthorized(),” match that selector in verified source before claiming impact. Mismatch between the story and the actual revert is a classic AI-report smell Immunefi triage rejects.
super (read order)Bug hunters get burned by “the bug is in the parent.” Skim:
is clauses left-to-right / C3 linearization surprisesvirtual / override on critical auth functionsinitialize vs constructor)onlyOwner comes from Ownable, AccessControl, or a custom modifier that secretly uses tx.originIf two parents define the same function name, confirm which override wins. Do not audit only the leaf file.
Since 0.8.0, overflow/underflow reverts by default. That closed a generation of textbook bugs — and opened new ones:
uncheckeduint256 → narrower typestype(uint256).max approvals and operator assumptionsSolidity’s security docs still cover two’s-complement limits and unchecked blocks. Hunters should ask: “Is the dangerous math economic, not just arithmetic?”
Many protocols expose IERC20 while the live token is fee-on-transfer, rebasing, or returns false. Your reading checklist:
transfer / hooks (ERC-777 historically; ERC-721/1155 receiver hooks; ERC-4626 deposit paths)?Write those four answers before you declare “simple transfer bug.”
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BetterAuth {
address public owner;
mapping(address => bool) public operators;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor() { owner = msg.sender; }
function setOperator(address who, bool ok) external onlyOwner {
operators[who] = ok;
}
function privilegedAction() external {
require(msg.sender == owner || operators[msg.sender], "no auth");
// effects...
}
}
Still incomplete for production (no 2-step ownership transfer, no events, no roles library) — but it shows the shape hunters expect: msg.sender, explicit roles, no tx.origin. Compare mentally to every onlyOwner you meet on Etherscan.
Memorize:
msg.sender for auth; tx.origin smellcall vs delegatecall storage meaningLook up every time:
Educational content by Syed Zada Abrar for Andrax (andraxpentester.in). Authorized research only.
How to write an Immunefi-ready bug report: structure, PoC expectations, severity language — template practice only.
40 min read
Sign in to leave a comment.