Solidity's try/catch was introduced in 0.6.0 as a way to handle external call failures gracefully. Before it existed, any failed external call would revert the entire transaction unless you used low-level calls with manual return value inspection. try/catch looked like the clean, safe alternative.
It is not. Not as it is typically used.
The mental model most developers apply is imported from JavaScript or Python, where try/catch is a general-purpose error boundary. In Solidity it is a narrow, highly constrained construct with specific failure modes that can leave contracts in corrupted state, silently skip critical operations, or expose attack surfaces that did not exist before.
This article covers six vulnerability classes introduced by try/catch misuse, each observed in production audits, each with a correct pattern.
1. Swallowing Errors Silently (Empty catch Block)
The most dangerous form of try/catch misuse is the empty catch block. It compiles without warnings. It passes naive unit tests. And in production it silently eats failures, allowing execution to continue as if an operation succeeded when it did not.
Vulnerable pattern:
// VULNERABLE: empty catch block discards all failure information
contract VaultRouter {
IExternalVault public vault;
mapping(address => uint256) public credited;
function depositToVault(address user, uint256 amount) external {
IERC20(token).transferFrom(user, address(this), amount);
IERC20(token).approve(address(vault), amount);
try vault.deposit(user, amount) {
credited[user] += amount;
} catch {
// silent: do nothing
}
}
}
When vault.deposit fails for any reason — wrong state, paused contract, insufficient liquidity, gas exhaustion — the catch block runs and does nothing. The caller receives no error. The token has already been transferred in. The credited mapping is not updated, but the funds are now stranded in the router with no recovery path.
An attacker can trigger the vault failure condition deliberately to strand user funds, or observe that any vault failure during a broader flow leaves the system in a half-updated state.
Fix:
// CORRECT: log the failure and propagate or handle explicitly
function depositToVault(address user, uint256 amount) external {
IERC20(token).transferFrom(user, address(this), amount);
IERC20(token).approve(address(vault), amount);
try vault.deposit(user, amount) {
credited[user] += amount;
} catch (bytes memory reason) {
emit DepositFailed(user, amount, reason);
// Refund the user rather than stranding funds
IERC20(token).transfer(user, amount);
revert VaultDepositFailed(reason);
}
}
At minimum, log the failure. In most cases, also revert so the transaction is atomic. If you intend to continue on failure, document the invariant that makes it safe.
2. Catching the Wrong Error Type
Solidity's catch clauses are typed. catch Error(string memory reason) only matches reverts that were triggered with revert("string message") or require(condition, "string message"). It does not catch everything.
Vulnerable pattern:
// VULNERABLE: only catches string reverts, misses custom errors and panics
contract PriceAggregator {
IOracle public oracle;
function getSafePrice(address asset) external view returns (uint256) {
try oracle.getPrice(asset) returns (uint256 price) {
return price;
} catch Error(string memory) {
return fallbackPrice[asset];
}
// What happens with custom errors? assert failures? Out of gas?
// They propagate uncaught and revert the caller.
}
}
The oracle may revert with a custom error: revert StalePrice(block.timestamp, lastUpdated). That is encoded as raw bytes, not a string. The catch Error(string memory) clause does not match it. The error propagates up, reverting the caller — exactly what the developer thought try/catch would prevent.
The same applies to assert() failures (which produce a Panic error with code 0x01), arithmetic overflows (Panic 0x11), and out-of-gas during the external call.
Fix:
// CORRECT: catch all error types with ordered clauses
function getSafePrice(address asset) external view returns (uint256) {
try oracle.getPrice(asset) returns (uint256 price) {
return price;
} catch Error(string memory reason) {
// Catches revert("string") and require(false, "string")
emit OracleError(asset, reason);
return fallbackPrice[asset];
} catch (bytes memory lowLevelData) {
// Catches custom errors, panics, and any other revert data
emit OracleRawError(asset, lowLevelData);
return fallbackPrice[asset];
}
}
Always include a catch (bytes memory) clause as the final catch to handle all possible failures. The typed catch Error clause is useful when you need to inspect the reason string specifically, but it must never be your only catch clause.
3. State Not Rolled Back Inside catch
This is the subtlest of the six vulnerability classes and the one most likely to slip through code review. When a try external call reverts, state changes made inside the external callee are rolled back. But state changes the calling contract made before executing the try statement are not rolled back — they are already committed to the EVM's state trie for this transaction.
Vulnerable pattern:
// VULNERABLE: pre-try state change persists even when external call fails
contract StakingBridge {
mapping(address => uint256) public stakedAmount;
mapping(address => bool) public hasStaked;
function stakeAndRegister(uint256 amount) external {
// State change BEFORE the try block
stakedAmount[msg.sender] += amount;
hasStaked[msg.sender] = true;
IERC20(token).transferFrom(msg.sender, address(this), amount);
try externalRegistry.register(msg.sender, amount) {
// success path
} catch {
emit RegistrationFailed(msg.sender);
// stakedAmount and hasStaked are NOT rolled back here
}
}
}
When externalRegistry.register fails, the catch block runs. The developer expects a partial rollback: the registration did not happen, so the stake should not count. But stakedAmount[msg.sender] and hasStaked[msg.sender] are already written. The user has staked tokens and is flagged as a staker, but is not registered in the external system — a broken invariant that may enable double-counting, reward manipulation, or access control bypass.
Fix:
// CORRECT: apply state changes only after confirming external call success
function stakeAndRegister(uint256 amount) external {
IERC20(token).transferFrom(msg.sender, address(this), amount);
try externalRegistry.register(msg.sender, amount) {
// Apply state changes only on success
stakedAmount[msg.sender] += amount;
hasStaked[msg.sender] = true;
} catch (bytes memory reason) {
// Return tokens since registration failed
IERC20(token).transfer(msg.sender, amount);
revert RegistrationFailed(reason);
}
}
Treat every try/catch as an explicit ordering decision: which state changes must happen before the external call versus which must happen only on success. When in doubt, move state changes inside the success branch.
4. Gas Exhaustion in the catch Block
try/catch does not give you unlimited gas for error recovery. The EVM forwards a portion of remaining gas into the external call. When that call reverts, the caller resumes with whatever gas remains. If the catch block performs expensive operations — storage writes, loops, events with large data — and there is not enough gas left, the catch block itself will out-of-gas revert, propagating the revert all the way up.
Vulnerable pattern:
// VULNERABLE: expensive catch block can exhaust remaining gas
contract BatchProcessor {
address[] public failedRecipients;
function distributeRewards(address[] calldata recipients, uint256 amount) external {
for (uint256 i = 0; i < recipients.length; i++) {
try vault.sendReward(recipients[i], amount) {
emit RewardSent(recipients[i], amount);
} catch {
// Storage write in catch: costs 20,000+ gas
failedRecipients.push(recipients[i]);
// If gas is low here, this push will out-of-gas revert,
// reverting the entire batch including successful sends.
}
}
}
}
An attacker targeting this contract does not need to attack the vault directly. They can ensure the catch block is reached with barely enough gas to revert out of the push, undoing all successful sends in the batch — a gas griefing vector compounded by poor catch block design.
Fix:
// CORRECT: reserve gas before try/catch and keep catch blocks cheap
function distributeRewards(address[] calldata recipients, uint256 amount) external {
for (uint256 i = 0; i < recipients.length; i++) {
// Ensure minimum gas available before each iteration
if (gasleft() < 50_000) break;
try vault.sendReward{gas: 30_000}(recipients[i], amount) {
emit RewardSent(recipients[i], amount);
} catch (bytes memory) {
// Cheap catch: only emit, no storage writes
emit RewardFailed(recipients[i]);
}
}
}
Keep catch blocks cheap: emit events rather than write storage. Enforce minimum gas thresholds before entering try/catch. Use explicit gas limits on the call so remaining gas after failure is predictable.
5. try/catch on Internal Function Calls
try/catch only works on external calls. The Solidity specification is explicit: the expression after try must be either a call to an external contract function or a call using this.functionName(). Using try on a plain internal function is a compile error.
The dangerous variant is when developers work around this by wrapping internal functions in this.wrapper() calls.
Vulnerable pattern:
// VULNERABLE: exposing internal logic as external to enable try/catch
contract Calculator {
function _complexComputation(uint256 input) internal returns (uint256) {
// Sensitive calculation
return input * SCALE_FACTOR / totalSupply();
}
// Made external ONLY to use try/catch — now publicly callable
function computationWrapper(uint256 input) external returns (uint256) {
return _complexComputation(input);
}
function safeCompute(uint256 input) external returns (uint256) {
try this.computationWrapper(input) returns (uint256 result) {
return result;
} catch {
return 0;
}
}
}
computationWrapper is now part of the contract's public interface. Anyone can call it directly. If it modifies state, that state can be manipulated outside the expected flow. If it has access-control requirements, those may not be applied because the function was written only for internal consumption. The attack surface grew purely as an artifact of working around try/catch's constraint.
Fix:
// CORRECT: use low-level staticcall/call for revert-safe internal-like calls,
// or restructure to validate preconditions before executing
function safeCompute(uint256 input) external returns (uint256) {
// Validate preconditions so you know the computation will not revert
if (totalSupply() == 0) return 0;
if (input == 0) return 0;
// Now call directly — no try/catch needed when preconditions are validated
return _complexComputation(input);
}
Do not expose internal functions to enable try/catch. Validate preconditions before the call, or use low-level call with manual return data decoding if you need to capture reverts from your own logic.
6. Assuming try/catch Prevents All Reverts
The final class of vulnerability is a conceptual one: believing that wrapping a call in try/catch makes it fully safe regardless of what the callee does. Two specific failure modes break this assumption.
The first is the 2300 gas stipend trap. If you call a contract that forwards only 2300 gas (as .transfer() and .send() do), and that contract has any logic beyond a simple receive, it will out-of-gas revert. The try/catch captures this — but catch block cleanup is still constrained by the remaining gas, which may itself be nearly zero.
The second is the return data copy attack. When an external call reverts, Solidity automatically copies the revert data into memory. An attacker controlling the callee can return a massive amount of data on revert. Copying it costs gas proportional to size. If large enough, copying it in the catch clause consumes all remaining gas, causing an out-of-gas revert in the caller — defeating the entire purpose of try/catch.
Vulnerable pattern:
// VULNERABLE: unrestricted returndata copy can exhaust caller gas
contract Exchange {
function fillOrder(address counterparty, Order calldata order) external {
try ICounterparty(counterparty).executeOrder(order)
returns (uint256 filled)
{
_settleOrder(order, filled);
} catch (bytes memory reason) {
// 'reason' may be megabytes of attacker-controlled data
// Copying it already spent enormous gas
emit OrderFailed(order.id, reason);
}
}
}
A malicious counterparty returns 500KB of data on revert. The EVM copies all of it into the reason variable. The copy costs approximately 500,000 gas. The entire transaction out-of-gas reverts in the catch block. The caller's transaction is failed, potentially griefing a batch that had other valid orders.
Fix:
// CORRECT: use assembly to limit returndata copy size
function fillOrder(address counterparty, Order calldata order) external {
(bool success, ) = address(counterparty).call{gas: 100_000}(
abi.encodeWithSelector(ICounterparty.executeOrder.selector, order)
);
if (!success) {
// Cap returndata copy at 256 bytes to prevent gas exhaustion
bytes memory reason = new bytes(256);
assembly {
let size := returndatasize()
if gt(size, 256) { size := 256 }
returndatacopy(add(reason, 32), 0, size)
mstore(reason, size)
}
emit OrderFailed(order.id, reason);
return;
}
uint256 filled = abi.decode(/* returndata */, (uint256));
_settleOrder(order, filled);
}
When dealing with untrusted callees, prefer low-level call with explicit gas limits over try/catch, and cap the return data you copy into memory.
What ContractScan Detects
ContractScan applies static and semantic analysis to Solidity source to surface these patterns before they reach production.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Empty catch block | AST pattern: catch clause with empty body | High |
| Incomplete catch type coverage | AST: try with only catch Error and no catch (bytes) |
Medium |
| Pre-try state changes not rolled back on failure | Data flow: storage writes before try that are not reverted in catch | High |
| Expensive catch block logic | Gas estimation: catch body exceeds threshold gas cost | Medium |
| Internal function exposed for try/catch use | Call graph: this.X() in try where X is only called via this |
Medium |
| Uncapped returndata copy in catch | Pattern match: bytes memory catch variable used without size check |
High |
Related Posts
- Gas Griefing Attacks: Solidity Out-of-Gas DoS Vulnerabilities
- DeFi Composability Security: Integration Attack Surfaces When Protocols Call Each Other
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.