7

Paradigm CTF-LOCKBOX

 3 years ago
source link: https://learnblockchain.cn/article/2719
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Paradigm CTF-LOCKBOX

这是Paradigm CTF的一道题,整体来说难度不算特别大,与之前的STATIC CALL难度相似。😆这道题考察的知识点比较多,涉及到ABI编码,EIP-712等。可以看作是一个编码相关的大集合题目。

同样本文的参考连接如下:Paradigm CTF solutions (github.com)

目前作者正在找智能合约相关的工作,希望能跟行业内人士多聊聊 🐟 。如果你觉得我写的还不错,可以加我的微信:woodward1993

题目分析:

contract Setup {
    Entrypoint public entrypoint;
    
    constructor() public {
        entrypoint = new Entrypoint();
    }
    
    function isSolved() public view returns (bool) {
        return entrypoint.solved();
    }
}

可以看到该题的设置很简单,就是创建一个Entrypoint合约,最后判断该合约是否solved。下面我们分析下目标合约:

1️⃣ Stage 合约

该合约是后续合约的母合约,一共干了三件事:1. 定义了一个全局变量address, 2. 定义一个函数getSelector(), 3. 定义了一个modifier。比较有意思的是这个modifier, 我们将逐行分析下该modifier. 该Modifier的作用是调用全局变量存储的地址上的solve方法。

modifier _(){
	_;
	assembly{
	//第一步:拿到全局变量Stage, 判断如果stage的值没有更新则返回
	let stage := sload(0x00)
	if iszero(stage) {
		return(0,0)
	}
	//第二步:调用Stage上的getSelector()函数,将结果存储在内存中
	// keccak(abi.encode("getSelector"))[0:0x04] = 0x034899bc
	mstore(0x00, 0x034899bc00000000000000000000000000000000000000000000000000000000)
	switch call(gas(),stage, 0, 0x00, 0x04, 0x00, 0x04)
	case 0x00 {
		revert(0, returndatasize())
	}
	case 0x01 {
		returndatacopy(0x00,0x00,0x04)
	}
	//第三步:调用Stage合约,函数选择器为getSelector()函数的返回值,参数为CALLDATA[0x04:]
	calldatacopy(0x04, 0x04, sub(calldatasize(),0x04))
	switch call(gas(), stage, 0, 0x00, calldatasize(), 0x00, 0x00)
	//第四步:如果调用失败,则REVERT
	case 0x00 {
		returndatacopy(0x00,0x00,returndatasize())
		revert(0, returndatasize())
	}
	case 0x01 {
		returndatacopy(0x00,0x00,returndatasize())
		return(0, returndatasize())
	}
	}
}

2️⃣ EntryPoint合约

bool public solved;
    
function solve(bytes4 guess) public _ {
    require(guess == bytes4(blockhash(block.number - 1)), "do you feel lucky?");
    solved = true;
}

