Security engineering
About 1957 wordsAbout 7 min
Week 6 · Part 3 — Security engineering
Core question — my tests pass, so how do I find what they never checked, and make sure it cannot come back?
In Part 1 you proved the behaviour you wrote tests for. An attacker does not care about those. They look for the behaviour nobody thought to test.
In Week 3 Part 5 you learned to be careful about what you approve. This Part moves to the developer's side: finding a weakness in code, proving it is real, fixing it, and leaving a test behind so it stays fixed.
Picture a house inspection
Your passing tests are like checking that the front door locks. A burglar tries the back window. A security review walks around the whole house and asks "how else could someone get in?"
Where the picture stops: a house has a few doors and windows. A contract has every function, every caller and every other contract it talks to. You will not check everything, so you learn which questions catch the most.
The hard skill
Find a weakness in a contract, reproduce it with a test, patch it, and keep the test as a regression check.
After this Part you can:
- read a contract function by function with a short list of security questions;
- recognise access-control failures and unsafe external calls;
- turn a suspected weakness into a test that fails because of it;
- patch the weakness and show the same test now passes.
This Part does not make you a smart-contract auditor. Professional audits use specialised tools and experience far beyond one lesson. The aim is that you stop shipping the most common mistakes yourself.
Core / reference material
Five questions for every function
Go through the contract one function at a time and ask:
- Who can call this? Anyone, only the owner, only one account? Is that check actually in the code, or only in the comment?
- What does it change? Which state, and whose?
- Does it send ETH or call another contract? If so, what has already changed when that call happens, and what has not changed yet?
- What does it trust? An input, a caller, another contract, a price?
- What if it runs twice, or in the middle of itself?
Most beginner-level weaknesses fail one of these questions.
Common weaknesses
| Weakness | What goes wrong | Usual defence |
|---|---|---|
| Missing access control | A function meant for the owner, or for one account, can be called by anyone | Check msg.sender before doing anything; an Ownable-style pattern |
| Unsafe external call (reentrancy) | The contract sends ETH or calls out before updating its own state, so the receiver can call back in while the old state is still there | Checks-effects-interactions; a reentrancy guard |
| Approval and permission misuse | A contract can move more than the user meant, or keeps a permission after it should end | Approve exact amounts; revoke when done (Week 3 Part 5) |
| Missing input validation | Zero amounts, empty values, the zero address or huge numbers are accepted | Reject bad input at the top of the function |
| Over-powered admin functions | The owner can do far more than users expect, such as move everyone's funds | Keep admin powers small, visible and documented |
Checks, effects, interactions
A function that sends ETH or calls another contract should do things in this order:
- Checks: validate the caller and the inputs.
- Effects: update your own state.
- Interactions: only then send ETH or call another contract.
Why the order matters: sending ETH to a contract runs that contract's code. If your state is not updated yet, that code can call your function again, and your function still sees the old state. This is called reentrancy.
Picture a cash machine that hands out the money first and only then updates your balance. If you could press "withdraw" again while the notes are still coming out, it would pay you again from the same balance. Where that picture stops: a person cannot do this at a real machine, but a contract can call back within the same transaction, in a fraction of a second.
Assumptions are not guarantees
A comment saying /// only the owner can call this is an assumption. A line that reverts when msg.sender != owner is a guarantee. Reviewers trust the second, not the first. The same goes for "users will only send small amounts" or "nobody will call this from a contract".
Reproducing a weakness in Foundry
You already have everything you need from Part 1. A reproduction is a test that describes the safe behaviour, so it fails while the weakness exists:
- For an access-control weakness:
vm.prankan outsider, thenvm.expectRevert(...)the error the contract should give. - For a weakness involving ETH: use
vm.deal(addr, amount)to give accounts test ETH, then check balances afterwards. - For reentrancy: write a small attacker contract inside your test file. Its
receive()function runs whenever it is sent ETH, which is where a callback would happen.
Once the patch is in, the same test passes. You keep it, and it becomes the regression test: if anyone undoes the fix later, it fails again.
Worked example
Suppose a teammate adds an owner-only moderation function to the starter's Registry, so the owner can write a record on someone's behalf:
/// @notice Owner-only moderation: write a record on someone's behalf.
function setRecordFor(address account, string calldata value) external {
if (bytes(value).length == 0) revert EmptyRecord();
if (bytes(value).length > 140) revert RecordTooLong();
records[account] = value;
emit RecordUpdated(account, value);
}Find. Question 1, "who can call this?" The comment says owner-only, but no line checks the caller. Anyone can overwrite anyone's record.
Reproduce. Write a test that describes the safe behaviour:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
import {Registry} from "../src/Registry.sol";
contract SetRecordForTest is Test {
Registry registry;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public { registry = new Registry(); }
function test_RevertWhen_NonOwnerWritesForSomeoneElse() public {
vm.expectRevert(Registry.NotOwner.selector);
vm.prank(alice);
registry.setRecordFor(bob, "not really bob");
assertEq(registry.records(bob), "");
}
}Run it and it fails, which proves the weakness is real:
Ran 1 test for contracts/test/SetRecordFor.t.sol:SetRecordForTest
[FAIL: next call did not revert as expected] test_RevertWhen_NonOwnerWritesForSomeoneElse() (gas: 38000)
Suite result: FAILED. 0 passed; 1 failed; 0 skippedFix. Add the check the comment promised, as the first line:
function setRecordFor(address account, string calldata value) external {
if (msg.sender != owner) revert NotOwner();
// ...rest unchanged
}Regression. Run forge test again. The same test now passes, and the starter's six still pass:
Ran 1 test for contracts/test/SetRecordFor.t.sol:SetRecordForTest
[PASS] test_RevertWhen_NonOwnerWritesForSomeoneElse() (gas: 18113)
Suite result: ok. 1 passed; 0 failed; 0 skippedThat is the whole loop: find, reproduce, fix, keep the test. You did not need to deploy anything or spend any test ETH.
Landscape — tools and libraries you will hear about
- Static analysers such as Slither scan code for known weakness patterns automatically. They find candidates; a person still has to check each one.
- OpenZeppelin Contracts provides tested building blocks, such as
Ownablefor owner checks andReentrancyGuardfor blocking callbacks. Using them is common practice. Understanding what they protect against is what this Part is about. - Audit contests, where many reviewers search one codebase for bugs for rewards, are where many security researchers start.
Hands-on task
The lab is on the w6-3-security-lab branch of your starter repository. It adds TipRegistry: the Registry, plus tipping. Anyone can send test ETH to thank the author of a record, and authors withdraw what they have received.
Local tests only
TipRegistry is deliberately unsafe. Run it only in local Foundry tests. Never deploy it, and never send it real funds.
Get the lab. From your starter repository:
git fetch origin git checkout w6-3-security-lab forge test --match-contract TipRegistryTestAll six tests pass. That does not mean the contract is safe.
Find one weakness. Read
contracts/src/TipRegistry.solwith the five questions. The contract has more than one weakness; you need one. Write two or three sentences: what the weakness is, who could exploit it, and what they would gain or break.Reproduce it. Add a test in
contracts/test/that fails because of the weakness. Run it and keep the failing output.Fix it. Change
TipRegistry.solso the weakness is gone, with the smallest change that does the job.Prove the fix. Run
forge test. Your new test must pass, and every test that passed before your change (the sixTipRegistrytests and the starter's sixRegistrytests) must still pass.
Hint: a test that needs to receive ETH
If your weakness involves sending ETH, your test may need its own small contract that can receive it:
contract Attacker {
// keep a reference to the target contract here
receive() external payable {
// this runs every time the contract is sent ETH:
// what could it call from here?
}
}Give accounts test ETH with vm.deal(addr, 10 ether), and check address(...).balance before and after.
Out of scope: running audit tools, fixing every weakness in the contract, gas optimisation, and deploying anything.
Use AI as a pair, not a replacement
An AI assistant can suggest likely weaknesses. Do not submit one you cannot reproduce. The failing test is your evidence that the weakness is real, and the passing test is your evidence that the fix works.
Evidence required
- Two or three sentences describing the weakness: what it is, who could exploit it and what they would gain or break.
- Your test file, and the
forge testoutput showing it failing before the fix. - Your patch (the diff, or a link to the commit).
- The
forge testoutput showing every test passing after the fix.
Completion and revision
This Part is worth 100 points. Completed / approved earns the full points; incomplete or materially incorrect work is returned with specific feedback for revision. There is no partial-score rubric.
Further exploration — optional, not assessed
- Find and fix a second weakness in
TipRegistry, with its own regression test. - Replace your hand-written fix with OpenZeppelin's
ReentrancyGuardorOwnable, and check your regression test still passes. - Read the Solidity security considerations, which cover reentrancy and checks-effects-interactions in more depth.
Sources and attribution
- Solidity documentation — Security considerations — Link, referenced only
- OpenZeppelin Contracts — ReentrancyGuard — Link, referenced only
- OpenZeppelin Contracts — Ownable — Link, referenced only
- Foundry Book — deal cheatcode — Link, referenced only
- Blockchain@NTU Academy Builder starter — Reuse (MIT), the
Registryand theTipRegistrylab contract