Skip to content
Merged
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
74 changes: 74 additions & 0 deletions docs/returndata-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# External Call Return-Value and Returndata Safety

ChainProof's returndata analysis engine (`CP-RTD-001` through `CP-RTD-016`) detects ignored call success flags, unchecked token returns, unsafe ABI decoding, and stale returndata patterns.

## Threat model

The analyzer assumes:

- Low-level calls (`.call`, `.send`, `.delegatecall`, `.staticcall`) can fail silently
- ERC20 tokens may be non-standard (no return value or false return)
- Returndata buffers are overwritten by subsequent calls
- Assembly returndata copies may read out of bounds

## Rules

| Rule | Category | Description |
|------|----------|-------------|
| CP-RTD-001 | ignored-return | Ignored call success flag |
| CP-RTD-002 | overwritten-return | Call result overwritten before check |
| CP-RTD-003 | token-return | Unchecked ERC20 transfer return |
| CP-RTD-004 | low-level-return | Unchecked low-level `.call()` return |
| CP-RTD-005 | decode-safety | Unsafe ABI decode without length check |
| CP-RTD-006 | stale-returndata | Stale returndata reuse across calls |
| CP-RTD-007 | batch-failure | Partial batch failure ignored |
| CP-RTD-008 | ignored-return | Ignored delegatecall return |
| CP-RTD-009 | ignored-return | Ignored staticcall return |
| CP-RTD-010 | ignored-return | Ignored send() return |
| CP-RTD-011 | transfer-safety | transfer() without return check |
| CP-RTD-012 | assembly-safety | Assembly returndata copy without bounds |
| CP-RTD-013 | try-catch | Try/catch swallows critical failure |
| CP-RTD-014 | multicall | Multicall partial failure not propagated |
| CP-RTD-015 | proxy-decode | Proxy delegatecall decode assumption |
| CP-RTD-016 | optional-call | Security-critical call marked optional |

## Recognized mitigations

- **SafeERC20**: `safeTransfer`, `safeTransferFrom`, `safeApprove`
- **Address utilities**: `functionCall`, `functionCallWithValue`, `sendValue`
- **Try/catch**: Wrapped external calls with explicit handling
- **Assembly bounds**: `returndatasize()` checks before `returndatacopy`

## Usage

### CLI

```bash
chainproof returndata contracts/ --format json
chainproof returndata Token.sol --exclude-rule CP-RTD-011
```

### API

```typescript
import { analyzeReturndataSource, detectReturndataSafety } from '@chainproof/core';

const report = analyzeReturndataSource(source, 'Vault.sol');
// Integrated into ordinary scan via detectReturndataSafety
```

## Slither merge

When `mergeSlither: true` is set in configuration, equivalent Slither return-value findings are merged while preserving ChainProof evidence paths and stable rule identities.

## Limitations

- Cannot distinguish all intentionally optional calls without `@dev optional` documentation
- Assembly analysis is pattern-based, not symbolic
- Does not execute contracts or simulate returndata at runtime

## Troubleshooting

- **False positive on documented optional call**: Add `@dev optional` comment or use `--exclude-rule CP-RTD-016`
- **Missing detection**: Ensure source contains `.call(`, `.transfer(`, or `abi.decode` patterns
- **Secure fixture still flagged**: Verify SafeERC20/Address patterns appear in source text
61 changes: 61 additions & 0 deletions examples/contracts/returndata/SecureReturndata.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}

library SafeERC20Mock {
function safeTransfer(IERC20 token, address to, uint256 amount) internal {
require(token.transfer(to, amount), "transfer failed");
}
}

library AddressMock {
function functionCall(address target, bytes memory data) internal returns (bytes memory) {
(bool success, bytes memory result) = target.call(data);
require(success, "call failed");
return result;
}

function sendValue(address payable to, uint256 amount) internal {
(bool success,) = to.call{value: amount}("");
require(success, "send failed");
}
}

