vanity
分析
1.全局观
题目给了5个合约,其实挺简单:
- SignatureChecker.sol:一个检查签名的library
- Setup.sol:初始化题目和设置题目完成的条件
- IERC1271.sol
- ECDSA.sol
- Challenge.sol
- 题目的核心部分,给了三个方法,只有两个可以调用,提供数据,要求地址至少包含16个0字节
2.任务
从下面的代码可以看出,我们需要调用solve()
或者solve(address, bytes)
,然后使得bestScore大于等于16。也就是一个地址至少包含16个0字节
1 2 3
| function isSolved() external view returns (bool) { return challenge.bestScore() >= 16; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| contract Challenge { // 0x19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb528 bytes32 private immutable MAGIC = keccak256(abi.encodePacked("CHALLENGE_MAGIC"));
uint public bestScore;
function solve() external { solve(msg.sender); }
function solve(address signer, bytes memory signature) external { require(SignatureChecker.isValidSignatureNow(signer, MAGIC, signature), "Challenge/invalidSignature");
solve(signer); }
function solve(address who) private { uint score = 0;
for (uint i = 0; i < 20; i++) if (bytes20(who)[i] == 0) score++;
if (score > bestScore) bestScore = score; } }
|
3.详细分析
3.1初探突破口
solve()
函数是要求msg.sender至少包含16个0字节,显示不可能,因此只能寄希望于solve(address, bytes)
。从这个方法又调用了isValidSignatureNow()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function isValidSignatureNow( address signer, bytes32 hash, bytes memory signature ) internal view returns (bool) { (address recovered, ECDSA.RecoverError error) = ECDSA.tryRecover(hash, signature); if (error == ECDSA.RecoverError.NoError && recovered == signer) { return true; }
(bool success, bytes memory result) = signer.staticcall( abi.encodeWithSelector(IERC1271.isValidSignature.selector, hash, signature) ); return (success && result.length == 32 && abi.decode(result, (bytes4)) == IERC1271.isValidSignature.selector); }
|
上半部分,调用了ecdsa库函数的tryRecover()
函数,传入一个hash常量,一个签名的字符串,返回一个签名者的地址,要求签名者的地址和传入的signer地址相等。但是hash不可控,是一个常量,由于哈希算法的特性我们无法找到一个有效的签名可以通过验证,这一步走不通。
下半部分,对传入地址signer的staticcall调用。把函数签名、hash、签名内容这三个东西进行abi编码之后传入。要求staticcall返回内容的长度是32字节,返回的bytes32转化成bytes4(也就是高位截断之后的前4字节),这前4个字节要等于isValidSignature的函数签名——也就是说,这个signer要求至少16个字节的内容都是0,然后要求调用之后返回32字节的内容,并且返回内容等于一个已知常量。这也是我们唯一的出路了。
3.2唯一出路
要一个地址有至少16个字节都是0,容易想到evm预编译。首先这个预编译合约它是不在链上的,这部分内容集成在每个节点上,因为调用频繁,所以不在链上计算,节约成本。 具体文档参照: https://www.evm.codes/precompiled。首先这些个预编译合约的地址前面有很多0,满足条件,但是还需要找一个调用返回bytes32的。
发现只有0x2合适,输入任意长度的内容,进行SHA2-256算法,注意!是SHA2-256算法而不是SHA3-256,因为很多在线网站的加密都省略了SHA3而直接说是SHA256算法,因此撞坑了,用这个网站,他有SHA2-256,并且一定要选择HEX/BASE16
。返回的内容是32字节。
也就是说,任何内容只要输入0x2合约,那么0x2合约就只会执行SHA-256方法对内容进行hash
那现在这个问题就转化了。首先传一个0x2的地址,符合要求,还需要传入一个bytes字符串。然后把isValidSignature()
的函数签名、hash常量、外部传入的bytes字符串,这3个东西进行abi编码之后,做一个SHA2-256计算,要求返回结果的前4个字节是isValidSignature的函数签名,即:0x1626ba7e
。然后编码情况大概如下:
1 2 3 4 5
| // 1626ba7e // IERC1271.isValidSignature.selector:isValidSignature(bytes32,bytes meomry) // 19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb528 // MAGIC // 0000000000000000000000000000000000000000000000000000000000000040 // 实际数据的offset // 0000000000000000000000000000000000000000000000000000000000000020 // 取数据长度0x20字节(高位算起) // 实际签名数据,要填充到32字节
|
因为有部分内容是固定的,我们所需要做的是,将实际的签名数据不断变化,直到hash结果符合条件,进行爆破:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| const crypto = require('crypto');
function decimalToHex(d, padding) { var hex = Number(d).toString(16); padding = typeof (padding) === "undefined" || padding === null ? padding = 2 : padding;
while (hex.length < padding) { hex = "0" + hex; } return hex; } var baseStr = "1626ba7e19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb52800000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000020"
var max = 2**32;
for(i=0; i< max; i++) { var obj=crypto.createHash('sha256'); var nonceStr = decimalToHex(i, 64); var str = baseStr + nonceStr; var buf = Buffer.from(str, "hex") obj.update(buf) var res = obj.digest('hex'); if (res.substr(0, 8) == '1626ba7e') { console.log('find', i, nonceStr); break; } if (i % 1000000 == 0) { console.log(i, nonceStr); } }
|
最终,程序跑了三个小时我日(10835.736 秒 = 180.5956 分钟),终于输出了如下结果:
1 2 3
| find 3341776893 00000000000000000000000000000000000000000000000000000000c72f77fd
[Done] exited with code=0 in 10835.736 seconds
|
最终我们得到了一个有效的签名:00000000000000000000000000000000000000000000000000000000c72f77fd
然后整理如下:
1 2 3 4 5 6 7 8
| 1626ba7e19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb5280000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000c72f77fd
整理 1626ba7e 19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb528 0000000000000000000000000000000000000000000000000000000000000040 0000000000000000000000000000000000000000000000000000000000000020 00000000000000000000000000000000000000000000000000000000c72f77fd
|
解题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| pragma solidity >=0.5.0; // 注意版本,一些库的版本很低,foundry无法通过编译
import "../../src/05.vanity/Setup.sol"; import "forge-std/Test.sol"; import "../../src/05.vanity/Challenge.sol"; pragma abicoder v2; // foundry提示说要添加这个否则不兼容
contract vanityTest is Test{
Setup level; Challenge challenge;
function setUp() public { level = new Setup(); challenge = level.challenge(); }
function test_isSolved() public { // 解法1: //IChallenge(address(challenge)).solve(address(0x0000000000000000000000000000000000000002), hex"8cf1a8bb"); // 解法2: IChallenge(address(challenge)).solve(address(0x0000000000000000000000000000000000000002), abi.encodePacked(uint256(3341776893))); assertEq(level.isSolved(),true); }
}
interface IChallenge{ function solve(address , bytes memory ) external; }
|
答案并不唯一,网上另外一个答案8cf1a8bb应该是不同的爆破方法算出来的,签名不一样,但是结果一样,它的输入是:
1 2 3 4 5
| 1626ba7e 19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb528 0000000000000000000000000000000000000000000000000000000000000040 0000000000000000000000000000000000000000000000000000000000000004 // 取数据长度4字节(高位算起) 8cf1a8bb00000000000000000000000000000000000000000000000000000000 // 实际签名数据
|
我的是
1 2 3 4 5 6 7
| 1626ba7e 19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb528 0000000000000000000000000000000000000000000000000000000000000040 0000000000000000000000000000000000000000000000000000000000000020 // 取数据长度0x20字节(高位算起) 00000000000000000000000000000000000000000000000000000000c72f77fd // 实际签名数据
结果:1626BA7EEB6B28B0484CB0562A5AEB2004E5F6A5C63E04AE3FA810950D1BC251
|
因此,一个版本是取前4字节的内容,一个版本是取前0x20字节的内容,我的是后者。
1 2 3 4 5 6 7 8 9 10 11
| Traces: [38820] vanityTest::test_isSolved() ├─ [27413] Challenge::solve(0x0000000000000000000000000000000000000002, 0x8cf1a8bb) │ ├─ [120] PRECOMPILE::sha256(1626ba7e19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb528000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000048cf1a8bb00000000000000000000000000000000000000000000000000000000) [staticcall] │ │ └─ ← 0x1626ba7e11c9fdc6c495f346beb65e2f712676389ec7733846f0457a36113dc1 │ └─ ← () ├─ [853] 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f::isSolved() [staticcall] │ ├─ [276] Challenge::bestScore() [staticcall] │ │ └─ ← 19 │ └─ ← 0x0000000000000000000000000000000000000000000000000000000000000001 └─ ← ()
|