Skip to content
On this page

揭秘以太坊“隐私”:Ethernaut CTF挑战深度解析

在区块链的世界里,“去中心化”和“透明性”是两大基石。但当一个智能合约标榜“隐私”时,它真的能保守秘密吗?今天,我们就来深入剖析Ethernaut CTF平台上的一个经典挑战——Privacy,看看它是如何巧妙地利用了我们对“隐私”的误解,以及我们该如何抽丝剥茧,揭开其隐藏的真相!

Ethernaut 是什么?

想象一个由OpenZeppelin创建的,专门用于检验以太坊智能合约安全知识的在线“密室逃脱”游戏。每个关卡(Level)都是一个有漏洞的智能合约,你需要通过编写代码或与合约交互,找到并利用这些漏洞,最终“解锁”关卡。这不仅是对技术的考验,更是对思维的挑战。

挑战目标:Privacy —— 一份“小心翼翼”的合约

Privacy合约的创建者似乎一丝不苟,试图保护其存储中的敏感区域。我们的任务就是“解锁”这个合约,通过关卡。

合约代码乍一看,似乎一切都在掌握之中:

solidity
contract Privacy {
    bool public locked = true; // 默认锁定
    uint256 public ID = block.timestamp;
    // ... 一些 private 私有变量 ...
    bytes32[3] private data; // 重要的!一个私有bytes32数组

    constructor(bytes32[3] memory _data) {
        data = _data; // 构造函数初始化data
    }

    function unlock(bytes16 _key) public {
        require(_key == bytes16(data[2])); // 解锁需要正确的key
        locked = false; // 成功解锁
    }
    // ...
}

很明显,我们需要调用unlock函数,并传入一个正确的_key。而这个_key的秘密就藏在bytes16(data[2])中。问题来了:data是一个private(私有)变量,我们如何才能知道data[2]的值呢?

迷雾中的指引:关键提示

Ethernaut的提示总是那么耐人寻味:

  • 理解存储如何工作 (Understanding how storage works)
  • 理解参数解析如何工作 (Understanding how parameter parsing works)
  • 理解类型转换如何工作 (Understanding how casting works)

这些提示,如同灯塔一般,指明了我们的方向。特别是第一点,它几乎是解开谜题的唯一钥匙!

区块链上的“隐私”:一个误解

当我们在Solidity中声明一个变量为private时,这确实意味着其他合约无法直接访问它。但请注意,这里的“私有”仅限于合约内部访问权限。对于链上存储而言,一切都是公开的!

想象一下,以太坊是一个巨大的公共账本,所有的交易、合约代码以及合约的状态变量(也就是它的存储数据),都明明白白地记录在链上。private关键字并不能阻止任何人直接读取这些存储数据。它只阻止了其他智能合约通过代码调用来获取这些数据。

揭示真相:智能合约存储的奥秘

智能合约的存储可以被看作是一个巨大的哈希表,或者说是一个编号从0开始的,每个槽位(Slot)可以存储32字节数据的数组。变量在存储槽中的布局遵循特定的规则:

  1. 静态大小变量: 按照声明顺序依次占据存储槽。
  2. 紧凑打包: 如果多个连续的小于32字节的变量可以打包到一个32字节的槽中,它们会尽可能地被打包在一起,以节省Gas。
  3. 动态大小数组和映射: 它们有更复杂的存储机制,通常会有一个槽来存储其长度,并根据其哈希值在另一个存储区域分配空间。

让我们根据Privacy合约的变量声明,一步步推导data数组的存储位置:

  • bool public locked = true;bool占用1字节,位于 槽位 0
  • uint256 public ID = block.timestamp;uint256占用32字节,位于 槽位 1
  • uint8 private flattening = 10; (1字节)
  • uint8 private denomination = 255; (1字节)
  • uint16 private awkwardness = uint16(block.timestamp); (2字节) 这三个变量总共只占用1+1+2 = 4字节。它们会紧凑地打包在 槽位 2 中。
  • bytes32[3] private data;:这是一个固定大小的bytes32数组,包含3个bytes32元素。每个bytes32元素会占用一个完整的32字节存储槽。
    • data[0] 将位于 槽位 3
    • data[1] 将位于 槽位 4
    • data[2] 将位于 槽位 5

Bingo!我们锁定了目标——data[2]就存储在合约的 槽位 5

锁定并提取密钥:实践操作

有了这个发现,我们就可以利用以太坊节点提供的eth_getStorageAt(或ethers.provider.getStorage)方法,直接读取合约在特定地址特定存储槽的数据。

根据TypeScript解题代码,我们看到关键步骤:

typescript
const SLOT5 = 5;
// 从合约地址的槽位5中读取数据
const data2 = await ethers.provider.getStorage(PRIVACY_ADDRESS, SLOT5);
// 提取key:因为unlock需要bytes16,而存储的是bytes32,所以需要截取
const key = data2.slice(0, 34); // '0x' + 16字节 * 2字符/字节 = 34个字符

这里data2.slice(0, 34)的魔力在于:getStorage返回的是一个0x开头的十六进制字符串,表示整个32字节的数据。由于unlock函数需要的是bytes16(即16个字节),所以我们需要从这个32字节数据中截取前16个字节。在十六进制字符串中,这对应于0x前缀后的32个字符(16字节 * 2字符/字节 = 32字符),加上0x,正好是34个字符。

最后,拿到正确的key之后,调用privacyContract.unlock(key),合约的locked状态就会变为false,挑战成功!

总结与启示

Privacy挑战以一种非常直观的方式,向我们揭示了区块链世界的一个重要原则:链上无隐私。任何写入智能合约存储的数据,无论你用private还是public修饰,都可以通过直接查询区块链状态来获取。

这个挑战不仅仅是关于找出隐藏的密钥,更是关于理解区块链的底层机制,尤其是智能合约的存储布局。对于任何希望在区块链领域构建安全应用的人来说,这都是一个至关重要的知识点。

思考一下: 如果真的需要在链上存储敏感数据,你会怎么做?(提示:加密!)

希望这篇解析能帮助你更好地理解Privacy挑战及其背后的区块链原理。赶快去Ethernaut亲自体验一下吧,更多精彩的挑战等你来征服!


Built with AiAda