/// @notice Secure contract with checked returns and SafeERC20-style wrappers.
contract SecureReturndata {
using SafeERC20Mock for IERC20;
IERC20 public token;

constructor(address _token) {
token = IERC20(_token);
}

function pay(address to, uint256 amount) external {
token.safeTransfer(to, amount);
}

function execute(address target, bytes calldata data) external {
AddressMock.functionCall(target, data);
}

function sendEth(address payable to, uint256 amount) external {
AddressMock.sendValue(to, amount);
}

function batchPay(address[] calldata recipients, uint256[] calldata amounts) external {
require(recipients.length == amounts.length, "length");
for (uint256 i = 0; i < recipients.length; i++) {
token.safeTransfer(recipients[i], amounts[i]);
}
}

/// @dev optional notification; failure is intentionally ignored
function tryNotifyOptional(address to) external {
try token.transfer(to, 1) returns (bool success) {
success;
} catch {}
}
}
47 changes: 47 additions & 0 deletions examples/contracts/returndata/VulnerableReturndata.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

/// @notice Vulnerable contract with ignored call returns and unsafe token transfers.
contract VulnerableReturndata {
IERC20 public token;
address public target;

constructor(address _token) {
token = IERC20(_token);
}

function pay(address to, uint256 amount) external {
token.transfer(to, amount);
}

function execute(bytes calldata data) external {
target.call(data);
}

function sendEth(address payable to) external {
to.send(1 ether);
}

function batchPay(address[] calldata recipients, uint256[] calldata amounts) external {
for (uint256 i = 0; i < recipients.length; i++) {
token.transfer(recipients[i], amounts[i]);
}
}

function decodeResult(bytes memory data) external pure returns (uint256 value) {
value = abi.decode(data, (uint256));
}

function proxyCall(address impl, bytes calldata data) external {
impl.delegatecall(data);
}

function tryNotify(address to) external {
try token.transfer(to, 1) {} catch {}
}
}
186 changes: 186 additions & 0 deletions packages/cli/src/commands/returndata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { Command } from "commander";
import chalk from "chalk";
import * as fs from "fs";
import {
analyzeReturndataFiles,
generateReturndataMarkdown,
ReturndataAnalysisCancelledError,
ReturndataConfigError,
loadReturndataConfigFile,
serializeReturndataReport,
} from "@chainproof/core";
import type {
ReturndataAnalysisLimits,
ReturndataAnalysisOptions,
ReturndataAnalysisReport,
ReturndataRuleId,
} from "@chainproof/core";

type OutputFormat = "json" | "markdown";
type FailSeverity = "none" | "info" | "low" | "medium" | "high" | "critical";

interface ReturndataCliOptions {
format: OutputFormat;
output?: string;
config?: string;
includeModels?: boolean;
includeRule: string[];
excludeRule: string[];
maxSourceBytes?: number;
maxFiles?: number;
maxContracts?: number;
maxFunctions?: number;
maxOperations?: number;
maxFindings?: number;
failOn: FailSeverity;
}

const RULE_PATTERN = /^CP-RTD-(?:00[1-9]|01[0-6])$/;
const SEVERITY_RANK: Record<FailSeverity, number> = {
none: 99,
info: 1,
low: 2,
medium: 3,
high: 4,
critical: 5,
};

