Solidity Inline Assembly Vulnerabilities
Using assembly to save gas can introduce memory corruption and other subtle vulnerabilities..
Table of contents
- Prerequisite Knowledge
- Memory Corruption From External Call
- Assuming Unchanged Free Memory Pointer Address
- Memory Corruption Due To Insufficient Allocation
- External Call To Non-Existent Contract
- Overflow / Underflow During Inline Assembly
- Uint128 Overflow Evades Detection As In-Line Assembly Uses 256 Bits
- Additional Resources
Smart contract developers working on Ethereum mainnet use assembly instead of regular Solidity to save gas thereby reducing transaction fees for users. However using assembly instead of normal Solidity can introduce subtle memory corruption issues and result in unexpected behavior that smart contract auditors and developers should be aware of.
Prerequisite Knowledge
In order to understand this deep dive you'll need to have completed at least Section 1 of Updraft's Assembly & Formal Verification course or already have equivalent knowledge of:
Memory Corruption From External Call
The return value of an external call is stored into memory at the Next Free Memory Address (NFMA), which is retrieved by reading memory at the Free Memory Pointer Address(FMPA - 0x40). If the smart contract developer has not accounted for this, when an external call occurs after an assembly block the return value of the external call can over-write data stored in memory by the preceding assembly block. This can result in insidious vulnerabilities if that data is used by subsequent calculations.
To see exactly how this memory corruption occurs examine this simplified stand-alone Foundry example based upon this real-world finding:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
// separate contracts accessed via interfaces
// prevents the optimizer from being "too smart", helping
// to better approximate real-world execution
interface IInfoRetriever {
function getVal() external returns(uint256);
}
// this contract gets used to return additional
// values required by the primary computation
contract InfoRetriever is IInfoRetriever {
uint256 returnVal = 3;
function setVal(uint256 newVal) public {
returnVal = newVal;
}
function getVal() external view returns(uint256) {
return returnVal;
}
}
interface IHasher {
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result);
}
// actual implementation that computes a hash of
// two primary inputs and an optional number of
// secondary inputs
contract HasherImpl is IHasher {
// used to retrieve optional number of secondary inputs
IInfoRetriever infoRetriever;
constructor(IInfoRetriever _infoRetriever) {
infoRetriever = _infoRetriever;
}
function _getSecondaryInputs(uint256 dataPtr, uint256 inputsToFetch) private returns(uint256) {
for(uint256 i; i<inputsToFetch; i++) {
// make external call to retrieve the data
uint256 input = infoRetriever.getVal();
assembly {
// for each additional input, store it into memory
// at next free memory address
mstore(dataPtr, input)
// update pointer to subsequent next free memory address
dataPtr := add(dataPtr, 0x20)
}
}
// returns updated dataPtr
return dataPtr;
}
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result) {
uint256 dataPtr;
assembly {
// read free memory pointer to find next free memory address
dataPtr := mload(0x40)
// store `a` in memory at next free memory address
mstore(dataPtr, a)
// update pointer to subsequent next free memory address
dataPtr := add(dataPtr, 0x20)
// store `b` in memory at next free memory address
mstore(dataPtr, b)
// update pointer to subsequent next free memory address
dataPtr := add(dataPtr, 0x20)
}
// get the additional input from the info retriever, passing the updated
// `dataPtr` so any new elements will be saved in subsequent free memory
// addresses
dataPtr = _getSecondaryInputs(dataPtr, additionalInputCount);
// compute hash of all inputs
assembly {
let startPtr := mload(0x40)
result := keccak256(startPtr, sub(dataPtr, startPtr))
}
}
}
// test harness
contract HasherTest is Test {
IInfoRetriever infoRetriever = new InfoRetriever();
IHasher hasher = new HasherImpl(infoRetriever);
uint256 a = 1;
uint256 b = 2;
function test_HashWithoutAdditionalInput() external {
// works great
bytes32 result = hasher.hashInputs(a, b, 0);
assertEq(result, keccak256(abi.encode(1, 2)));
}
function test_HashWithAdditionalInput() external {
// fails due to calculating incorrect hash
bytes32 result = hasher.hashInputs(a, b, 1);
assertEq(result, keccak256(abi.encode(1, 2, 3)));
}
}
This simplified example contains a contract HasherImpl
whose purpose is to:
receive a set of primary inputs (
a, b
)optionally retrieve a set of secondary inputs
calculate a hash over the entire set of primary and secondary inputs
HasherImpl
uses assembly to:
correctly calculate Next Free Memory Addresses (NFMA)
correctly store both primary and secondary inputs into NFMAs
perform a gas-efficient hash over the entire input set
When no secondary inputs exist everything works correctly which can be verified by forge test --match-contract HasherTest --match-test test_HashWithoutAdditionalInput
However when using one or more secondary inputs the relevant test test_HashWithAdditionalInput
fails as an incorrect hash is calculated. This occurs for two reasons - let's examine the first cause in detail!
Manual Assembly Analysis
Examining the assembly code superficially everything looks great; each primary and secondary input is saved into the NFMA and dataPtr
is always updated to point to the next NFMA such that no variables will over-write each-other.
However when secondary inputs are retrieved a very subtle bug occurs due to the external call(s) to IInfoRetriever::getVal
since:
the manual assembly never updated the FMPA (0x40) and stored the first input
a
at the Starting Next Free Memory Address (SNFMA - 0x80)when setting up for the external call, the FMPA will be loaded from memory and a temporary value used in the external call will be stored at the address it points to (0x80)
when the external call completes, the FMPA will again be loaded from memory and the return value stored at the address it points to (0x80)
this causes memory corruption at 0x80, over-writing the input variable
a
both before and after the external call
Hence the hash will never execute over the entire input set since at least one of the inputs was over-written, resulting in an incorrect hash being calculated. Let's prove this is exactly what happens using Foundry's debugger to step through every opcode and see the exact moment when memory corruption occurs!
Opcode Execution Trace
To start the debugger execute forge test --match-contract HasherTest --debug test_HashWithAdditionalInput
. We are concerned with the execution inside HasherImpl::hashInputs
:
from the first
PUSH1(0x40)
after calldata has been loaded onto the stack andJUMPDEST
has been calleduntil the memory corruption occurs
The relevant execution starts at PC 0x56 (86) and looks like this:
// push Free Memory Pointer Address (FMPA) onto stack
PUSH1(0x40) [Stack : 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// duplicate FMPA
DUP1 [Stack : 0x40, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// load Starting Next Free Memory Address (SNFMA) by reading FMPA
MLOAD [Stack : 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// duplicate value of `a` input
DUP5 [Stack : 0x01, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// duplicate SNFMA
DUP2 [Stack : 0x80, 0x01, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// store value of `a` into memory at SNFMA
MSTORE [Stack : 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// push 0x20 onto stack (2nd param of first `add` call)
// this is an offset to calculate Next Free Memory Address (NFMA)
PUSH1(0x20) [Stack : 0x20, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// duplicate SNFMA
DUP2 [Stack : 0x80, 0x20, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// calculate NFMA by SNFMA + offset (0x80 + 0x20)
ADD [Stack : 0xa0, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// duplicate value of `b` input
DUP5 [Stack : 0x02, 0xa0, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// exchange 2nd and 1st elements
SWAP1 [Stack : 0xa0, 0x02, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// store value of `b` into memory at NFMA
MSTORE [Stack : 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// not sure what the purpose of this is?
PUSH1(0x00) [Stack : 0x00, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// exchange 3rd and 1st stack elements
SWAP2 [Stack : 0x40, 0x80, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// calculate NFMA by SNFMA + offset (0x80 + 0x40)
ADD [Stack : 0xc0, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// `a` and `b` primary inputs have been written to memory
// and the next free memory address 0xc0 has been calculated and
// on the stack. We skip opcodes concerned with calling internal
// function `getSecondaryInputs` and evaluating `for` loop condition
// until we reach PC 0xc2 (194) which is setting up for the external
// call
DUP2 [Stack : 0xe1cb0e52..00, 0x80, 0xe1cb0e52..00, 0x5616..b72f, 0x00, 0x00, 0x00, 0x01, 0xc0, 0x71, 0xc0, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// before making external call the value of `a` at 0x80 will be
// over-written - memory corruption first occurs before external call!
MSTORE [Stack : 0xe1cb0e52..00, 0x5616..b72f, 0x00, 0x00, 0x00, 0x01, 0xc0, 0x71, 0xc0, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80,
0x80 = 0xe1cb0e52..00, // `a` over-written
0xa0 = 0x02]
// we then move forward into `InfoRetriever::getVal` until `RETURN`
// takes us back into `HasherImpl::_getSecondaryInputs` PC 0x0d5 (213),
// where we observe that 0x80 now contains the return value `3` from
// the external call
ISZERO [Stack : 0x84, 0xe1cb0e52..00, 0x5616..b72f, 0x00, 0x00, 0x00, 0x01, 0xc0, 0x71, 0xc0, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80,
0x80 = 0x03, // `a` over-written again
0xa0 = 0x02]
// manual assembly to store `3` gets executed at PC 0x10b (267)
// which stores the return value of the external call at 0xc0.
// however the return value `3` was also stored at 0x80 where `a` was
// previously stored so the correct hash can no longer be computed.
// also note that solidity has updated the FMPA 0x40 to point to
// 0xa0 which is where we are storing the value of variable `b` meaning
// that the second input variable could also be over-written
MSTORE [Memory: 0x40 = 0xa0, // FMPA updated to where `b` stored!
0x80 = 0x03, // `a` remains over-written
0xa0 = 0x02, // `b` correct
0xc0 = 0x03 // manual assembly stores return val]
The above opcode trace shows:
the value of input variable
a
stored in memory at 0x80 gets over-written twice; once before and once after the external callSolidity has updated the FMPA to point to 0xa0 which is where the manual assembly is storing the value of input variable
b
- this makes it possible for future operations to also over-write the second input variableit is impossible to now calculate the correct hash as at least one input variable
a
has been over-written due to memory corruption caused by the manual assembly not accounting for how Solidity uses memory during the external function call
To fix this problem we need to modify the first block in our manual assembly to update the Free Memory Pointer Address (FMPA) to point to the Next Free Memory Address (NFMA) like so:
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result) {
uint256 dataPtr;
assembly {
// read free memory pointer to find next free memory address
dataPtr := mload(0x40)
// store `a` in memory at next free memory address
mstore(dataPtr, a)
// update pointer to subsequent next free memory address
dataPtr := add(dataPtr, 0x20)
// store `b` in memory at next free memory address
mstore(dataPtr, b)
// update pointer to subsequent next free memory address
dataPtr := add(dataPtr, 0x20)
// @audit this prevents the external call in `_getSecondaryInputs`
// from over-writing value of `a` stored at 0x80
//
// update free memory pointer to point to next free memory address
mstore(0x40, dataPtr)
}
Making this change then re-running the Foundry debugger shows at PC 0x110 (272):
the return value of the external call is stored in memory at 0xc0 with both primary and secondary input variables having been correctly stored in memory
the FMPA 0x40 points to unused 0xe0 such that any new values stored in memory won't over-write the primary and secondary input variables required for the hash calculation
MSTORE [Memory: 0x40 = 0xe0, // FMPA updated to unused value
0x80 = 0x01, // `a` correct
0xa0 = 0x02, // `b` correct
0xc0 = 0x03] // returned secondary input correct
However this is not enough as another subtle bug lies in wait to corrupt our hash calculation!
Assuming Unchanged Free Memory Pointer Address
Another subtle problem when mixing blocks of manual assembly with normal Solidity code is when the manual assembly code assumes that the Free Memory Pointer Address (FMPA) hasn't changed simply because it hasn't changed it - this is a dangerous assumption because the normal Solidity code in between manual assembly blocks could have updated the FMPA.
In the previous opcode execution trace we observed that:
even before making our fix for the identified bug, Solidity updated the FMPA after returning the secondary input value from the external call
our fix required a manual update to the FMPA before the external call
Any update to the FMPA is problematic for our code because the manual assembly block which calculates the hash:
reads the Next Free Memory Address (NFMA) from the current FMPA
assumes that the inputs to be hashed start at that NFMA
// compute hash of all inputs
assembly {
// @audit assumes FMPA unchanged from first assembly block
// where primary inputs are stored my first loading FMPA
let startPtr := mload(0x40)
result := keccak256(startPtr, sub(dataPtr, startPtr))
}
Hence once the external function which fetches the secondary inputs executes and the FMPA has been updated, the hashing code will never correctly hash the appropriate inputs even if memory corruption didn't occur. We can observe this in the opcode execution traces, firstly for the original version without our first fix starting from PC 0x079 (121):
// exchange 2nd and 1st stack elements
SWAP1 [Stack : 0xa0, 0x40, ......]
[Memory: 0x40 = 0xa0,
0x80 = 0x03, // value of 'a' corrupted by ext func
0xa0 = 0x02,
0xc0 = 0x03]
// call keccak256(offset, size)
// this will hash memory 0xa0 and 0xc0
// returning keccak256(abi.encode(2,3))
KECCAK256(0xa0, 0x40)
Secondly the same error occurs in our "fixed" version where the memory corruption issue has been resolved starting from PC 0x7e (126):
// exchange 2nd and 1st stack elements
SWAP1 [Stack : 0xe0, 0x00, ......]
[Memory: 0x40 = 0xe0,
0x80 = 0x01, // no memory corruption
0xa0 = 0x02,
0xc0 = 0x03]
// call keccak256(offset, size)
// this will hash empty memory since now FMPA points to unused memory
KECCAK256(0xa0, 0x00)
To resolve the second issue:
the first manual assembly block needs to save the starting memory address for where the input variables are stored
before the external function call the FMPA needs to be updated to an unused address (our first fix)
the final assembly block needs to use the saved starting memory address to calculate the offset and size parameters passed to
keccak256
The full fixed source code contains @audit
tags to indicate all changes made:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// separate contracts accessed via interfaces
// prevents the optimizer from being "too smart", helping
// to better approximate real-world execution
interface IInfoRetriever {
function getVal() external returns(uint256);
}
// this contract gets used to return additional
// values required by the primary computation
contract InfoRetriever is IInfoRetriever {
uint256 returnVal = 3;
function setVal(uint256 newVal) public {
returnVal = newVal;
}
function getVal() external view returns(uint256) {
return returnVal;
}
}
interface IHasher {
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result);
}
// actual implementation that computes a hash of
// two primary inputs and an optional number of
// secondary inputs
contract HasherImpl is IHasher {
// used to retrieve optional number of secondary inputs
IInfoRetriever infoRetriever;
constructor(IInfoRetriever _infoRetriever) {
infoRetriever = _infoRetriever;
}
function _getSecondaryInputs(uint256 dataPtr, uint256 inputsToFetch) private returns(uint256) {
for(uint256 i; i<inputsToFetch; i++) {
// @audit once this external call completes it will:
// - read the free memory pointer to find next free memory address
// which will be 0x80 as we never updated it since we are manually
// allocating memory
// - write the result of the external call to this memory address
// - but this is where the input variable `a` was saved!
// - hence `a` gets subtly over-written by the return
// value of this external call which results in the hash calculation
// becoming corrupted!
//
// make external call to retrieve the data
uint256 input = infoRetriever.getVal();
assembly {
// for each additional input, store it into memory
// at next free memory address
mstore(dataPtr, input)
// update pointer to subsequent next free memory address
dataPtr := add(dataPtr, 0x20)
}
}
// returns updated dataPtr
return dataPtr;
}
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result) {
uint256 dataPtr;
// @audit used to hash correct memory addresses at the end
uint256 startDataPtr;
assembly {
// read free memory pointer to find next free memory address
dataPtr := mload(0x40)
// @audit save starting address where we save our variables
// as 0x40 gets overwritten later
startDataPtr := dataPtr
// store `a` in memory at next free memory address
mstore(dataPtr, a)
// update pointer to subsequent next free memory address
dataPtr := add(dataPtr, 0x20)
// store `b` in memory at next free memory address
mstore(dataPtr, b)
// update pointer to subsequent next free memory address
dataPtr := add(dataPtr, 0x20)
// @audit this prevents the external call in `_getSecondaryInputs` from
// over-writing value of `a` stored at 0x80
//
// update free memory pointer to point to next free memory address
mstore(0x40, dataPtr)
}
// get the additional input from the info retriever, passing the updated
// `dataPtr` so any new elements will be saved in subsequent free memory
// addresses
dataPtr = _getSecondaryInputs(dataPtr, additionalInputCount);
// compute hash of all inputs
assembly {
// @audit using saved starting memory address
result := keccak256(startDataPtr, sub(dataPtr, startDataPtr))
}
}
}
// test harness
contract HasherTest is Test {
IInfoRetriever infoRetriever = new InfoRetriever();
IHasher hasher = new HasherImpl(infoRetriever);
uint256 a = 1;
uint256 b = 2;
function test_HashWithoutAdditionalInput() external {
// works great
bytes32 result = hasher.hashInputs(a, b, 0);
assertEq(result, keccak256(abi.encode(1, 2)));
}
function test_HashWithAdditionalInput() external {
// now also works
bytes32 result = hasher.hashInputs(a, b, 1);
assertEq(result, keccak256(abi.encode(1, 2, 3)));
// also works for more than 1 secondary input
result = hasher.hashInputs(a, b, 3);
assertEq(result, keccak256(abi.encode(1, 2, 3, 3, 3)));
}
}
Verify that both tests pass using forge test --match-contract HasherTest
.
Memory Corruption Due To Insufficient Allocation
Another subtle memory corruption issue that can occur is due to insufficient allocation, a subsequent memory variable can become corrupted when the variable before it in memory is updated. This is likely to occur if there is an asymmetry between functions which allocate capacity and functions which write. Consider this real-world insufficient allocation memory corruption from Ethereum Name Service (ENS) smart contract audit which I've simplified into the following stand-alone Foundry test harness:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// finding : https://solodit.xyz/issues/memory-corruption-in-buffer-fixed-consensys-ens-permanent-registrar-markdown
// code from : https://github.com/ensdomains/buffer/blob/c06d796e2f6086473d229a96c2eb75053a19b8ec/contracts/Buffer.sol
library Buffer {
struct buffer {
bytes buf;
uint capacity;
}
function init(buffer memory buf, uint capacity) internal pure returns(buffer memory) {
if (capacity % 32 != 0) {
capacity += 32 - (capacity % 32);
}
// Allocate space for the buffer data
buf.capacity = capacity;
assembly {
let ptr := mload(0x40)
mstore(buf, ptr)
mstore(ptr, 0)
// @audit does not account for length 0x20 (32),
// even though the `write` function does
mstore(0x40, add(ptr, capacity))
// @audit fix:
//mstore(0x40, add(32, add(ptr, capacity)))
}
return buf;
}
function append(buffer memory buf, bytes memory data) internal pure returns (buffer memory) {
return write(buf, buf.buf.length, bytes32(data), data.length);
}
function write(buffer memory buf, uint off, bytes32 data, uint len) private pure returns(buffer memory) {
if (len + off > buf.capacity) {
resize(buf, max(buf.capacity, len) * 2);
}
uint mask = 256 ** len - 1;
// Right-align data
data = data >> (8 * (32 - len));
assembly {
// Memory address of the buffer data
let bufptr := mload(buf)
// Address = buffer address + sizeof(buffer length) + off + len
let dest := add(add(bufptr, off), len)
mstore(dest, or(and(mload(dest), not(mask)), data))
// Update buffer length if we extended it
if gt(add(off, len), mload(bufptr)) {
mstore(bufptr, add(off, len))
}
}
return buf;
}
function resize(buffer memory buf, uint capacity) private pure {
bytes memory oldbuf = buf.buf;
init(buf, capacity);
append(buf, oldbuf);
}
function max(uint a, uint b) private pure returns(uint) {
if (a > b) {
return a;
}
return b;
}
}
// test harness
contract BufferTest is Test {
using Buffer for Buffer.buffer;
function test_MemoryCorruption() external {
Buffer.buffer memory buffer;
buffer.init(1);
// `foo` immediately follows buffer.buf in memory
bytes memory foo = new bytes(0x01);
// sanity check passes
assert(1 == foo.length);
// append "A" 0x41 (65) to buffer. This gets written to
// the high order byte of `foo.length`!
buffer.append("A");
// foo.length == 0x4100000000000000000000000000000000000000000000000000000000000001
// == 29400335157912315244266070412362164103369332044010299463143527189509193072641
// this passes showing the memory corruption
assertEq(29400335157912315244266070412362164103369332044010299463143527189509193072641,
foo.length);
}
}
Run the test in Foundry's debugger using forge test --match-contract BufferTest --debug test_MemoryCorruption
and let's examine how this memory corruption happens:
// PC 0x9b6 (2486) executing this line in `Buffer::init`:
// mstore(0x40, add(ptr, capacity))
// updates the Free Memory Pointer Address (FMPA) to 0x120
MSTORE(0x40, 0x120)
Memory: [0x40 = 0x120, // FMPA updated
0x80 = 0x100,
0xa0 = 0x20,
0xc0 = 0x60]
// PC 0x697 (1687) executing this line in `BufferTest::test_Mem...`
// bytes memory foo = new bytes(0x01);
// writes `foo.length` to 0x120
MSTORE(0x120, 0x01)
Memory: [0x40 = 0x120,
0x80 = 0x100,
0xa0 = 0x20,
0xc0 = 0x60,
0x120 = 0x01] // foo.length
// PC 0xbad (2989) executing this line in `Buffer::write`
// mstore(dest, or(and(mload(dest), not(mask)), data))
// over-writes high-order byte of `foo.length`
MSTORE(0x101, 0x41)
Memory: [0x40 = 0x120,
0x80 = 0x100,
0xa0 = 0x20,
0xc0 = 0x60,
0x120 = 0x4100..01] // foo.length memory corrupted
Applying the suggested fix results in foo.length
being written to 0x140 which prevents the memory corruption. Similar findings: [1, 2]
External Call To Non-Existent Contract
When using low-level call
, a call to an address without code deployed is always successful. Consider the following code based upon this real-world mainnet finding:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// external smart contract wallet which verifies a wallet's signature
// for a given hash
interface IWallet {
function isValidSignature(bytes32 _hash, bytes calldata signature) external returns (bool);
}
// separate contracts accessed via interfaces
// prevents the optimizer from being "too smart", helping
// to better approximate real-world execution
interface IWalletVerifier {
function isValidWalletSignature(bytes32 _hash, address walletAddress, bytes memory signature)
external view returns(bool);
}
// implements vulnerable function used to make external call
contract WalletVerifier is IWalletVerifier {
function isValidWalletSignature(bytes32 _hash, address walletAddress, bytes calldata signature)
external view returns (bool isValid) {
bytes memory callInput = abi.encodeWithSelector(
IWallet(walletAddress).isValidSignature.selector,
_hash,
signature
);
assembly {
let cdStart := add(callInput, 32)
let success := staticcall(
gas(), // forward all gas
walletAddress, // address of Wallet contract
cdStart, // pointer to start of input
mload(callInput), // length of input
cdStart, // write output over input
32 // output size is 32 bytes
)
switch success
case 0 {
// Revert with `Error("WALLET_ERROR")`
/* snip */
revert(0, 100)
}
case 1 {
// Signature is valid if call did not revert and returned true
isValid := mload(cdStart)
}
}
return isValid;
}
}
// test harness
contract CallTest is Test {
IWalletVerifier verifier = new WalletVerifier();
function test_EOAAddressCallVerifiesInvalidSig() external view {
bytes32 emptyHash;
bytes memory emptySignature;
bool returnVal = verifier.isValidWalletSignature(emptyHash, address(0x1234), emptySignature);
assert(returnVal);
}
}
The function WalletVerifier::isValidWalletSignature
verifies a signature by calling an external wallet smart contract. But if this function is called passing the address of an Externally Owned Account (EOA - an ordinary user wallet) then it will always return true as:
the
staticcall
towalletAddress
will always return 1 for EOA addressesthe
staticcall
specifies the output from the external call should be written over the input memoryin this case there is no external output hence the inputs will not be overwritten
hence the
mload
will return valid data which will be converted intotrue
resulting in the return variableisValid
being set totrue
This would allow an invalid signature to be verified for EOA accounts. To resolve this vulnerability when using low-level calls:
- before making the external call, the size of the contract should be checked to ensure it has code:
if iszero(extcodesize(walletAddress)) {
// Revert with `Error("WALLET_ERROR")`
revert(0, 100)
}
- if the external call was successful, the length of the returned data should be verified to match the expected length:
if iszero(eq(returndatasize(), 32)) {
// Revert with `Error("WALLET_ERROR")`
revert(0, 100)
}
Overflow / Underflow During Inline Assembly
Using inline assembly for opcodes such as add
and mult
does not offer any overflow/underflow protection which normal Solidity provides. Consider the following simplified stand-alone example based on this unique audit contest finding:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// separate contracts accessed via interfaces
// prevents the optimizer from being "too smart", helping
// to better approximate real-world execution
interface IDexPair {
function getSwapQuote(uint256 amountToSwap) external view returns(uint256);
}
// represents a decentralized exchange pair between
// two assets
contract DexPair is IDexPair {
// given an amount of input tokens
// always return +1 output tokens
function getSwapQuote(uint256 amountToSwap) external view returns(uint256 outputTokens) {
assembly {
outputTokens := add(amountToSwap, 1)
}
}
}
// test harness
contract OverflowTest is Test {
IDexPair dexPair = new DexPair();
function test_SwapQuoteOverflow() external {
// correct: swapping 1 token returns 2 tokens
assertEq(2, dexPair.getSwapQuote(1));
// incorrect: swapping max returns 0 due to overflow
assertEq(0, dexPair.getSwapQuote(type(uint256).max));
}
}
In order to safeguard against overflow when using add
inline assembly one option is to manually check that the result is not smaller than the input:
function getSwapQuote(uint256 amountToSwap) external view returns(uint256 outputTokens) {
assembly {
outputTokens := add(amountToSwap, 1)
// detect overflow if outputTokens < amountToSwap
if lt(outputTokens, amountToSwap) { revert(0, 0) }
}
}
Appropriate overflow/underflow checks also need to be implemented for multiplication and subtraction when using inline assembly.
Uint128 Overflow Evades Detection As In-Line Assembly Uses 256 Bits
Another similar yet more subtle overflow vulnerability can exist when instead of using uint256
the previous example solidity code uses uint128
or smaller - the recommended overflow check mitigation fails to capture this overflow! Consider the following simplified stand-alone example based upon this actual audit finding:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// separate contracts accessed via interfaces
// prevents the optimizer from being "too smart", helping
// to better approximate real-world execution
interface IDexPair {
function getSwapQuoteUint128(uint128 amountToSwap) external view returns(uint128 outputTokens);
}
// represents a decentralized exchange pair between
// two assets
contract DexPair is IDexPair {
// given an amount of input tokens always return +1 output tokens
// capping max input to uint128 and using explicit code to
// detect overflow
function getSwapQuoteUint128(uint128 amountToSwap) external view returns(uint128 outputTokens) {
assembly {
outputTokens := add(amountToSwap, 1)
// detect overflow if outputTokens < amountToSwap
if lt(outputTokens, amountToSwap) { revert(0, 0) }
}
}
}
// test harness
contract OverflowTest is Test {
IDexPair dexPair = new DexPair();
function test_SwapQuoteUint128Overflow() external {
// correct: swapping 1 token returns 2 tokens
assertEq(2, dexPair.getSwapQuoteUint128(1));
// incorrect: swapping max uint128 returns 0 due to overflow
// assembly overflow check fails to capture this!
assertEq(0, dexPair.getSwapQuoteUint128(type(uint128).max));
}
}
Even though DexPair::getSwapQuoteUint128
restricts both its input and output parameters to uint128
, the in-line assembly always operates on 256 bit values. Hence:
the
add
opcode always returns a 256 bit valueeven if
type(uint128).max
is passed as input foramountToSwap
, inside the in-line assembly block the value ofoutputTokens
will be represented in 256 bits and therefore greater than the inputamountToSwap
therefore the overflow detection using the
lt
opcode will fail to detect the overflow, but the overflow still occurs after the in-line assembly block causing the function to return an incorrect value
One option when working with numbers smaller than uint256
is to use addmod
to prevent the use of the full 256 bits:
function getSwapQuoteUint128(uint128 amountToSwap) external view returns(uint128 outputTokens) {
assembly {
// use addmod(a,b,N) with N = type(uint128).max
// to prevent use of full 256 bits
outputTokens := addmod(amountToSwap, 1, 340282366920938463463374607431768211455)
// detect overflow if outputTokens < amountToSwap
if lt(outputTokens, amountToSwap) { revert(0, 0) }
}
}
Another option to detect these more subtle overflows is to include a check using normal Solidity after the in-line assembly block:
function getSwapQuoteUint128(uint128 amountToSwap) external view returns(uint128 outputTokens) {
assembly {
outputTokens := add(amountToSwap, 1)
}
// detect overflow outside in-line assembly to
// prevent uint128 overflow not being detected due to
// in-line assembly working with 256 bit values
require(outputTokens >= amountToSwap, "Overflow detected!");
}