合约安全:访问私有数据

private关键词定义的函数和状态变量只对定义它的合约可见,该合约派生的合约都不能调用和访问该函数及状态变量。那么,我们能访问被private限定的变量吗?
首先我们详解一下storage存储。

一、storage

1.普通变量

  • storage 中的数据被永久存储。其以键值对的形式存储在 slot 插槽中。

  • storage 中的数据会被写在区块链中(因此它们会更改状态),这就是为什么使用存储非常昂贵的原因。

  • 占用 256 位插槽的 gas 成本为 20,000 gas。

  • 修改 storage 的值将花费 5,000 gas 。

  • 清理存储插槽时(即将非零字节设置为零),将退还一定量的 gas 。

  • storage 共有 2^256 个插槽,每个插槽 32 个字节数据按声明顺序依次存储,数据将会从每个插槽的右边开始存储,如果相邻变量适合单个 32 字节,然后它们被打包到同一个插槽中否则将会启用新的插槽来存储。

storage存储方式图

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页面,
storage
  • 其中第一个插槽为(这里存储的是变长数组的长度):
    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页面,

storage

前面两个插槽的值跟上面是一样的,这里我们可以看到新的插槽为:
0x6c13d8c1c5df666ea9ca2a428504a3776c8ca01021c3a1524ca7d765f600979a
这个值等于:
sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564")
插槽编号为: key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564
这个值等于:
sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+1
插槽中存储的数据为:
value=0x0000000000000000000000000000000000000000000000000000000000000002
也就是 16 进制表示的 2 ,也就是我们传入的值。

  • 我们输入5,输出:
0x
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000005

然后在Remix的debugger页面,

storage

最新的插槽为:
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

三、预防手段

不要将任何敏感数据存放在合约中,因为合约中的任何数据都可被读取。常见的敏感数据比如秘钥,游戏通关口令等。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容