Skip to content
On this page

揭秘『伪造者』:一次签名,无限铸币?Ethernaut CTF 挑战深度解析

在区块链安全领域,Ethernaut CTF 系列始终是学习和磨练技能的绝佳平台。它以实战的方式,将复杂的智能合约漏洞和设计缺陷以挑战的形式呈现。今天,我们将深入探讨一个名为『Forger』的挑战,它巧妙地利用了数字签名的细微差别,让我们得以突破『单次使用』的限制,实现代币的『无限』铸造。

背景揭秘:伪造者(The Forger)的传说

欢迎来到『Forger』,一个被描述为“你妈妈警告过你的代币打印机”。这是一个基于ERC-20标准的代币合约,它声称通过一种由合约所有者(owner)签名的『铸币通行证』来发放代币。合约明确指出:

  • 一张黄金签名已存在: 这张签名允许接收者铸造100个闪亮的代币。
  • 团队坚称: 铸币通行证是“单次使用”且“绝对安全”的。
  • 你的目标? 让代币的总供应量超过100个。

这个挑战的诱惑力不言而喻,它在字里行间透露着一种“看似安全实则不然”的悬念。那么,这个『单次使用』的承诺,究竟是如何被打破的呢?

代码深潜:剖析“安全”的陷阱

让我们来看看 Forger.sol 合约的关键部分:

solidity
// ... (imports and error definitions) ...

contract Forger is ERC20 {
    address public owner = 0xC9CAF9e17BBb4e4D27810d97d2C2a467A701e0D5;
    mapping(bytes32 signatureHash => bool used) public signatureUsed;

    constructor() ERC20("Forger Token", "FT") {}

    function createNewTokensFromOwnerSignature(
        bytes calldata signature,
        address receiver,
        uint256 amount,
        bytes32 salt,           
        uint256 deadline      
    ) public {
        require(block.timestamp <= deadline, SignatureExpired());
        // 核心检查:通过签名的哈希值判断是否已使用
        require(!signatureUsed[keccak256(signature)], SignatureUsed());

        bytes32 messageHash = keccak256(abi.encode(
            receiver,
            amount,
            salt,
            deadline
        ));

        address signer = ECDSA.recover(messageHash, signature);

        require(signer == owner, InvalidSigner(signer));

        // 标记签名的哈希值为已使用
        signatureUsed[keccak256(signature)] = true;

        _mint(receiver, amount);
    }
    // ... (invalidateSignature function) ...
}

这段代码的核心逻辑在 createNewTokensFromOwnerSignature 函数中。它接收一个 signature 字节数组以及其他参数,然后:

  1. 时间戳检查: 确保签名未过期。
  2. 使用状态检查: 这是关键!require(!signatureUsed[keccak256(signature)], SignatureUsed()); 合约通过计算传入 signature 字节数组的 keccak256 哈希值,并检查 signatureUsed 映射来判断此签名是否已被使用。
  3. 消息哈希生成: 组合接收者、金额、盐值和截止日期,生成一个 messageHash
  4. 签名者恢复: 使用 ECDSA.recover(messageHash, signature) 从签名和消息哈希中恢复出签名者的地址。
  5. 签名者验证: 确保恢复出的签名者就是合约的 owner
  6. 标记为已使用: signatureUsed[keccak256(signature)] = true; 再次使用 keccak256(signature) 来标记此签名已使用。
  7. 铸造代币: 如果所有检查通过,则为接收者铸造指定数量的代币。

表面上看,这个逻辑无懈可击:签名过期就不能用,用过的签名也不能再用,而且只有 owner 的签名才有效。那么,漏洞究竟在哪里呢?

漏洞解密:一签多用的奥秘

问题的症结在于 keccak256(signature) 这一行。在ECDSA(椭圆曲线数字签名算法)中,一个有效的签名通常由 rsv 三个部分组成。其中 v 是恢复公钥的参数,它的取值通常为 2728(或更高,取决于EIP-155等标准)。

OpenZeppelin 的 ECDSA.recover 函数是一个非常健壮的工具,它能够识别并处理这些不同格式的 v 值。更进一步,v 值甚至可以被编码进 s 值,形成所谓的『紧凑型』签名(compact signature)。

