Skip to content
1 change: 1 addition & 0 deletions crates/lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ It helps enforce best practices and improve code quality within Foundry projects
- `enumerable-loop-removal`: Flags `remove` on an EnumerableSet inside a loop that also iterates a set with `at`; swap-and-pop removal corrupts the iteration.
- `function-selector-collision`: Flags colliding selectors between a proxy and the statically typed implementation API targeted by its fallback.
- `rtlo`: Flags Unicode bidirectional override characters ("Trojan Source", CVE-2021-42574) that can hide malicious code.
- `reentrancy-balance`: Flags reentrant calls between saving `address(this).balance` and checking the current balance against that stale value.
- `reentrancy-eth`: Flags uncapped ETH-transferring low-level calls followed by writes to state that was read before the call.
- `protected-vars`: Flags externally callable entry points that write a state variable without its required `@custom:security write-protection` function or modifier.
- `unprotected-initializer`: Upgradeable initializers should not be callable on the implementation contract.
Expand Down
53 changes: 53 additions & 0 deletions crates/lint/docs/reentrancy-balance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Reentrancy through stale contract balance checks

**Severity**: `High`
**ID**: `reentrancy-balance`

Flags reentrant external calls between saving `address(this).balance` and checking the current
contract balance against that saved value.

## What it does

Warns when a public or external entry point saves `address(this).balance` in a local value, performs
an external call that can forward enough gas to re-enter, and then compares the saved value with a
fresh contract-balance read in a `require`, `assert`, or exiting branch. Casts, tuple assignments,
derived locals, fresh post-call balance locals, internal helper parameters and returns, and
modifiers are tracked when their bodies are available.

This detector intentionally covers native ETH held by the current contract. It is not the later
token `balanceOf(address)` detector with a similar name. It does not report token balances,
balances of other addresses, mutable state or storage baselines, view or static calls, calls capped
at a total callee budget of 2,300 gas or less after accounting for the value-transfer stipend,
checks on directly mutually exclusive branches, baselines overwritten after the call, or
expressions that do not compare a fresh contract-balance read with the stale local value. It also
recognizes standard state-lock `nonReentrant` modifiers when the same lock guards every mutable
entry point on each concrete deployment of the function.

## Why is this bad?

A callback can re-enter the function several times before any invocation reaches its balance
check. Nested invocations can therefore share the same pre-call balance and make one payment appear
to satisfy several operations. A non-strict inequality does not prevent the attack because the
saved baseline, rather than the comparison operator, is stale.

## Example

### Bad

```solidity
function mint(IPayer payer, uint256 amount) external {
uint256 balanceBefore = address(this).balance;
payer.pay();
require(address(this).balance >= balanceBefore + amount, "insufficient payment");
_mint(msg.sender, amount);
}
```

### Good

```solidity
function mint(uint256 amount) external payable nonReentrant {
require(msg.value >= amount, "insufficient payment");
_mint(msg.sender, amount);
}
```
4 changes: 2 additions & 2 deletions crates/lint/src/sol/high/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use function_selector_collision::FUNCTION_SELECTOR_COLLISION;
use incorrect_exp::INCORRECT_EXP;
use incorrect_shift::INCORRECT_SHIFT;
use protected_vars::PROTECTED_VARS;
use reentrancy::{REENTRANCY_ETH, REENTRANCY_NO_ETH};
use reentrancy::{REENTRANCY_BALANCE, REENTRANCY_ETH, REENTRANCY_NO_ETH};
use rtlo::RTLO;
use unchecked_calls::{ERC20_UNCHECKED_TRANSFER, UNCHECKED_CALL};
use unprotected_initializer::UNPROTECTED_INITIALIZER;
Expand All @@ -38,7 +38,7 @@ register_lints!(
(IncorrectExp, late, (INCORRECT_EXP)),
(IncorrectShift, early, (INCORRECT_SHIFT)),
(ProtectedVars, late, (PROTECTED_VARS)),
(ReentrancyEth, late, (REENTRANCY_ETH, REENTRANCY_NO_ETH)),
(ReentrancyEth, late, (REENTRANCY_BALANCE, REENTRANCY_ETH, REENTRANCY_NO_ETH)),
(UncheckedCall, early, (UNCHECKED_CALL)),
(UncheckedTransferERC20, late, (ERC20_UNCHECKED_TRANSFER)),
(UnprotectedInitializer, late, (UNPROTECTED_INITIALIZER)),
Expand Down
Loading
Loading