export function registerReturndataCommand(program: Command, printBanner: () => void): void {
program
.command("returndata <targets...>")
.description("Analyze cross-chain returndata and message verification safety")
.option("--format <format>", "Output format: json|markdown", "markdown")
.option("--output <file>", "Write the report to a file")
.option("--config <file>", "Load a versioned returndata analysis configuration")
.option("--include-models", "Include the normalized returndata model in JSON output")
.option("--include-rule <id>", "Only run a rule (repeatable)", collect, [])
.option("--exclude-rule <id>", "Skip a rule (repeatable)", collect, [])
.option("--max-source-bytes <n>", "Maximum bytes per Solidity source", positiveInteger)
.option("--max-files <n>", "Maximum number of Solidity files", positiveInteger)
.option("--max-contracts <n>", "Maximum contracts per Solidity file", positiveInteger)
.option("--max-functions <n>", "Maximum functions per file and contract", positiveInteger)
.option("--max-operations <n>", "Maximum modeled operations per function", positiveInteger)
.option("--max-findings <n>", "Maximum findings in the report", positiveInteger)
.option(
"--fail-on <severity>",
"Exit 1 when this severity or higher is present: none|info|low|medium|high|critical",
"high",
)
.action((targets: string[], raw: ReturndataCliOptions) => {
const json = raw.format === "json";
if (!json && raw.format === "markdown") printBanner();
try {
validateFormat(raw.format);
validateFailSeverity(raw.failOn);
const configured = raw.config ? loadReturndataConfigFile(raw.config) : undefined;
const includeRules = raw.includeRule.length
? validateRules(raw.includeRule, "--include-rule")
: configured?.config.includeRules;
const excludeRules = raw.excludeRule.length
? validateRules(raw.excludeRule, "--exclude-rule")
: configured?.config.excludeRules;
rejectOverlap(includeRules, excludeRules);
const limits: Partial<ReturndataAnalysisLimits> = {
...configured?.config.limits,
...(raw.maxSourceBytes ? { maxSourceBytes: raw.maxSourceBytes } : {}),
...(raw.maxFiles ? { maxFiles: raw.maxFiles } : {}),
...(raw.maxContracts ? { maxContracts: raw.maxContracts } : {}),
...(raw.maxFunctions ? {
maxFunctionsPerFile: raw.maxFunctions,
maxFunctionsPerContract: raw.maxFunctions,
} : {}),
...(raw.maxOperations ? { maxOperationsPerFunction: raw.maxOperations } : {}),
...(raw.maxFindings ? { maxFindings: raw.maxFindings } : {}),
};
const options: ReturndataAnalysisOptions = {
limits,
includeModels: raw.includeModels ?? configured?.config.includeModels ?? false,
...(includeRules ? { includeRules } : {}),
...(excludeRules ? { excludeRules } : {}),
};
const report = analyzeReturndataFiles(targets, options);
const output = raw.format === "json"
? serializeReturndataReport(report)
: generateReturndataMarkdown(report);
if (raw.output) {
writeReport(raw.output, output);
if (!json) console.log(chalk.green(`\n Returndata report written to ${raw.output}`));
} else {
process.stdout.write(output);
}
process.exit(exitCode(report, raw.failOn));
} catch (error) {
const message = error instanceof ReturndataConfigError ||
error instanceof ReturndataAnalysisCancelledError || error instanceof Error
? error.message
: "Returndata analysis failed";
console.error(chalk.red(`Returndata analysis error: ${sanitize(message)}`));
process.exit(2);
}
});
}

function collect(value: string, previous: string[]): string[] {
return [...previous, value];
}

function positiveInteger(value: string): number {
if (!/^\d+$/.test(value)) throw new ReturndataConfigError("analysis limits must be positive integers");
const result = Number(value);
if (!Number.isSafeInteger(result) || result <= 0) {
throw new ReturndataConfigError("analysis limits must be positive safe integers");
}
return result;
}

function validateRules(values: string[], option: string): ReturndataRuleId[] {
const result = new Set<ReturndataRuleId>();
for (const value of values) {
if (!RULE_PATTERN.test(value)) throw new ReturndataConfigError(`${option} contains unknown rule ${value}`);
result.add(value as ReturndataRuleId);
}
return [...result].sort();
}

function rejectOverlap(include: ReturndataRuleId[] | undefined, exclude: ReturndataRuleId[] | undefined): void {
if (!include || !exclude) return;
const overlap = include.filter((rule) => exclude.includes(rule));
if (overlap.length) throw new ReturndataConfigError(`included and excluded rules overlap: ${overlap.join(", ")}`);
}

function validateFormat(value: string): asserts value is OutputFormat {
if (value !== "json" && value !== "markdown") {
throw new ReturndataConfigError("--format must be json or markdown");
}
}

function validateFailSeverity(value: string): asserts value is FailSeverity {
if (!(value in SEVERITY_RANK)) {
throw new ReturndataConfigError("--fail-on must be none, info, low, medium, high, or critical");
}
}

function exitCode(report: ReturndataAnalysisReport, threshold: FailSeverity): number {
const rank = SEVERITY_RANK[threshold];
return report.files.some((file) => file.findings.some((finding) =>
severityRank(finding.severity) >= rank,
)) ? 1 : 0;
}

function severityRank(severity: string): number {
return severity in SEVERITY_RANK ? SEVERITY_RANK[severity as FailSeverity] : 0;
}

function sanitize(message: string): string {
return message.replace(/[\r\n]+/g, " ").slice(0, 500);
}

function writeReport(file: string, output: string): void {
try {
fs.writeFileSync(file, output, "utf8");
} catch (error) {
const code = (error as { code?: unknown } | null)?.code;
const safeCode = typeof code === "string" && /^[A-Z0-9_]+$/.test(code) ? code : "IO_ERROR";
throw new ReturndataConfigError(`report file could not be written (${safeCode})`);
}
}
Loading
Loading