对于EntryPoint合约,它的要求很简单:即传入的参数guess 满足 guess == bytes4(blockhash(block.number - 1)即可。

故此时我们需要传入的参数为:

bytes memory data = abi.encode(
		Entrypoint.solve.selector,
		bytes4(blockhash(block.number - 1)) // => bytes4 guess
)

3️⃣Stage1 合约

function solve(uint8 v, bytes32 r, bytes32 s) public _ {
        require(ecrecover(keccak256("stage1"), v, r, s) == 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, "who are you?");
}

由于modifier的作用,我们传入的同一个参数会依次被EntryPoint, stage1, stage2, stage3, stage4, stage5调用,故我们需要让该参数依次满足所有的要求。Stage1的要求是传入3个参数,让其满足ecrecover(keccak256("stage1"), v, r, s) == 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf. 首先我们需要理解ecrecover函数的作用

查阅黄皮书得知:ECDSARECOVER函数是与ETH的交易签名相关。它假设发送方有一个有效的私钥,它是一个随机选择的正整数(以大数形式表示为长度为32bytes的字节数组)

ECDSAPUBKEY(pr​∈B32​)≡pu​∈B64​ECDSASIGN(e∈B32​,pr​∈B32​)≡(v∈B1​,r∈B32​,s∈B32​)ECDSARECOVER(e∈B32​,v∈B1​,r∈B32​,s∈B32​)≡pu​∈B64​

其中pu​为公钥,长度为64bytes的字节数组(由两个正整数<2256连接而成),pr​为私钥,长度为32bytes的字节数组(或上述范围内的单个正整数),e为交易的哈希值。假设v是 "恢复标识符"。恢复标识符是一个长度为1byte的值,用于指定r为x值的曲线点的坐标的奇偶性和有限性;这个值的范围是[27,30],值27代表偶数y值,28代表奇数y值。

当且仅当r,s,v同时满足如下三个条件时,认为其有效:

0<r<secp256k1n0<s<secp256k1n÷2+1v∈{27,28}
secp256k1n=115792089237316195423570985008687907852837564279074904382605163141518161494337secp256k1n=2256−232−977

对于一个给定的私钥,Pr​,它所对应的以太坊地址A(pr​)(一个160位的值)被定义为相应ECDSA公钥的Keccak哈希值的最右边160位。即:

A(pr​)=B96..255​(KEC(ECDSAPUBKEY(pr​))A(pr​)=KECCAK(Pu​)[−159:−1]

要签署的交易哈希值,h(T),是交易的Keccak哈希值。有两种不同风格的签名方案。一种是在没有后三个签名元素的情况下操作,正式描述为Tr​、Ts​和Tw​。另一个则对九个元素进行hash。 可以参考EIP-155

LS​(T)≡{(Tn​,Tp​,Tg​,Tt​,Tv​,p)                   ifv∈{27,28}(Tn​,Tp​,Tg​,Tt​,Tv​,p,β,(),())                   otherwise​where                                                                                               p≡{Ti​                   if Tt​=0Td​                   otherwise​h(T)≡KEC(LS​(T))

结合EIP-155得知:

六个参数分别是:(Nonce, gasprice, startgas, to, value, data), 其中V值的取值范围为[27, 28]

九个参数分别是:(Nonce, gasprice, startgas, to, value, data, chainid, 0, 0), 其中V值的取值范围变更为 [0,1]+chainid∗2+35。

chainid的取值表如下:

CHAIN_ID 公有链名称 1 以太坊主链 2 Morden链 3 Ropsten 以太坊测试链 4 Rinkeby 以太坊测试链 5 Goerli 以太坊测试链 1337 Geth 以太坊私有链

如下示例中,一笔交易的参数如下:

nonce = 9, gasprice = 20 * 10**9, startgas = 21000, to = 0x3535353535353535353535353535353535353535, value = 10**18, data='', chainid=1, 0, 0

整理为RLP的list为[9,0x4a817c800,0x5208,0x3535353535353535353535353535353535353535,0xDE0B6B3A7640000,,1,0,0]

则RLP编码为:

192+44=0xEC
09
8504a817c800
825208
943535353535353535353535353535353535353535
880DE0B6B3A7640000
80
01
80
80

则经过RLP编码后的数据LS​(T)为:0xec098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a764000080018080

❓最后的80018080是怎么编码出来的呢?为什么不是80100 =>["",1,0,0] ✔️应该编码为0x80018080

❓以及0x4a817c800的长度不足5bytes, 最后编码在前面补0?✔️ 其实质是值,而不是bytes,故在左侧补零。类似于9,补零成09,占据一个byte

❓空字符串被编码为80 ✔️ 空字符串和数值0被编码为0x80

则需要签署的交易hash值为:h(T)为keccak256(0xec098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a764000080018080)=daf5a779ae972f972197303d7b574746c7ef83eadac0f2791ad23db92e4c8e53

则签名好的交易定义为:G(T,pr​)

G(T,pr​)≡Texcept:(Tw​,Tr​,Ts​)=ECDSASIGN(h(T),pr​)

结合上文,即通过ECDSASIGN函数,将交易hash值和私钥作为参数传入该函数,得到三个返回值,依次是v,r,s.

Tw是恢复标识符或者是 [0,1]+chainid∗2+35。 在第二种情况下,v​是链标识符chainid*2+[0,1]+35,值35和36通过指定y的奇偶性来承担`恢复标识符'的角色,值35代表偶数值,36代表奇数值。

当使用私钥对交易哈希签名后,得到v,r,s三个值,然后可以通过ECDSARECOVER函数,分别以交易哈希,v,r,s为参数值,其返回值的最右侧160位就应该是该私钥对应的地址值。可以通过此方法来验证是否位该地址发出的真实签名

S(T)≡B96..255​(KEC(ECDSARECOVER(h(T),v0​,Tr​,Ts​)))v0​≡{Tw​                          if Tw​∈{27,28}28−(Tw​mod2)             otherwise​
∀T:∀pr​:S(G(T,pr​))≡A(pr​)

回到Stage1合约中,我们发现地址:0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, 是一个非常出名的地址,因为其私钥为:0x0000000000000000000000000000000000000000000000000000000000000001, 故针对题意:交易Hash值为keccak("stage1")=0xb6619a2d9d36a2acecba8e9d99c8444477624a46561077a675900f4af2c42c95, 故使用eth-sign方法可以得到v,r,s,注意在一般的web3.js中,在eth-sign时,会在签名的消息前加上\x19Ethereum Signed Message + len(msg)一段bytecode。故需使用不加入此消息的eth-sign函数库。

r = 0x274d91564d07600e8076a8843bd13a374cf43dcd2f5277fb61313f3d5c805b61
s = 0xa129687de0b602825f931363235f7a427088014fb94cde3264efbce58cc04236
v = 0x1c //28

上述值满足,ECDSARECOVER(keccak256("stage1"),0x1c,r,s) == Pu(私钥对应的地址)

bytes memory data = abi.encode(
		Entrypoint.solve.selector,
		(uint(bytes4(blockhash(block.number - 1))) << 224) | 0xff1c, // => bytes4 guess
    	0x274d91564d07600e8076a8843bd13a374cf43dcd2f5277fb61313f3d5c805b61,
    	0xa129687de0b602825f931363235f7a427088014fb94cde3264efbce58cc04236
)

4️⃣Stage2 合约

function solve(uint16 a, uint16 b) public _ {
    require(a > 0 && b > 0 && a + b < a, "something doesn't add up");
}

在进入到Stage2合约之前,我们需要先整理下data数据。注意到Entrypoint中,参数是bytes4,bytes4是从bytes32最左侧取4位byte作为值,而stage1的第一位参数是uint16, 其是从最右侧取2位值,并转换成uint。须注意的是:移位运算符只针对uint有效,对于Bytes类型不适用。故写作:

(uint(bytes4(blockhash(block.number - 1))) << 224) | 0xff1c

在remix中,bytes32和uint256实质是同样的二进制代码,但是uint256得到值是数字表示,可以用于运算。

我们在看下:uint16 a和uint 16 b,我们知道uint16是从bytes32的右侧取2byte的值,故这里:a=0xff1c, b=0x5b61, 则a+b=0x5A7D, 满足条件

调用stage2的calldata为:

0x07e13e4d000000000000000000000000000000000000000000000000000000000000ff1c0000000000000000000000000000000000000000000000000000000000005b61

5️⃣Stage3 合约

function solve(uint idx, uint[4] memory keys, uint[4] memory lock) public _ {
    require(keys[idx % 4] == lock[idx % 4], "key did not fit lock");

    for (uint i = 0; i < keys.length - 1; i++) {
        require(keys[i] < keys[i + 1], "out of order");
    }

    for (uint j = 0; j < keys.length; j++) {
        require((keys[j] - lock[j]) % 2 == 0, "this is a bit odd");
    }
}

要求1:idx需要对4取模,虽然idx是uint256,但实际上并无关系,只需要其uint8(idx)对4取模即可。此时的 idx % 4 = 0xff1c % 4 = 0, 故需保证keys[0]==lock[0]

要求2:要求保证keys中的4个值保持一个递增的关系。现在keys[0]=r, keys[1]=s已经确定且保持递增

要求3:要求对应位置的差值必须是偶数,两个偶数相减肯定是偶数,两个奇数相减肯定也是偶数。故这里我们保证两个值都是偶数即可。

此时的calldata应该为:

0x3f30497e
000000000000000000000000000000000000000000000000000000000000ff1c //idx
274d91564d07600e8076a8843bd13a374cf43dcd2f5277fb61313f3d5c805b61 //r key[0]
a129687de0b602825f931363235f7a427088014fb94cde3264efbce58cc04236 //s key[1]
x
y
274d91564d07600e8076a8843bd13a374cf43dcd2f5277fb61313f3d5c805b61 //lock[0]
0
0
0

6️⃣Stage4 合约

function solve(bytes32[6] choices, uint choice) public _ {
    require(choices[choice % 6] == keccak256(abi.encodePacked("choose")), "wrong choice!");
}

这题的考点主要是abi编码方式,对于不可变长度类型的定长数组的编码方式就是每32bytes依次排列。

可以看到key[2]或者key[3]都可以放置keccak256(abi.encodepacked("choose"))=e201a979a73f6a2947c212ebbed36f5d85b35629db25dfd9441d562a1c6ca896,故可以放置在key[2], 也可以放置在key[3]只要满足要求就行。由于要满足差值为偶数,故只能放置在key[3]中

0x3f30497e
000000000000000000000000000000000000000000000000000000000000ff1c //idx
274d91564d07600e8076a8843bd13a374cf43dcd2f5277fb61313f3d5c805b61 //r key[0]
a129687de0b602825f931363235f7a427088014fb94cde3264efbce58cc04236 //s key[1]
x //key[2]
e201a979a73f6a2947c212ebbed36f5d85b35629db25dfd9441d562a1c6ca896 //keccak(choose)
274d91564d07600e8076a8843bd13a374cf43dcd2f5277fb61313f3d5c805b61 //lock[0]
4
0
0

7️⃣Stage5 合约

function solve() public _ {
    require(msg.data.length < 256, "a little too long");
}

要求整个data的长度不能超过256

pragma solidity 0.4.24;
import "./setup.sol";
contract Hack {
    Entrypoint public entrypoint;
    constructor(address _setup) public {
        entrypoint = Setup(_setup).entrypoint();
    }
    function exploit() public {
        bytes memory data = abi.encodePacked(
        	entrypoint.solve.selector,
            uint(uint16(0xff1c)|(uint(byte4(blockhash(block.number-1))) << 224)),
            bytes32(0x274d91564d07600e8076a8843bd13a374cf43dcd2f5277fb61313f3d5c805b61),
            bytes32(0xa129687de0b602825f931363235f7a427088014fb94cde3264efbce58cc04236),
            bytes32(0xa129687de0b602825f931363235f7a427088014fb94cde3264efbce58cc04238),
            bytes32(keccak256('choose')),
            bytes32(0x274d91564d07600e8076a8843bd13a374cf43dcd2f5277fb61313f3d5c805b61),
            bytes32(0x0000000000000000000000000000000000000000000000000000000000000004)
        );
        uint size = data.length;
        address entry = address(entrypoint);
        assembly{
            switch call(gas(),entry,0,add(data,0x20),size,0,0)
            case 0 {
                   returndatacopy(0x00,0x00,returndatasize())
                   revert(0, returndatasize()) 
            }
        }
    }
}

注意点1:abi.encode('choose')和'choose'的编码并不一致。

'choose' => '0x63686f6f7365'
abi.encode('choose') => 是个动态类型string,先编码head,在编码tail。
0x0000000000000000000000000000000000000000000000000000000000000020 //head offset
  0000000000000000000000000000000000000000000000000000000000000006 //tail[0] length
  63686f6f73650000000000000000000000000000000000000000000000000000 //choose

注意点2:data是指向内存的指针,但是具体的数据指针是add(data, 0x20)位置处。

注意点3:assembly只能访问函数内部的局部变量,访问全局变量要使用sload(entrypoint.slot)来取值


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK