10分钟智能合约:进阶实战-3.10 私有数据暴露

57 阅读5分钟

欢迎订阅专栏10分钟智能合约:进阶实战

私有数据暴露:定义、原理与防御

私有数据暴露 指智能合约开发者误以为使用 private 关键字修饰的状态变量可以像传统程序中的私有变量一样完全对外隐藏,但实际上区块链上的所有数据都是公开的。任何节点都可以读取任意合约的存储、交易输入和事件,private 仅限制其他合约直接读取,无法阻止链下观察者获取数据。

核心认知:在区块链中,没有真正的隐私。即使变量被标记为 private,其值仍然存在于公开的存储中,任何人都可以通过解析存储槽或调试交易来查看。


1. 核心原理

EVM 将合约的状态变量按顺序存储在存储槽中,每个槽 32 字节。private 修饰符仅阻止 Solidity 编译器为外部合约生成访问该变量的 getter 函数,但无法阻止:

  • 直接读取存储槽:通过 web3.eth.getStorageAt(address, slot) 或 Etherscan 等工具,任何人都可以读取任意存储槽的值。
  • 交易执行追踪:通过调试交易或查看 eth_getProof,可以获取变量在执行过程中的变化。
  • 事件日志:即使变量未直接暴露,若其在事件中被使用,日志也会公开。

因此,任何存储在链上的数据都是透明的


2. 常见暴露场景

场景描述后果
私密信息存储将密码、API 密钥、个人身份信息等存储在私有变量中。完全暴露,被任何人读取。
随机数种子泄露使用 private 变量存储随机数种子,如 keccak256(block.timestamp, privateSeed)攻击者可读取种子,预测“随机”结果。
竞拍最高价在密封竞拍中,将最高出价存在私有变量,待结束后公布。任何人可读取该变量,破坏密封性。
密钥对泄露存储私钥或对称加密密钥。资产完全失控。
合约逻辑混淆将关键业务逻辑参数(如手续费率、归属地址)作为私有变量,以为能隐藏。攻击者可提前获取参数,调整策略套利。

3. 典型漏洞示例

3.1 私有变量可被任意读取

contract Secret {
    address private owner;
    uint256 private secretNumber;

    constructor() {
        owner = msg.sender;
        secretNumber = 42;
    }

    function guess(uint256 _guess) external view returns (bool) {
        return _guess == secretNumber;   // 用于某种验证
    }
}

读取方法(使用 web3.js):

// 假设合约地址为 0x...
const secretSlot = 1; // secretNumber 在 slot 1
const value = await web3.eth.getStorageAt(contractAddress, secretSlot);
console.log(web3.utils.hexToNumberString(value)); // 输出 42

任何人都能轻松获取 secretNumber,从而通过 guess 验证。

3.2 随机数种子暴露

contract Raffle {
    uint256 private seed;   // 自认为隐藏的种子
    mapping(address => uint) public tickets;

    function buyTicket() public payable {
        tickets[msg.sender] = block.number;
    }

    function drawWinner() public {
        seed = keccak256(abi.encodePacked(seed, blockhash(block.number-1)));
        uint winnerIndex = uint(seed) % totalTickets();
        // 分配奖励...
    }
}

攻击者可读取 seed 变量,提前计算出下一位赢家,从而操纵抽奖。

3.3 密封竞拍被破坏

contract SealedAuction {
    address public highestBidder;
    uint public highestBid;
    uint private sealedBid;       // 以为隐藏的最高价
    address private sealedBidder;
    uint public endTime;

    function bid(bytes32 _blindedBid) public {
        // 第一阶段:提交盲标
    }

    function reveal(uint _bid, bytes32 _secret) public {
        // 第二阶段:揭晓
        // 实际比较 _bid 与存储的 sealedBid
    }
}

攻击者读取 sealedBidsealedBidder,即可在 reveal 阶段以略高于该价格的价格获胜,破坏了密封性。


4. 防御措施

策略实现方式
不在链上存储秘密任何敏感信息(密码、私钥、个人数据)永远不要放在区块链上。使用链下存储 + 哈希/签名验证。
使用承诺-揭示模式用户先提交哈希(盲标),后再揭示原始值,确保隐藏性。但哈希本身也不能依赖于私有变量。
零知识证明(ZK)使用 zk-SNARKs/zk-STARKs 在不暴露原始数据的情况下验证知识。
链下随机数使用预言机(如 Chainlink VRF)生成可验证的随机数,避免依赖链上私有变量。
加密存储(不推荐)若必须存储,可在链上存储密文,但密钥仍需链下管理,且密文仍公开,仅适用于非关键场景。
事件与日志脱敏确保事件中不包含敏感信息。

承诺-揭示模式示例:

contract SealedAuction {
    mapping(address => bytes32) public commitments;
    mapping(address => uint) public revealedBids;

    function commit(bytes32 _commitment) public {
        commitments[msg.sender] = _commitment;
    }

    function reveal(uint _bid, bytes32 _secret) public {
        bytes32 computed = keccak256(abi.encodePacked(_bid, _secret));
        require(computed == commitments[msg.sender], "Invalid reveal");
        revealedBids[msg.sender] = _bid;
        // 公开竞标过程
    }
}

其中 _bid_secret 在揭示前是未知的,攻击者无法通过读取存储获取。


5. 真实案例

  • Fomo3D 随机数漏洞:游戏使用链上公开变量作为随机数来源,攻击者提前计算获胜者,最终被“justo”攻击获胜。
  • Mithril 密钥泄露:部分合约将 API 密钥存储在私有变量中,被用户通过 getStorageAt 读取并滥用。
  • 各类“隐私”代币:如早期的 Zcash 模仿者,在 Solidity 中尝试实现隐私,最终被发现所有交易数据仍然可追溯。

6. 总结

私有数据暴露 是新手开发者最常见的误解之一。在区块链上,没有任何数据是真正私有的private 关键字仅提供编译时的访问限制,与链上数据可见性无关。

核心原则

  • 链上存储的数据必然公开
  • 假设任何写入存储槽的数据都可以被攻击者读取。
  • 敏感信息应通过链下 + 密码学证明的方式处理,或使用专门的隐私保护方案(如 Aztec、Tornado Cash 等)。

审计时,需重点检查:

  • 是否有变量被认为“私有”并用于安全判断(如随机数、秘密价格)。
  • 是否依赖私有变量执行关键逻辑(如权限验证、奖励计算)。

记住:公开账本,无密可藏