Appearance
揭秘『伪造者』:一次签名,无限铸币?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 字节数组以及其他参数,然后:
- 时间戳检查: 确保签名未过期。
- 使用状态检查: 这是关键!
require(!signatureUsed[keccak256(signature)], SignatureUsed());合约通过计算传入signature字节数组的keccak256哈希值,并检查signatureUsed映射来判断此签名是否已被使用。 - 消息哈希生成: 组合接收者、金额、盐值和截止日期,生成一个
messageHash。 - 签名者恢复: 使用
ECDSA.recover(messageHash, signature)从签名和消息哈希中恢复出签名者的地址。 - 签名者验证: 确保恢复出的签名者就是合约的
owner。 - 标记为已使用:
signatureUsed[keccak256(signature)] = true;再次使用keccak256(signature)来标记此签名已使用。 - 铸造代币: 如果所有检查通过,则为接收者铸造指定数量的代币。
表面上看,这个逻辑无懈可击:签名过期就不能用,用过的签名也不能再用,而且只有 owner 的签名才有效。那么,漏洞究竟在哪里呢?
漏洞解密:一签多用的奥秘
问题的症结在于 keccak256(signature) 这一行。在ECDSA(椭圆曲线数字签名算法)中,一个有效的签名通常由 r、s 和 v 三个部分组成。其中 v 是恢复公钥的参数,它的取值通常为 27 或 28(或更高,取决于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 的思路如下:
首次铸造: 攻击者首先利用合约中注释里提供的那份原始黄金签名 (
signature28),调用createNewTokensFromOwnerSignature函数。这次调用会成功铸造100个代币,并且keccak256(signature28)会被标记为used = true。二次铸造(关键一步): 攻击者不会就此罢休。他会解析原始签名,提取出
r、s和v分量。原始签名中的v值通常是27或28。攻击者会根据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); // 生成新的紧凑型签名尽管
compactSig和signature28的字节序列完全不同,但它们在ECDSA.recover函数看来,都能够正确地恢复出owner的地址,因为它们代表的是同一份有效的加密签名。再次调用: 当攻击者用这个『新』的紧凑型签名 (
compactSig) 再次调用createNewTokensFromOwnerSignature时,合约会执行以下检查:keccak256(compactSig)是一个全新的哈希值,它在signatureUsed映射中是false(即未被使用)。ECDSA.recover(messageHash, compactSig)仍然会成功恢复出owner。 因此,所有条件都满足,合约会再次成功铸造100个代币!
通过两次使用本质上相同的签名(只是字节表示形式不同),攻击者成功地将代币总供应量从100提升到了200,完成了挑战目标。
启示与思考:安全开发的警钟
『Forger』挑战生动地提醒我们,在区块链开发中,对底层密码学原理的理解至关重要。仅仅依靠签名的原始字节哈希来判断其使用状态是不够严谨的。这种方法无法区分加密上等价但字节序列不同的签名。
正确的做法应该是:
- 哈希底层消息: 在处理签名验证时,不应该直接哈希原始签名数据来作为
used标志。更安全的方法是哈希签名所代表的**『意图』或『消息哈希』本身**,确保对于同一个消息,无论签名以何种有效形式提交,都只会被处理一次。 - 规范化签名: 如果必须以签名本身作为键值,则应在存储或比较之前,对签名进行规范化处理(例如,统一
v值为0或1,或使用特定的编码格式),确保其字节表示形式是唯一的。
『Forger』不仅仅是一个有趣的CTF题目,它更是一堂深刻的安全课,告诫所有智能合约开发者:对区块链底层机制的细致入微的理解,是构建真正安全、健壮系统的基石。在看似简单的规则之下,往往隐藏着最意想不到的漏洞。保持警惕,持续学习,方能在这片数字丛林中立于不败之地。