Error handling is not a neutral mechanism. Every revert your contract emits is a message — and attackers read messages carefully. The data you include in a require string, a custom error parameter, or a revert call tells observers something about the internal state of your contract, even when the transaction fails and no state changes are written to the chain.
Revert data is fully visible. It appears in node RPC responses, in block explorers, and in the receipts that MEV bots, competing protocols, and malicious users scrape continuously. A developer who writes require(balance[user] >= amount, string.concat("Insufficient: have ", uint2str(balance[user]))) to help with debugging has inadvertently published every user's balance to anyone willing to send a failing transaction.
Beyond information leakage, error handling shapes correctness guarantees. A missing require where a guard was intended, a misapplied assert that burns all remaining gas, or a try/catch block that silently ignores whole categories of errors can each create exploitable conditions. The developer's intent — "stop if this fails" — diverges from the actual runtime behavior, and that gap is where vulnerabilities live.
Solidity gives developers three primary tools for error handling: require, which reverts with a message if a condition is false; revert, which unconditionally reverts with optional data; and assert, which signals an invariant violation. Custom errors, added in 0.8.4, provide a gas-efficient alternative to string messages. Understanding when to use each, and what each reveals to external observers, is a prerequisite for writing secure contracts.
This post covers six vulnerability classes that emerge from the intersection of error handling and information security in Solidity. Each pattern is subtle enough to pass code review, yet consequential enough to compromise the privacy or correctness guarantees of a production protocol.
1. Revert Reason Exposes Internal State
The most direct form of information leakage through error handling is embedding sensitive state data in revert strings.
Vulnerable code:
function withdraw(uint256 amount) external {
require(
balance[msg.sender] >= amount,
string.concat("Balance: ", uint2str(balance[msg.sender]))
);
balance[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
An attacker does not need a getBalance() view function to learn how much any address holds. They send a withdrawal transaction for a very large amount from an address they control, watch it revert, and read the revert reason. Then they do the same for any address they want to probe — spoofing msg.sender is impossible on-chain, but they can call the function as any EOA from an off-chain simulation using eth_call with a crafted from field.
The impact is protocol-wide. If balances are supposed to be private — as they are in many lending protocols, privacy pools, or order books — this single require string destroys that property entirely.
Fix:
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "Insufficient balance");
balance[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
Use generic messages that confirm what went wrong without revealing the underlying values. If you need the actual balance for debugging, rely on events emitted during successful operations, or use a private off-chain logging system.
2. Missing require() in a Critical Check
A common correctness bug masquerading as a security pattern: using an early return where a revert is required. The developer intends to guard against invalid state, but the chosen construct allows execution to silently continue along the wrong path.
Vulnerable code:
function processTransfer(address from, address to, uint256 amount) internal {
if (balance[from] < amount) {
return; // silently does nothing
}
balance[from] -= amount;
balance[to] += amount;
emit Transfer(from, to, amount);
}
function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
for (uint256 i = 0; i < recipients.length; i++) {
processTransfer(msg.sender, recipients[i], amounts[i]);
}
}
When balance[from] is insufficient, processTransfer returns without doing anything. From the caller's perspective, the batch transaction succeeded — it emitted no error, cost gas, and returned normally. Integrating contracts that assume a successful transaction implies all transfers completed will have a corrupted view of the world. In the worst case, off-chain accounting systems credit recipients while on-chain balances never moved.
Fix:
function processTransfer(address from, address to, uint256 amount) internal {
require(balance[from] >= amount, "Insufficient balance");
balance[from] -= amount;
balance[to] += amount;
emit Transfer(from, to, amount);
}
Use require for any check that must stop execution if the condition is false. Early return is appropriate only when the no-op path is genuinely acceptable and explicitly documented. For invariant checks in financial operations, silent continuation is almost never correct.
3. Custom Error with Sensitive Parameters
Custom errors introduced in Solidity 0.8.4 are gas-efficient and composable, but they carry the same information leakage risk as revert strings. Error parameters are ABI-encoded in the revert data and are just as readable as string messages.
Vulnerable code:
error InsufficientBalance(uint256 actual, uint256 required);
function transfer(address to, uint256 amount) external {
if (balance[msg.sender] < amount) {
revert InsufficientBalance(balance[msg.sender], amount);
}
balance[msg.sender] -= amount;
balance[to] += amount;
}
Even if there is no balanceOf view function, an attacker can call transfer(attacker, type(uint256).max) via eth_call for any from address, decode the revert data, and extract the actual balance from the first parameter. The four-byte selector for InsufficientBalance(uint256,uint256) is deterministic and publicly derivable from the ABI.
Fix:
error InsufficientBalance();
function transfer(address to, uint256 amount) external {
if (balance[msg.sender] < amount) {
revert InsufficientBalance();
}
balance[msg.sender] -= amount;
balance[to] += amount;
}
If the balance data is not sensitive — for example, in a standard ERC-20 token where balanceOf is public anyway — including it in the error is fine and improves debuggability. Apply this fix only when the underlying state is genuinely sensitive.
4. Using assert() Instead of require() for Input Validation
assert and require look similar but have meaningfully different semantics and gas behaviors. Using assert for user input validation is both a security and a denial-of-service issue.
Vulnerable code:
function setMultiplier(uint256 value) external {
assert(value <= MAX_MULTIPLIER);
multiplier = value;
}
Before Solidity 0.8, assert compiled to the INVALID opcode, which consumes all remaining gas when triggered. A user who calls setMultiplier with a value above MAX_MULTIPLIER loses every unit of gas they forwarded — including gas forwarded through intermediate contracts. This becomes a griefing vector: an attacker can craft calls that funnel large gas stipends into a failing assert and burn them.
In Solidity 0.8+, assert triggers a Panic(uint256) error with code 0x01. While the gas behavior changed slightly, assert panics still consume all gas forwarded through external calls in some contexts, and the semantic confusion between "this should never happen" and "user gave bad input" remains a maintenance and audit liability.
Fix:
function setMultiplier(uint256 value) external {
require(value <= MAX_MULTIPLIER, "Multiplier exceeds maximum");
multiplier = value;
}
The rule is straightforward: require validates inputs and external conditions that may legitimately fail; assert verifies internal invariants that represent programming bugs if they trigger. Anything a user can cause to fail should use require.
5. Revert in Fallback Without Data
Contracts that reject plain ETH transfers or unrecognized calldata often implement a bare revert() in their receive or fallback function. This creates ambiguity for callers and breaks integrations that depend on distinguishing rejection types.
Vulnerable code:
receive() external payable {
revert();
}
fallback() external payable {
revert();
}
When a contract calls this function and the revert() fires, the caller receives empty revert data — 0x bytes with no selector and no message. Integration contracts that check for specific error selectors to handle rejection gracefully will fail to match, potentially treating "intentional rejection" the same as "out of gas" or "stack overflow." Worse, try/catch blocks in Solidity only catch revert data in their catch Error(string memory) and catch (bytes memory) branches — and empty revert data falls through in ways that can cause unexpected behavior in complex call chains.
Fix:
error ETHNotAccepted();
error UnknownFunction();
receive() external payable {
revert ETHNotAccepted();
}
fallback() external payable {
revert UnknownFunction();
}
Named custom errors in fallback functions allow callers to identify exactly why the call failed. This makes integration code more robust and makes the contract's behavior auditable — a reader of the ABI can immediately see what conditions trigger rejection without reading the full bytecode.
6. try/catch Missing the Custom Error Branch
Solidity's try/catch syntax has three catch forms, and many developers only write one. When a protocol uses custom errors, integrations that only catch Error(string memory) will silently miss those custom errors and allow them to propagate as uncaught reverts.
Vulnerable code:
interface IPool {
error InsufficientLiquidity();
function swap(uint256 amountIn) external returns (uint256 amountOut);
}
contract Router {
IPool pool;
function safeSwap(uint256 amountIn) external returns (bool success, uint256 amountOut) {
try pool.swap(amountIn) returns (uint256 result) {
return (true, result);
} catch Error(string memory) {
// catches legacy string reverts only
return (false, 0);
}
// InsufficientLiquidity() is NOT caught here — it propagates up
}
}
When pool.swap reverts with InsufficientLiquidity(), the catch Error(string memory) branch does not match because InsufficientLiquidity() is not a string revert — it is a custom error with selector 0xbb55fd27. The error propagates up through safeSwap, reverting the entire call even though Router intended to handle swap failures gracefully. Any protocol built on top of safeSwap that expects a (bool, uint256) return will instead receive an unexpected revert.
Fix:
contract Router {
IPool pool;
function safeSwap(uint256 amountIn) external returns (bool success, uint256 amountOut) {
try pool.swap(amountIn) returns (uint256 result) {
return (true, result);
} catch Error(string memory) {
return (false, 0);
} catch (bytes memory) {
// catches custom errors and low-level reverts
return (false, 0);
}
}
}
Always include catch (bytes memory) when integrating with contracts that use custom errors. If you need to distinguish between specific custom errors, decode the first four bytes of the bytes data against known selectors.
What ContractScan Detects
ContractScan performs static and semantic analysis across all six of these vulnerability classes during smart contract audits.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Revert reason exposes internal state | Taint analysis tracing state variables into revert string arguments | High |
| Missing require() in critical check | Control flow analysis detecting silent early returns on guard conditions | High |
| Custom error with sensitive parameters | Data flow analysis from private storage slots into custom error parameters | Medium |
| assert() used for input validation | Pattern matching on assert calls with user-controlled or external inputs | Medium |
| Revert in fallback without data | AST inspection of receive/fallback bodies for bare revert() calls | Low |
| try/catch missing custom error branch | Call graph analysis checking catch completeness against callee ABI | High |
These checks run automatically on every contract submitted to ContractScan. Upload your source code or paste a verified contract address and receive a full report covering error handling patterns alongside the broader vulnerability surface — access control, reentrancy, integer overflow, and more — in under a minute.
Related Posts
- Solidity try/catch Error Handling and Silent Failure Security Patterns
- Access Control Vulnerabilities in Smart Contracts
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.