
Testing Solidity Reentrancy Defenses with Attack Harnesses
Why reentrancy tests need a dedicated harness
A normal unit test often calls a function once and checks the final state. That is not enough for reentrancy. The bug only appears when control flow leaves your contract and returns before the original function finishes.
A good reentrancy test should answer these questions:
- Can an external callback re-enter the same function?
- Can it enter a different function that shares the same state?
- Does the contract preserve balances, ownership, or accounting after the attack?
- Does the defense fail closed, or does it partially update state before reverting?
A malicious harness is useful because it can:
- receive ETH or tokens and immediately call back into the target contract
- control the timing of the re-entry
- record whether the second call succeeded
- assert that the target contract’s state remains consistent
The attack surface you should test
Reentrancy is not limited to call{value: ...}. Any external interaction can be a callback point:
- ETH transfers using
call - ERC777 token hooks
- ERC1155 receiver callbacks
safeTransferFromon NFT contracts- external oracle or adapter calls
- low-level calls to untrusted contracts
The most common vulnerable pattern is:
- contract reads user balance
- contract sends ETH or calls external code
- contract updates balance afterward
If the external code re-enters before step 3, the same balance may be spent twice.
A minimal vulnerable example
The following contract is intentionally unsafe. It sends ETH before reducing the user’s balance.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
// Vulnerable: external call before state update
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] -= amount;
}
}A test that only checks one withdrawal would miss the bug. A reentrancy harness can exploit the gap between the transfer and the balance update.
Building a malicious test harness
The harness is a small contract that deposits into the target, then re-enters during the ETH receive callback.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IVault {
function deposit() external payable;
function withdraw(uint256 amount) external;
function balances(address user) external view returns (uint256);
}
contract ReentrancyAttacker {
IVault public immutable vault;
uint256 public reentryCount;
uint256 public maxReentries;
uint256 public attackAmount;
constructor(address vaultAddress) {
vault = IVault(vaultAddress);
}
function attack(uint256 _maxReentries) external payable {
maxReentries = _maxReentries;
attackAmount = msg.value;
vault.deposit{value: msg.value}();
vault.withdraw(msg.value);
}
receive() external payable {
if (reentryCount < maxReentries) {
reentryCount++;
vault.withdraw(attackAmount);
}
}
}How it works
attack()deposits ETH into the vault and starts the first withdrawal.- The vault sends ETH back to the attacker.
- The attacker’s
receive()function fires. - The attacker re-enters
withdraw()before the original call finishes.
This pattern is simple, but it is enough to validate whether your defense actually blocks recursive entry.
What to assert in the test
A reentrancy test should not only check that the attack reverts. Sometimes the correct defense is to allow the first call but prevent the second. Other times the contract should revert the entire transaction.
Useful assertions include:
- the attacker cannot withdraw more than their deposit
- the vault’s ETH balance remains correct
- the attacker’s recorded balance is zero or reduced as expected
- reentry counter stays at zero when a guard is active
- no partial state changes survive a revert
If your contract emits events, you can also assert that only one withdrawal event is emitted. But the core of the test should be state integrity, not event count.
Testing the vulnerable contract
The exact test framework is less important than the structure. The key is to deploy the vault, fund the attacker, and inspect balances after the attack attempt.
A typical test flow is:
- deploy the vault
- deploy the attacker with the vault address
- send ETH to the attacker via
attack() - verify whether the attack drained more than expected
- compare final balances against the initial accounting
If the vault is vulnerable, the attacker may receive multiple withdrawals from a single deposit. If the vault is safe, the second call should fail or the entire transaction should revert.
Defending with a reentrancy guard
The most common mitigation is a mutex-style guard. OpenZeppelin’s ReentrancyGuard is widely used, but the important part is understanding what it protects and what it does not.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}Why this version is safer
- the balance is reduced before the external call
nonReentrantblocks recursive entry into the same function- if the external call fails, the whole transaction reverts and state is restored
This is the preferred pattern for value transfers in most contracts.
Cross-function reentrancy: the bug many tests miss
A contract can be safe against direct re-entry into one function but still vulnerable if another function shares the same state. For example:
withdraw()is guardedclaimReward()is not guarded- both functions read and write the same balance or reward pool
An attacker may re-enter through a different path and exploit inconsistent intermediate state.
Example scenario
Suppose withdraw() sends ETH and then updates a reward accumulator. During the callback, the attacker calls claimReward(), which reads the old accumulator value. The contract is still vulnerable even though withdraw() itself has a guard.
Test strategy
Design a harness that can call multiple public functions from receive() and try alternate entry points. Your test should verify:
- shared state is updated atomically
- all externally callable state-mutating functions are covered
- the guard is applied consistently where needed
Comparing common defense patterns
| Defense pattern | Strengths | Weaknesses | Best use case |
|---|---|---|---|
checks-effects-interactions | Simple, cheap, easy to audit | Easy to implement incorrectly | Balance withdrawals, claims, refunds |
ReentrancyGuard | Strong protection against recursive entry | Does not fix bad accounting logic | Public functions with external calls |
| Pull payments | Avoids direct external transfer in critical path | Requires users to claim funds later | Escrow, payouts, refunds |
| Internal accounting only | Minimizes external interaction | More complex architecture | High-value protocols, aggregators |
A strong test suite should verify the chosen pattern, not just the symptom.
Practical best practices for attack harnesses
Keep the harness deterministic
Avoid randomness in the attacker contract. You want the same sequence of calls every time so failures are reproducible.
Parameterize the number of re-entries
A single re-entry may not expose all bugs. Test with:
- zero re-entries, to confirm normal behavior
- one re-entry, to catch the classic case
- multiple re-entries, to stress recursive paths
Test both revert and non-revert outcomes
Some defenses intentionally revert the entire transaction. Others stop the second call but allow the first to succeed. Your assertions should match the intended design.
Use realistic callback surfaces
If your contract interacts with tokens or receiver interfaces, test the actual callback mechanism rather than a simplified ETH-only attack. For example, ERC777 and ERC1155 hooks can trigger reentrancy in production even when ETH transfers are absent.
Verify post-attack invariants
After the test, check:
- contract ETH balance
- attacker ETH balance
- internal accounting mappings
- total supply or pool totals
- any pending claim state
A reentrancy test is only complete when the final state is mathematically consistent.
A compact checklist for reentrancy test coverage
- Test every external call site
- Test every public function that mutates shared state
- Test direct and cross-function reentrancy
- Test with both guarded and unguarded versions of the contract
- Assert final balances, not just success or failure
- Confirm that failed attacks do not leave partial state behind
Common mistakes when writing these tests
Assuming transfer() is always safe
Historically, transfer() limited gas and reduced callback risk, but relying on gas stipends is not a robust security strategy. Modern Solidity code should not treat transfer() as a reentrancy defense.
Guarding only one function
If multiple functions touch the same accounting, they may all need protection or a redesigned flow.
Forgetting token callbacks
A contract that never sends ETH can still be reentered through token standards with receiver hooks.
Testing only the happy path
A defense that works under normal conditions may still fail when the external call reverts, consumes gas, or triggers nested calls.
When to prefer a pull-based design
If your contract pays users, a pull-based architecture is often easier to secure and test than push-based transfers.
Instead of sending funds immediately:
- record the amount owed
- let the user withdraw later
- update state before any external transfer
This reduces the number of dangerous external calls in critical paths and makes reentrancy tests simpler. Your harness then focuses on the withdrawal function alone.
Conclusion
Reentrancy testing is most effective when you model the attacker explicitly. A dedicated harness gives you precise control over callback timing, re-entry depth, and alternate entry points. Combined with strong assertions on balances and shared state, it can expose vulnerabilities that ordinary unit tests will miss.
The best defense is still architectural: update state before external calls, minimize callback surfaces, and use guards where appropriate. But even a well-designed contract should be tested with a malicious harness to prove that the defense holds under real attack conditions.
