Why You Should Never Store Sensitive Data in Smart Contracts 🚫🔒

Written by codingjourneyfromunemployment | Published 2023/11/29
Tech Story Tags: data-security | smart-contracts | cybersecurity | solidity | ethereum | storage-vulnerabilities | blockchain-development | smart-contract-security

TLDRvia the TL;DR App

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)));
    }
}

Variable Storage in Smart Contracts 📊🔍

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:

  1. If a variable occupies 32 bytes or 256 bits, it always occupies a single slot on its own. For example, a byte32 or uint256 type variable.
  2. Variables of dynamic size always occupy a single slot on their own, such as bytes or string.
  3. If a variable doesn't occupy 32 bytes or 256 bits, it might be packed with adjacent variables in the same slot automatically. For instance, Ethereum's 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.
  4. Elements of a fixed array are stored directly in consecutive slots. If array elements are types that are less than or equal to 32 bytes, multiple elements might be packed in the same slot. However, if an element type itself occupies an entire slot, each element will occupy its own slot.
  5. Dynamic-length arrays are stored in two parts. The length of the array occupies a single slot. The position of the elements is based on the slot's position, the size of the element, and the element's index. For example, if we declare a dynamic array in the third slot, i.e., slot 2, its length is stored in slot 2, and its first element is stored at: uint(keccak256(abi.encodePacked(2))).
  6. A 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))).
  7. A 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.

How to Access the Database Password and User Passwords in the Example Contract 🔓💡

  1. The example contract first defines a 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.
  2. Each user is a struct, stored in both 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.
  3. The 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.
  4. The 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! 👍🙂📝



Written by codingjourneyfromunemployment | Middle-aged and determined to reinvent myself in the world of programming
Published by HackerNoon on 2023/11/29