Thanks to growing demand on the Ethereum network, smart contract developers are continuing to research and implement new methods of reducing transaction costs. In the last year alone, enormous progress has been made to eliminate unnecessary opcode executions as well as optimizing storage in contracts.
While pushing hard for the lowest possible execution costs is breeding creativity and innovation, it is putting some protocols at risk due to unsafe implementations and unforeseen problems.
This post will get you up-to-date with the newest developments and recent zero-day vulnerabilities while also giving a security researcher’s perspective on each.
Earlier this month, user 0xriptide
submitted a vulnerability via Immunefi regarding the Arbitrum bridge smart contract, featuring a bug involving an uninitialized proxy contract due to a vulnerable implementation of postUpgradeInit
. You can see his original post here.
In the below code snippets, the public initialize()
function that is standard in the TransparentUpgradeableProxy
uses the initializer
modifier that checks the first two storage slots to determine whether the function has already been called. That is all well and good, until we get to the “optimized” postUpgradeInit()
function.
function initialize(IBridge _bridge, ISequencerInbox _sequencerInbox)
external
initializer
onlyDelegated
{
bridge = _bridge;
sequencerInbox = _sequencerInbox;
allowListEnabled = false;
__Pausable_init();
}
“
postUpgradeInit()
(as seen below) … wipes slots 0, 1 & 2 and sets the bridge andallowListEnabled
slots to new values — but leavessequencerInbox
and the two booleans set by theintializer
modifier empty!” (0xriptide)
function postUpgradeInit(IBridge _bridge)
external
onlyDelegated
onlyProxyOwner
{
uint8 slotsToWipe = 3;
for (uint8 i = 0; i < slotsToWipe; i++) {
assembly {
sstore(i, 0)
}
}
allowListEnabled = false;
bridge = _bridge;
}
sstore()
and assembly in general are great tools to reduce costs, as the unnecessary checks that the EVM does to ensure execution goes according to plan are not used. As 0xriptide states in his post, the bridge contract was in danger also in part because Arbitrum removed this moot check (seen below) that ensured that the bridge address variable was set in order to presumably save on gas during initialization.
if (address(bridge) != address(0)) revert AlreadyInit();
This next vulnerability was submitted by a number of auditors during OpenSea’s Seaport audit contest on Code4rena. It involves the classic overflowing as well as typecasting; explicitly converting one type to another.
In every case, you must ensure that user input is handled accordingly to avoid truncating, that is in this case - losing data due to mismanaged types. If you aren’t familiar with these terms, follow along with the next exploit.
This vulnerability is located in OrderValidator.sol
, which as the name implies, “contains functionality related to validating orders and updating their status”. More specifically this is in the _validateOrderAndUpdateStatus()
function. It is a very complex function, so I did my best to keep the snippet brief.
struct AdvancedOrder {
OrderParameters parameters;
uint120 numerator;
uint120 denominator;
bytes signature;
bytes extraData;
}
function _validateOrderAndUpdateStatus(
AdvancedOrder memory advancedOrder,
CriteriaResolver[] memory criteriaResolvers,
bool revertOnInvalid,
bytes32[] memory priorOrderHashes
)
internal
...
{
...
OrderParameters memory orderParameters = advancedOrder.parameters;
...
bytes32 orderHash = _assertConsiderationLengthAndGetNoncedOrderHash(orderParameters);
...
uint256 filledNumerator = orderStatus.numerator;
uint256 filledDenominator = orderStatus.denominator;
if (filledDenominator != 0) {
...
unchecked {
_orderStatus[orderHash].isValidated = true;
_orderStatus[orderHash].isCancelled = false;
_orderStatus[orderHash].numerator = uint120(filledNumerator +
numerator);
_orderStatus[orderHash].denominator = uint120(denominator);
}
}
else {
_orderStatus[orderHash].isValidated = true;
_orderStatus[orderHash].isCancelled = false;
_orderStatus[orderHash].numerator = uint120(numerator);
_orderStatus[orderHash].denominator = uint120(denominator);
}
...
}
The issue lies in the fact that the user controlled parameter AdvancedOrder
can be given a numerator
and/or denominator
value that is close to overflow like 2**118
. In turn, when someone wants to fill the order, it does so by cross multiplying and
“[b]ecause … the
uint120
truncation in OrderValidator.sol#L228-L248, thenumerator
anddenominator
are truncated to0
and0
respectively.”(SpearbitDAO)
Ultimately, this small opening allowed for a potential attacker to drain any approved tokens for the same consideration amount.
At first glance, these kinds of issues are truly difficult to understand due to the fact that these protocols are quite complex. It is important to recognize where the most common mistakes are made in order to discover vulnerabilities the developers may have missed. If you want to learn more about this issue, here is the original submission by SpearbitDAO with an included PoC.
// Un-optimized
revert("Unauthorized");
// Yul equivalent
let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(add(free_mem_ptr, 4), 32)
mstore(add(free_mem_ptr, 36), 12)
mstore(add(free_mem_ptr, 68), "Unauthorized")
revert(free_mem_ptr, 100)
// Peak gas savings
revert Unauthorized();
// Yul equivalent
let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x82b4290000000000000000000000000000000000000000000000000000000000)
revert(free_mem_ptr, 4)
Using custom errors is cheaper than a string error on both deployment and execution. Below the example of each is the same thing just in Yul - you can see that the revert(“Unauthorized”)
uses 3 additional of our precious mstore()
s.
Events are a great way to keep track of things that happen on-chain. They are quite efficient in their own right, but one thing you can do to make your events even more optimized that many people do not know is that you can use the indexed
keyword when logging events with values of type uint
, bool
, and/or address
to save on some gas costs.
This next example comes courtesy of the Art Gobblers
project led by the team at Paradigm.
// Un-optimized
event GobblerClaimed(address user, uint256 gobblerId);
...
currentNonLegendaryId++;
gobblerId = currentNonLegendaryId;
emit GobblerClaimed(msg.sender, gobblerId);
// Peak gas savings
event GobblerClaimed(address indexed user, uint256 indexed gobblerId);
...
emit GobblerClaimed(msg.sender, gobblerId = ++currentNonLegendaryId);
One funky thing you may have also noticed in this example is the second parameter of the bottom event emission. Lets take a closer look:
emit GobblerClaimed(msg.sender, gobblerId = ++currentNonLegendaryId)
It is doing 3 things here:
updating gobblerId
’s value
incrementing currentNonLegendaryId
using the updated gobblerId
as the input
// Un-optimized
for (uint256 i = 0; i < 10; i++) {
...
}
// Peak gas savings
for (uint256 i; i < 10;) {
...
unchecked {
++i;
}
}
In every case, using unchecked{ ++i; }
is cheaper due to a few things:
It doesn’t use a default value of 0
for i
, saves 3 gas
per declaration
Using the iterator ++i
( ++i
uses 5 gas
less than i++
)
Using the unchecked{}
block - as it is impossible for the iterator to overflow or underflow. A combination of this and ++i
saves at least 25 gas
, however can be up to 80 gas
under certain conditions
// Un-optomized
string internal constant _NAME = "Berry";
// Peak gas savings
bytes32 internal constant _NAME = "Berry";
Storing string
constants are more expensive than bytes
constants because they are dynamically sized and have certain limitations in the context of the EVM.
If you can fit a string literal into a bytes32
do it - using fixed sized variables are always cheaper. You will find most large protocols practically never use strings because of this exact reason.
When creating functions, it is always best to limit the accessibility to only where the function is actually called from. internal
and private
are both cheaper than public
and external
when called from inside the contract in some cases.
People have noted that public
is more expensive than external
when called, but from my testing in versions solidity >0.8.0
that is not the case ( perhaps in newer versions? ).
When a function without payable
is called, one of the first operations is to ensure msg.value == 0
and will revert the call if any `ether`
is sent with the transaction.
Using the payable
modifier negates that and the contract will accept the transaction regardless of msg.value
. This is notably useful for the constructor
function.
Each instance of the payable
modifier saves 24 gas
over the alternative.
The effort to reduce transaction costs with new techniques can often be like playing with fire. Some people are being burned because they dropped one require()
statement to save 5 gwei
to deploy.
Regardless, security ALWAYS comes first. If you are 99.9% positive that your withdraw()
function is not susceptible to a re-entrancy attack, that’s enough reason to keep your ReentrancyGuard
modifier on your function. Users safety is much more important than saving a couple gwei
.
Stay responsible, keep hunting, and keep learning. Learning small things at a time will yield large results.
You’ll find that crit soon Anon, cheers.