Smart contracts are essentially public programs running on Ethereum, often taking on a portion of the backend role in DApp development. However, due to their public nature, data stored within them cannot be treated like a typical backend server or database. Even data set as private
can be accessed directly via storage slot positions.
This reminds us to thoroughly consider data structures when writing smart contracts and to store sensitive data in backend databases, not in contracts. In this article, I'll demonstrate sensitive data access through a simple example.
Alice deployed a simple Vault contract to mimic a simplified backend database, storing user IDs and passwords.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
// slot 0
uint public count = 123;
// slot 1
address public owner = msg.sender;
bool public isTrue = true;
uint16 public u16 = 31;
// slot 2
bytes32 private password;
// constants do not use storage
uint public constant someConst = 123;
// slot 3, 4, 5 (one for each array element)
bytes32[3] public data;
struct User {
uint id;
bytes32 password;
}
// slot 6 - length of array
// starting from slot hash(6) - array elements
// slot where array element is stored = keccak256(slot)) + (index * elementSize)
// where slot = 6 and elementSize = 2 (1 (uint) + 1 (bytes32))
User[] private users;
// slot 7 - empty
// entries are stored at hash(key, slot)
// where slot = 7, key = map key
mapping(uint => User) private idToUser;
constructor(bytes32 _password) {
password = _password;
}
function addUser(bytes32 _password) public {
User memory user = User({id: users.length, password: _password});
users.push(user);
idToUser[user.id] = user;
}
function getArrayLocation(
uint slot,
uint index,
uint elementSize
) public pure returns (uint) {
return uint(keccak256(abi.encodePacked(slot))) + (index * elementSize);
}
function getMapLocation(uint slot, uint key) public pure returns (uint) {
return uint(keccak256(abi.encodePacked(key, slot)));
}
}
In Ethereum smart contracts, a slot can hold 32 bytes, equivalent to 256 bits. The allocation of storage for variables in smart contracts depends on several factors: the type of variable, the size it occupies in storage, and their declaration order. Here are some key principles for determining variable storage in a contract:
byte32
or uint256
type variable.bytes
or string
.address
is a 20-byte byte string. bool
only occupies one byte (actually, just a bit, but the smallest storage unit is a byte). uint16
occupies 16 bits or 2 bytes. In the packing process, if a large variable or a variable of dynamic size is encountered, the packing process stops, and the new variable is stored in the next adjacent slot.uint(keccak256(abi.encodePacked(2)))
.mapping
declared causes an empty slot to appear, but neither the key nor the value of the mapping is stored in this slot. The key of the mapping is not stored, and the value's position is calculated based on the position of this empty slot and the key. For instance, if we declare a mapping(uint => User) private idToUser
in the third slot, i.e., slot 2, and suppose it has a key of 10, then the position of the value corresponding to this key is at uint(keccak256(abi.encodePacked(10, 2)))
.struct
doesn't directly occupy a slot when defined. It only occupies storage slots when instances of the struct are used as state variables (such as array elements or mapping values). Each struct instance's storage method depends on how it is used. For example, if used as elements of an array or values of a mapping, it must conform to the storage mechanism of these data structures.uint
, which, by default, is uint256
, occupying 32 bytes and thus a single slot 0. The next three variables defined occupy 20 bytes, 1 byte, and 2 bytes, respectively, and are packed together in slot 1. The Vault's password occupies its own slot. Although set as private
, we can directly access it through its slot position.User[] private users;
array and mapping(uint => User) private idToUser;
mapping. The user's password is saved in this struct. Thus, although both the array and mapping are set to private
, we can still calculate the slot position to obtain the user's password.getArrayLocation
function demonstrates how to calculate the storage slot position of the corresponding user struct from the array. The statement return uint(keccak256(abi.encodePacked(slot))) + (index * elementSize);
returns the starting position of the user struct stored at the index. After we obtain this position, we can further obtain the user's password based on the internal data structure of the struct. Inside the structure, there is a uint
and a bytes32
, both occupying one slot each. Therefore, the password is stored in the slot returned by the getArrayLocation
function + 1.getMapLocation
function demonstrates how to calculate the storage slot position of the corresponding user struct from the mapping. The statement return uint(keccak256(abi.encodePacked(key, slot)));
returns the starting position of the user struct stored at the key (i.e., id). After we obtain this position, we can also obtain the user's password based on the internal data structure of the struct.
This is why we should never treat contracts as regular backends and store sensitive data within them. If similar needs arise, we still need to rely on backend servers and databases to solve them.
That's all about bypassing the private
modifier to directly read sensitive data and related security strategies. If you've made it this far, help me out with a like! 👍🙂📝