Yul and Memory: A Low-Level Exploit
Quick answer. In Yul, EVM memory is 32-byte words: scratch space, a free memory pointer at 0x40, and a zero slot at 0x60. A string is stored as its length then its data, and forging that length forces a revert.
Yul is one of the first advanced features you reach as you move from an intermediate Solidity developer or auditor toward a more advanced one. Reading and writing low-level memory gives you an edge.
It lets you see edge cases that a version of you without it would miss.
This guide covers how memory is laid out, then walks through a finding that uses low-level memory manipulation to carry out an attack.
Memory in Words
Memory is split into words. Each word is 32 bytes, and a byte is two hex characters.
A hex character is 4 bits. So 0x40 is the bits for 4 (which is 0100, or 64) followed by a zero, giving 64 in decimal.
So a word holding 0x40 holds the number 64. Other words can hold any arbitrary 32 bytes.
The Structure of Memory
Each word sits at a hex offset. The first is 0x00, the next is 0x20 (32 in decimal, since one word is 32 bytes), then 0x40, and so on.
Memory has an inherent structure, and the free memory pointer is part of it. It points to where it is safe to add memory.
| Offset | Purpose |
|---|---|
0x00 to 0x3F | Scratch space for the EVM and Solidity |
0x40 | The free memory pointer, initially 0x80 |
0x60 | The zero slot, the default value for an uninitialized dynamic array |
0x80 onward | Free for the user to write to |
Solidity expects you to steer clear of the first two word slots. It uses them as scratch space while executing functions, and it expects them clear.
The free memory pointer
The word at 0x40 holds the free memory pointer. It tells you the first word where writing will not overwrite something already written.
It starts at 0x80. It skips 0x60 because that slot is meant to stay a zero value, the default for an uninitialized dynamic array, so it should never be written to.
With Yul you can write anywhere you like, which is dangerous. The safe approach is still to read the pointer and write at whatever offset it holds.
Playing With Memory
Load the free memory pointer by reading the word at 0x40. Log it and you see a word of zeros ending in 80: the pointer is 0x80, as expected.
bytes32 wordFreeMemory;
assembly {
wordFreeMemory := mload(0x40) // reads the free memory pointer
}
// wordFreeMemory is 0x80
Read 0x60 and it is empty, as it should be.
MSTORE does not update the pointer
Now write to memory at the free memory pointer. Store the decimal value 32, and it shows up at 0x80 as 0x20 in hex.
assembly {
let freeMemory := mload(0x40) // 0x80
mstore(freeMemory, 32) // writes 0x20 at 0x80
}
Read the pointer at 0x40 again, and it still says 0x80. An mstore does not update the free memory pointer for you.
Write again and you overwrite what you just stored. High-level Solidity updates the pointer automatically, but with low-level manipulation you must do it yourself.
Update it after any mstore if you plan to use the pointer again, or if more Solidity runs after the assembly block and touches memory.
Storing a String
Declare string memory s = "hello" and look at memory in the debugger. The free memory pointer has advanced, because this is high-level Solidity.
At 0x80 is the value 5. At 0xA0 is the word for "hello" in hex.
The 5 is the length of the string: five bytes, one per character. A string is stored as its length, then its data in the words that follow.
A Memory Manipulation Exploit
Now a finding that turns the string layout into an attack. Credit for the original goes to the researchers who found it in a Sherlock contest.
The vulnerable system
A rewarder contract lets anyone give a reward to someone else, and the recipient claims all their rewards at once in a single loop. Claiming everything in one loop is already a poor idea.
On each transfer, the contract catches any revert reason bytes, parses them, and emits them in an event, so a transaction can be debugged.
A helper, getRevertMessage, does the parsing, and it holds the bug. It reads the error selector from the first word of the data.
If the selector matches the string error, it shifts the data left by four bytes to strip the selector, then decodes the rest as a string.
function getRevertMessage(bytes memory data) internal pure returns (string memory) {
bytes4 selector;
assembly { selector := mload(add(data, 0x20)) } // first word = selector
if (selector != stringErrorSelector) return "";
assembly { data := add(data, 0x04) } // strip the 4-byte selector
return abi.decode(data, (string)); // trusts the encoded length
}
Why the length is the weak point
A string starts with its length. The decode call trusts that length and tries to read a string that long.
A crafted value can claim a huge length while no data backs it. The decode then iterates over an enormous string that is not there, burning gas until it runs out and reverts.
When the rewarder hits a malicious token that does this, the transfer does not simply get caught so the loop can move on.
The out-of-gas revert kills the whole transaction, so no other rewards are distributed and nothing else runs.
The malicious token
On its transfer function, a malicious token reverts with hand-built bytes: the string error selector, then a crafted length with no data behind it.
It reads the free memory pointer, writes a word that starts with the four-byte selector padded to the right, then, offset by those four bytes, writes the string's claimed length. Then it reverts with that data.
assembly {
let p := mload(0x40) // free memory pointer
mstore(p, stringErrorSelector) // selector, right-padded
mstore(add(p, 0x04), 0x20) // offset to the string data
mstore(add(p, 0x24), 0xffffffff...) // a huge claimed length, no data
revert(p, 0x44)
}
The four-byte offset matters because getRevertMessage slices the data by four bytes. The selector occupies those four bytes, and the string's length and data follow in the words after it.
Seeing it run
Deploy a good token, the malicious token and the rewarder. Reward a user with zero good tokens and the withdrawal succeeds.
Reward the same user with zero malicious tokens and the withdrawal reverts. It reverts on the decode call, trying to decode an impossibly long string.
Add a log after the decode and you never see it. Execution reaches the decode but never finishes it, because it runs out of gas.
In the debugger
In memory, the error selector sits at 0x120, with the 0x20 offset in the next word, and the claimed length continuing after it.
Nothing is perfectly aligned per word, because everything was shifted by four bytes to make room for the selector.
When the assembly strips those four bytes, the first word ends in 0x20, the offset to the string.
The next word becomes entirely the string's length, and after it there is plainly no string. That gap is the crux of the exploit.
Takeaways
- A string in memory is its length followed by its data. A decoder trusts that length, so a crafted length with no backing data can force an out-of-gas revert.
- Claiming many rewards in a single loop lets one malicious entry block the whole batch.
mstorenever updates the free memory pointer. Update it yourself after writing in assembly.- Low-level memory work in Yul reveals attacks that high-level review misses.
For how these memory areas fit into the wider machine, read how the EVM works. For the bug class behind the loop, see external call attacks, and for more subtle issues, sneaky Solidity bugs.
How Guardian Helps
Guardian's smart contract audits are done by researchers who read assembly and trace memory layout directly, so low-level issues like this one do not slip through.
An invariant suite built around your safety properties stays with your team afterward.
Frequently Asked Questions
What is the free memory pointer in Yul?
The word at memory offset 0x40. It holds the first free memory location, initially 0x80. An mstore does not update it, so in assembly you must update it yourself before writing again.
How is a string stored in EVM memory?
As its length in the first word, followed by its data in the words after it. Writing "hello" stores a 5, then the bytes for the five characters.
Why does mstore not update the free memory pointer?
mstore only writes a value at an offset. High-level Solidity updates the pointer for you, but low-level assembly does not, so you must update 0x40 yourself after writing.
How can a crafted string length cause an exploit?
A decoder trusts the length word. A value claiming a huge length with no backing data makes the decoder iterate over a string that is not there, burning all the gas and reverting.
Sources
Get assembly-level code reviewed
Guardian's researchers read Yul and trace memory directly, so low-level exploits do not reach production.