05.vanity
2023-09-18 11:09:40 # 14.Paradigm CTF 2022

vanity

分析

1.全局观

题目给了5个合约,其实挺简单:

  • SignatureChecker.sol:一个检查签名的library
  • Setup.sol:初始化题目和设置题目完成的条件
  • IERC1271.sol
    • 一个非常简单的接口
  • ECDSA.sol
    • ECDSA库
  • 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');

// 将数值从10进制转换为16进制,然后补0
function decimalToHex(d, padding) {
// 转为16进制
var hex = Number(d).toString(16);
padding = typeof (padding) === "undefined" || padding === null ? padding = 2 : padding;

// 在前面补0
while (hex.length < padding) {
hex = "0" + hex;
}
return hex;
}

var baseStr = "1626ba7e19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb52800000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000020"
//
// 1626ba7e // IERC1271.isValidSignature.selector:isValidSignature(bytes32,bytes meomry)
// 19bb34e293bba96bf0caeea54cdd3d2dad7fdf44cbea855173fa84534fcfb528 // MAGIC
// 0000000000000000000000000000000000000000000000000000000000000040 // 实际数据的offset
// 0000000000000000000000000000000000000000000000000000000000000020 // 取数据长度0x20字节(高位算起)
// 实际签名数据,要填充到32字节

// 最多跑多少
var max = 2**32;

for(i=0; i< max; i++) {
// 获取sha256加密算法
var obj=crypto.createHash('sha256');
// 每一轮实际的签名数据
var nonceStr = decimalToHex(i, 64);
// 拼接
var str = baseStr + nonceStr;
// 转为buff
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
└─ ← ()