关键点来了: 尽管 ECDSA.recover 可以从两种不同字节表示的签名(例如,v=27 的完整签名形式和 v=28 的完整签名形式,或者一种完整签名和一种紧凑签名)中成功恢复出相同的签名者,但它们的原始字节序列是不同的!因此,它们的 keccak256 哈希值也将截然不同。

这就意味着,攻击者可以拿到一个由 owner 签名的有效签名(例如,合约注释中提供的『黄金签名』),通过简单地转换其 v 值,或者将其编码为紧凑型格式,生成一个新的、不同字节序列的有效签名。虽然这两个签名都指向同一份消息和同一个签名者,但对于合约的 signatureUsed 映射来说,它们是两个完全不相干的哈希值!

因此,合约只会将其中一个哈希值标记为已使用,而另一个完全有效的签名哈希值仍然是“未使用”状态。

攻击复现:传奇伪造的诞生

攻击者正是利用了这种签名表示形式的差异来完成挑战。

合约注释中已经提供了黄金签名的详细信息: signature = f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb1camount = 100 etherreceiver = 0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3esalt = 0x044852b2a670ade5407e78fb2863c51de9fcb96542a07186fe3aeda6bb8a116ddeadline = 115792089237316195423570985008687907853269984665640564039457584007913129639935

攻击脚本 MyScript.sol 的思路如下:

  1. 首次铸造: 攻击者首先利用合约中注释里提供的那份原始黄金签名 (signature28),调用 createNewTokensFromOwnerSignature 函数。这次调用会成功铸造100个代币,并且 keccak256(signature28) 会被标记为 used = true

  2. 二次铸造(关键一步): 攻击者不会就此罢休。他会解析原始签名,提取出 rsv 分量。原始签名中的 v 值通常是 2728。攻击者会根据ECDSA的特性,将其转换为另一种有效的签名表示形式——例如,通过将 v 值编码到 s 中,形成一个紧凑型签名 (compactSig)。

    solidity
    // 从原始签名中提取 r, s, v
    bytes32 r = 0xf73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809;
    bytes32 s = 0x402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb;
    uint8   v = 28; // 原始v值
    
    bytes32 vs;
    if (v == 27) {
        vs = s;
    } else { // v == 28
        vs = s | bytes32(uint256(1) << 255); // 将v=28编码到s的最高位
    }
    bytes memory compactSig = abi.encodePacked(r, vs); // 生成新的紧凑型签名
    

    尽管 compactSigsignature28 的字节序列完全不同,但它们在 ECDSA.recover 函数看来,都能够正确地恢复出 owner 的地址,因为它们代表的是同一份有效的加密签名。

  3. 再次调用: 当攻击者用这个『新』的紧凑型签名 (compactSig) 再次调用 createNewTokensFromOwnerSignature 时,合约会执行以下检查:

    • keccak256(compactSig) 是一个全新的哈希值,它在 signatureUsed 映射中是 false(即未被使用)。
    • ECDSA.recover(messageHash, compactSig) 仍然会成功恢复出 owner。 因此,所有条件都满足,合约会再次成功铸造100个代币!

通过两次使用本质上相同的签名(只是字节表示形式不同),攻击者成功地将代币总供应量从100提升到了200,完成了挑战目标。

启示与思考:安全开发的警钟

『Forger』挑战生动地提醒我们,在区块链开发中,对底层密码学原理的理解至关重要。仅仅依靠签名的原始字节哈希来判断其使用状态是不够严谨的。这种方法无法区分加密上等价但字节序列不同的签名。

正确的做法应该是:

  • 哈希底层消息: 在处理签名验证时,不应该直接哈希原始签名数据来作为 used 标志。更安全的方法是哈希签名所代表的**『意图』或『消息哈希』本身**,确保对于同一个消息,无论签名以何种有效形式提交,都只会被处理一次。
  • 规范化签名: 如果必须以签名本身作为键值,则应在存储或比较之前,对签名进行规范化处理(例如,统一 v 值为 01,或使用特定的编码格式),确保其字节表示形式是唯一的。

『Forger』不仅仅是一个有趣的CTF题目,它更是一堂深刻的安全课,告诫所有智能合约开发者:对区块链底层机制的细致入微的理解,是构建真正安全、健壮系统的基石。在看似简单的规则之下,往往隐藏着最意想不到的漏洞。保持警惕,持续学习,方能在这片数字丛林中立于不败之地。

Built with AiAda