合约私有数据泄漏的安全问题分析及演示

  • 小驹
  • 更新于 2022-06-09 00:06
  • 阅读 3163

所有存储在区块链上都是公开可见的,包括合约的私有状态变量!所以不要在区块链上存储密码和私钥,而且对于确实需要存储的敏感数据尽量使用hash比对的存储密文hash。通过本文可以理解合约数据的存储,并学会访问合约的状态变量数据。

1. 简介

以太坊编程中的存储主要包括两种:

  • 以太坊如何在区块链上存储合约数据
  • Solidity 如何存储全局变量和局部变量。

每个智能合约都有自己的存储来反映合约的状态,这些存储都与智能合约的地址进行绑定。在不同的函数调用中,这些存储中的值都是保持不变的。

1.1 存储的基本原则

本文主要讨论在区块链上存储的合约数据。根据官方文档,合约数据在以太坊区块链上有2^256个槽,每个槽32字节.

Storage on Ethereum blockchain is 2^256 slots, and each slot is 32 bytes. 在以太坊区块链中的存储有2^256个槽,每个槽32字节。

静态变量(除了映射和动态大小的数组类型之外的所有变量)从位置 0 开始在存储中连续布局。同时为了节省空间,会根据以下规则将需要少于 32 个字节的多个项目打包到一个存储槽中:

  • 在每个槽中,第一项存储在低位,第二项存储在次低位,从低位向高位存储。
  • 基本类型只使用存储它们所需的那么多字节,如一个bool只使用1个字节,1个uint16只使用2个字节。
  • 如果一个存储槽的剩余空间不足以存储基本类型,则将该基本类型移动到下一个存储槽中存储。
  • 结构休和数组总是开始一个新的槽并占据整个槽(但是结构体或数组中的子类型也会根据这些上面的规则被优化存储)。

数据按声明顺序依次存储在这些插槽中。 存储时会进行优化以节省存储空间。因此,如果依次的多个变量可以在单个 32 字节槽中容纳的话,它们将共享同一个槽,并且依次从最低有效位(从右侧)开始存储和索引。

太坊存储和空间优化的可视化示例。图1说明状态变量按顺序挨个slot存储,图2说明会将变量尽可能“挤”在一个slot中,“挤” 不下的话就存储在下一个slot中。 1.png

当使用小于 32 字节的类型时,所花费合约的 gas可能会更高。这与直观感受的”使用的空间越少,gas费越低”的直观感受不相符。 这是因为 EVM 一次运行 32 个bytes。因此,如果类型小于32个bytes,EVM 必须使用更多的操作才能将类型的大小从 32 bytes减少到所需的大小。 只有在存储内容时,使用对应大小的的参数是效果的,因为编译器会将多个元素打包到一个存储槽中,从而将多个读取或写入组合到一个操作中。 在处理函数参数或memory类型值时,尽量不要使用小于32的类型,因为编译器不会打包这些值。由于它们不可预测的大小,映射和动态大小的数组类型使用 Keccak-256 哈希计算来查找值或数组数据的起始位置。这些起始位置始终是满栈槽。

1.2 动态数组和mapping类型的存储

mapping和动态数组的大小不可预测,因此映射和动态数组类型使用 Keccak256 哈希计算来查找值或数组数据的起始位置。这些起始位置始终是放在一个槽中。

🤪下面的文字比较难理解,建议可以根据演示三和演示四的模拟过程进行理解。

假设mapping或动态数组的存储位置在应用存储布局规则后最终存储在slot p中。

  • 对于动态数组,此slot(也就是slot p)储数组中元素的数量(字节数组和字符串除外,见下文)。
  • 对于mapping,该slot(也就是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 是根据类型应用于键的函数:

  • 对于值类型,h 将值填充为32个字节,其方式与将值存储在内存中时的方式相同。
  • 对于字符串和字节数组,h(k) 只是未填充的数据。

1.3 对区块存储的访问

在web3.js中,可以使用web3.eth.getStorageAt来访问合约存储。

在ethers.js中,可以使用privider.getStorageAt来访问合约存储。

本文的模拟是基于hardhat+ehers.js的演示,所以读取合约存储时,使用的是privider.getStorageAt函数进行访问。

2.演示实例

通过4个示例来理解合约在区块链上的存储原则,4个演示代码由简入繁,依次递进,逐步深入。

演示一:简单的slot数据的排列

使用ethernaut vault 题目作为演示。演示的过程是:

  1. 本地部署合约,其中使用自定义的password为”aaaabbbccc”.
  2. 部署完成后,假装我们不知道部署时的password,通过合约直接读取slot得到部署时的密码。

通过这个演示,来理解在合约中的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。 2.png

演示二:不同类型状态变量在slot中排列

以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]

