Miniblog #1: Callbacks, try/catch and 63/64 rule make an interesting combo
I'm thesvn, a newcomer documenting my journey into smart contract security research. These articles share my learning process, insights, and potential misconceptions as I explore this complex field.
This vulnerability is a fascinating one that caught my attention when I first encountered it.
I got to know about this class of vulnerability through a report I was reading on Codehawks: The caller of withdraw and renounce can skip callbacks, by sending less gas
If you're new to smart contract security, you might want to familiarize yourself with a few key terms to understand this vulnerability better. I will try explaining them in simple words here:
63/64 rule:
This rule was introduced in the EIP-150 (Ethereum Improvement Proposal 150). When a function calls an external function, only (63/64 * gas remaining) is transferred for the called function's operations.
If you want to understand more about why this was implemented, check this article: EIP-150 and the 63/64 Rule for Gas
Try/catch
This is a mechanism for handling errors. Try/catch is like telling your smart contract, "Give this code a shot, but if it backfires, use the backup plan." It's your contract's safety net for when things go wrong.
Here’s the blog (from b0g0) about try/catch if you want to know more about it: Auditing the try/catch gotchas of solidity smart contracts
Callbacks
Claude says “Callbacks are functions that are passed as arguments to other functions and are executed after the completion of that function”. But in this bug report, the functions are not exactly passed as arguments to other functions, however, the functions are executed after the completion of a function. So they are considered to be “callbacks” in that sense.
So now that we have looked at the important terms needed to understand the bug, let’s move on. Consider the withdraw
function:
function withdraw(
uint256 streamId,
address to,
uint128 amount
)
public
override
noDelegateCall
notNull(streamId)
updateMetadata(streamId)
{
// Check: the stream is not depleted.
if (_streams[streamId].isDepleted) {
revert Errors.SablierV2Lockup_StreamDepleted(streamId);
}
// Check: the withdrawal address is not zero.
if (to == address(0)) {
revert Errors.SablierV2Lockup_WithdrawToZeroAddress(streamId);
}
// Check: the withdraw amount is not zero.
if (amount == 0) {
revert Errors.SablierV2Lockup_WithdrawAmountZero(streamId);
}
// Retrieve the recipient from storage.
address recipient = _ownerOf(streamId);
// Check: if `msg.sender` is neither the stream's recipient nor an approved third party, the withdrawal address
// must be the recipient.
if (to != recipient && !_isCallerStreamRecipientOrApproved(streamId)) {
revert Errors.SablierV2Lockup_WithdrawalAddressNotRecipient(streamId, msg.sender, to);
}
// Check: the withdraw amount is not greater than the withdrawable amount.
uint128 withdrawableAmount = _withdrawableAmountOf(streamId);
if (amount > withdrawableAmount) {
revert Errors.SablierV2Lockup_Overdraw(streamId, amount, withdrawableAmount);
}
// Retrieve the sender from storage.
address sender = _streams[streamId].sender;
// Effects and Interactions: make the withdrawal.
_withdraw(streamId, to, amount);
// Interaction: if `msg.sender` is not the recipient and the recipient is a contract, try to invoke the
// withdraw hook on it without reverting if the hook is not implemented, and also without bubbling up
// any potential revert.
if (msg.sender != recipient && recipient.code.length > 0) {
try ISablierV2Recipient(recipient).onLockupStreamWithdrawn({
streamId: streamId,
caller: msg.sender,
to: to,
amount: amount
}) { } catch { }
}
// Interaction: if `msg.sender` is not the sender, the sender is a contract and is different from the
// recipient, try to invoke the withdraw hook on it without reverting if the hook is not implemented, and also
// without bubbling up any potential revert.
if (msg.sender != sender && sender.code.length > 0 && sender != recipient) {
try ISablierV2Sender(sender).onLockupStreamWithdrawn({
streamId: streamId,
caller: msg.sender,
to: to,
amount: amount
}) { } catch { }
}
}
The following piece of code is where things get interesting:
if (msg.sender != sender && sender.code.length > 0 && sender != recipient) {
try ISablierV2Sender(sender).onLockupStreamWithdrawn({
streamId: streamId,
caller: msg.sender,
to: to,
amount: amount
}) { } catch { }
Usually, when a callback doesn’t have enough gas to be completely executed it reverts with an out-of-gas error. This behavior usually ensures that all parts of a transaction are executed fully or not at all.
However, this vulnerability exploits an interplay between gas limits, the try/catch mechanism, and the 63/64 rule. Here's how it works:
A user can call the
withdraw
function with an exact amount of gas they specify.If the user sends an amount of gas insufficient to complete the callback's complex operations, one might expect the entire transaction to revert.
However, because the callback is wrapped in a try/catch block, it behaves differently. When the callback reverts due to insufficient gas, instead of causing the entire transaction to fail, it just enters the empty catch block.
Thanks to the 63/64 rule, there's still enough gas left to complete the main transaction, even though the callback failed. This means the transaction doesn't revert completely.
As a result, although the critical logic in the callback hasn't been fully implemented, the
withdraw
function completes and a state change occurs.
And voila, a great vulnerability has been cooked!
Disclaimer
This mini blog series documents my learning journey in smart contract security research. While I strive for accuracy, it's important to note that:
I am new to this field and still learning.
My understanding may/will contain gaps or inaccuracies.
The content presented here should not be taken as authoritative or used as a sole source of information.
This series is meant to share my experiences and insights as I progress.
I encourage readers to verify information independently and consult established resources in the field of smart contract security. My goal is to document my journey publicly, and I welcome constructive feedback that can help improve my understanding.
Subscribe to my newsletter
Read articles from thesvn directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by