模拟过程:

  1. 先部署Privacy合约。
  2. 查看slot 0到slot 5的数据。
  3. 根据代码分析slot 5中的取16就会password,取出password.
  4. 使用3中得到password,调用privacy合约unlock方法。就实现了解锁操作。
  5. 调用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;
});

3.png

演示三:定位动态数组状态变量的内容

在合约中,定位到动态数组类型的状态变量的内容。

演示的合约代码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,演示过程如下:

  1. 部署Privacy合约,打印合约的部署地址。
  2. 部署完成后,打印slot的内容。
  3. 向动态数组中添加两个用户,id为1的用户password为“123”,id为2的用户的password为“789”
  4. 上面的环境完成后,尝试读取到动态用户的password.
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;
});

演示四:定位mapping状态变量的内容

目的:获取到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

模拟的过程:

  1. 部署UserPass合约。
  2. 打印出部署完成后,合约的地址和合约的slot的内容。
  3. 使用user1向UserPass合约中添加用户名”111”,密码为”1111”的用户。
  4. 使用user2向UserPass合约中添加用户名”222”,密码为”2222”的用户。
  5. 上述的1-4的准备工作完成后,开始模拟读取UserPass合约中mapping类型的状态变量user的值。 mapping的读取方式:
    1. 通过mapping的类型(address⇒uint)、待取的数据的key的值user1.addr和起始的slot(这里是1),计算出hash=await ethers.utils.keccak256(await ethers.utils.defaultAbiCoder.encode(["address", "uint"], [user1.address,1]))
    2. 该hash处对应的slot存储的就是value,value也是按照尽量”挤”在一个slot中,一个“挤”不下时放在下一个slot中的存储原则。( 具体的对照着代码来理解吧 )
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

3.总结

区块链上合约数据的存储,最需要理解的一些内容如下:

  • 所有存储在区块链上都是公开可见的,包括合约的私有状态变量!所以不要在区块链上存储密码和私钥,而且对于确实需要存储的敏感数据尽量使用hash比对的存储密文hash。
  • 状态变量存储在slot上,共2^256个slot,每个 slot占32个字节,状态变量按顺序挨个slot存储,尽可能“挤”在一个slot中,“挤” 不下的话就存储在下一个slot中。
  • 动态数组和mapping的变量存储比较复杂。
    • 动态数组,在对应的slot上存储数组的长度。通过对数组下标与数组类型计算出hash,找到对应hash的slot后依次存储。
    • mapping类型,对应的slot上为0x0。通过mapping类型、key和起始的slot计算出hash,找到hash对应的slot后依次存储。
  • 对合约存储的访问。使用ethers.js中的privider.getStorageAt访问slot,对于动态数组和mapping类型,结合使用ethers.utils.defaultAbiCoder.encode(["xxx", “xxx”], [slotnum])ethers.utils.keccak256来得到slot的位置。

4.参考

solidity官方数据存储说明

https://docs.soliditylang.org/en/v0.4.24/miscellaneous.html#layout-of-state-variables-in-storage

常量不占存储空间的说明

https://docs.soliditylang.org/en/latest/contracts.html#constants

ethernaut private题目解答

https://medium.com/coinmonks/ethernaut-lvl-12-privacy-walkthrough-how-ethereum-optimizes-storage-to-save-space-and-be-less-c9b01ec6adb6

ethernaut vault 题目链接

https://ethernaut.openzeppelin.com/level/0xf94b476063B6379A3c8b6C836efB8B3e10eDe188

ethernaut vault题目解答

https://medium.com/coinmonks/how-to-read-private-variables-in-contract-storage-with-truffle-ethernaut-lvl-8-walkthrough-b2382741da9f

智能合约安全审计入门篇 —— 访问私有数据

https://learnblockchain.cn/article/3880

点赞 3
收藏 9
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
小驹
小驹
区块链安全分析,欢迎私信沟通交流