0x00 分析题目

根据官方WP,这道题是有原题的

原题的WP在这:https://ctf.0xff.re/2022/dicectf_2022/cable_management

本题的脚本有:https://ycznkvrmzo.feishu.cn/docx/E92JdQmGxoUwXexnQgpcRaIsn7g

此题据官方WP是

改编了逻辑状态,把整个部分切为两半,第一个部分在异或的基础上加入了一个与门

在第二个部分加入了一个或门

没看懂(

由于这题相对比较抽象,所以会大量引用WP

这题需要对wireworld有所了解(至少知道规则才能做):

Wireworld导线世界规则:

1、空(黑色):永远不变

2、电子头(蓝色):下一代变为电子尾

3、电子尾(白色):下一代变为导线

4、导线(黄色):一般不变,但当周围一圈有一个或两个电子头时变为电子头

0x01 解

ELF,无壳

无从下手,只能先看看main函数

从下面开始看看函数

printf的样子很奇怪,不过unk_2004和unk_2005倒是知道是什么

那么估计sub_1570就是一个判断函数了,

根据WP,这个sub_1220是read_one_bit

read_one_bit is self-explanatory: it reads one bit of the user input. More precisely, it reads a byte from stdin with getc every 8 calls, stores its bits in .data variables. Therefore, each call, it returns a new bit from the user input.

即将用户的输入(char,8bit)转换成bit,然后存储在.data变量中,因此每次调用此函数就会返回从用户输入中解析的一个bit

接下来就抽象起来了,IDA拒绝识别sub_1570这个函数,究其原因,因为栈帧太大

那就看看汇编

1
2
3
4
5
6
7
8
9
10
.text:000000000000159C    lea     r14, map
.text:00000000000015A3 mov edx, offset unk_526990 ; n
.text:00000000000015A8 mov rsi, r14 ; src
.text:00000000000015AB mov rax, fs:28h
.text:00000000000015B4 mov [rsp+19C8h+arg_524FC8], rax
.text:00000000000015BC xor eax, eax
.text:00000000000015BE mov r12, rsp
.text:00000000000015C1 mov r13, rdi
.text:00000000000015C4 mov rdi, r12 ; dest
.text:00000000000015C7 call _memcpy

此函数首先用memcpy将指定的内容(unk_4018,这里改名为map)从.data拷入栈帧中(就是这玩意巨大,共占了0x526990 = 5400976 bytes)

为什么要改名叫map呢?因为在sub_1570这个函数中一直有循环用到这块数据

高亮的cmp中可以发现这个巨大循环的判断条件是0x526990,即要循环5400976次,所以把这个变量命名为map就很合理了(原作者用的英文,所以这里的map应该有种”按图索骥“的那种”图“的意思)

可以粗略去看看这个map变量,会发现里面主要由0x00和0xCD组成,为什么会这样呢?

暂时按下不表,去看看别的函数

在动调过程中可以发现有一个函数一直走不进去(毕竟循环太多了,一时半会肯定进不去)

断点所在的为函数,而一般走到高亮处就执行跳转了,进不去断点所在的函数

那么就手动进去看看

这里进去之后就是WP中的sub_1390了

这里改一下变量名,让其与WP一样

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
bool __fastcall sub_13B0(int idx, __int64 map)
{
unsigned int y; // eax
unsigned int x; // edx
unsigned int right_cell; // ebx
int sum; // esi
unsigned int bottom_cell; // edi
unsigned int left_cell; // ebp
unsigned int top_cell; // eax

y = idx / 2324;
x = idx % 2324;
right_cell = idx % 2324 + 1;
sum = right_cell <= 2323 && y <= 2323 && *(_BYTE *)(map + (int)(right_cell + 2324 * y)) == 236;
bottom_cell = y + 1;
if ( right_cell <= 2323 && bottom_cell <= 2323 && *(_BYTE *)(map + (int)(right_cell + 2324 * bottom_cell)) == 236 )
++sum;
if ( bottom_cell <= 2323 && x <= 2323 && *(_BYTE *)(map + (int)(x + 2324 * bottom_cell)) == 236 )
++sum;
left_cell = x - 1;
if ( bottom_cell <= 2323 && left_cell <= 2323 && *(_BYTE *)(map + (int)(left_cell + 2324 * bottom_cell)) == 236 )
++sum;
if ( y <= 2323 && left_cell <= 2323 && *(_BYTE *)(map + (int)(left_cell + 2324 * y)) == 236 )
++sum;
top_cell = y - 1;
if ( left_cell <= 2323 && top_cell <= 2323 && *(_BYTE *)(map + (int)(left_cell + 2324 * top_cell)) == 236 )
++sum;
if ( x <= 2323 && top_cell <= 2323 && *(_BYTE *)(map + (int)(2324 * top_cell + x)) == 236 )
++sum;
if ( right_cell <= 2323 && top_cell <= 2323 && *(_BYTE *)(map + (int)(right_cell + 2324 * top_cell)) == 236 )
++sum;
return (unsigned int)(sum - 1) <= 1;
}

可能看起来与WP不同,但是实际上逻辑与WP一致,相应的常数这里用的是十进制,对比WP也是一致的

0x913 = 2323 以及0xEC = 236

Since it performs integer division by 2324, it makes sense to view the variables I renamed x and y as coordinates. Furthermore, we can try dumping and visualizing the map as a 2324-bytes-wide image:

由于这些数字都除以了2324,所以这里可以将变量以坐标形式命名为x,y,更进一步地,我们可以试着去将这个map给dump成一个2324bytes长的图片(将一个Byte视为一个像素,然后根据上面这个函数dump出来)

那么这个dump怎么做呢?

诶,让我们回到那个IDA都不想反编译的东西

动调这个时候就派上了用场,让我们在调用上面那个看起来像是画图的函数的地方下个断点,会发现根本断不下来,为什么呢?因为这个地方缺了输入!

让我们输入29bytes长的数据,然后回车,可以发现在这个地方断下来了!

接下来就可以跟着去看看发生了什么了

由这个进去可以发现之后会循环经过很多个cmp

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
.text:00005644A46C75D8                 cmp     al, 11h
.text:00005644A46C75DA jz loc_5644A46C7690
.text:00005644A46C75E0 cmp al, 80h ; '€'
.text:00005644A46C75E2 jnz short loc_5644A46C7630
.text:00005644A46C75E4 mov rsi, r12
.text:00005644A46C75E7 call sub_5644A46C73B0 # start
.text:00005644A46C75EC test al, al
.text:00005644A46C75EE jnz loc_5644A46C76BF
.text:00005644A46C75F4 nop dword ptr [rax+00h]
.text:00005644A46C75F8
.text:00005644A46C75F8 loc_5644A46C75F8: ; CODE XREF: sub_5644A46C7570+BA↓j
.text:00005644A46C75F8 ; sub_5644A46C7570+CE↓j ...
.text:00005644A46C75F8 add rbx, 1
.text:00005644A46C75FC add rbp, 1
.text:00005644A46C7600 cmp rbx, 526990h
.text:00005644A46C7607 jz loc_5644A46C76B1
.text:00005644A46C760D
.text:00005644A46C760D loc_5644A46C760D: ; CODE XREF: sub_5644A46C7570+61↑j
.text:00005644A46C760D movzx eax, byte ptr [r12+rbx]
.text:00005644A46C7612 mov edi, ebx
.text:00005644A46C7614 cmp al, 0CDh
.text:00005644A46C7616 jz short loc_5644A46C7670
.text:00005644A46C7618 jbe short loc_5644A46C75D8
.text:00005644A46C761A cmp al, 0EAh
.text:00005644A46C761C jz short loc_5644A46C7680
.text:00005644A46C761E cmp al, 0ECh
.text:00005644A46C7620 jnz loc_5644A46C76A8

...

.text:00005644A46C7630 cmp al, 1
.text:00005644A46C7632 jnz short loc_5644A46C76A8

...

.text:00005644A46C7670 loc_5644A46C7670: ; CODE XREF: sub_5644A46C7570+A6↑j
.text:00005644A46C7670 mov rsi, r12
.text:00005644A46C7673 call sub_5644A46C73B0
.text:00005644A46C7678 test al, al
.text:00005644A46C767A jnz short loc_5644A46C769A
.text:00005644A46C767C nop dword ptr [rax+00h]

...

.text:00005644A46C76A8 mov byte ptr [rbp+0], 0
.text:00005644A46C76AC jmp loc_5644A46C75F8

这些cmp非常可疑,因为里面有很多0xCD,还记得刚刚提到的map变量里面有很多CD吗?可以合理怀疑一下这两个东西有关

可以验证一下猜想,将map变量dump出来,然后搜索一下cmp的其他字符,比如01,11,80,EA,EC

1
2
3
4
5
6
7
8
9
10
11
12
data = list(open('./re', 'rb').read()[0x3018: 0x3018 + 0x526990])
# 01,11,80,EA,EC
if 0x80 in data:
print("0x80")
if 0x01 in data:
print("0x01")
if 0x11 in data:
print("0x11")
if 0xEA in data:
print("0xEA")
if 0xEC in data:
print("0xEC")

运行一下会发现除了EA都有

所以想要输出图片可能就和这些字符有关系了

再由sub_1390画图函数

可以知道画图的取值不是简单的在数组中的12345,而是与2324有关

1
2
y = idx / 2324;
x = idx % 2324;

所以我们可以写出这样的脚本

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
import sys
import os

data = list(open('./re', 'rb').read()[0x3018: 0x3018 + 0x526990])


def show_state(data, height, width):
f = sys.stdout
for y in range(height):
for x in range(width):
c = data[width * y + x]
if c == 0x01:
f.write('@')
elif c == 0x80:
f.write('$')
elif c == 0x11:
f.write('?')
elif c == 0xec:
f.write('2')
elif c == 0xea:
f.write('1')
elif c == 0xcd:
f.write('0')
else:
f.write(' ')
f.write('\n')


_data = []
width = 122 # 可变,由于vscode的输出大小只有122个字符,所以我设置成这个
height = 55 # 50以后y轴就没有东西输出了
for y in range(height):
# for x in range(width):
_data += data[y * 2324: y * 2324 + width]
show_state(_data, height, width)

结果如下:

上半

下半

非常的抽象

下半的研究价值比较大一点,所以单独截取下半部分进行分析,便于观看,我把他在wireworld中进行了实现

The map mostly consists of 232 of these patterns horizontally glued next to each other. Here is the same pattern in hexadecimal, where I removed null bytes for clarity.

整个图最多包含232张这样的图案,然而在此题中,第四个和第五个图案有所改变(改编了逻辑状态)

由下半可知

Some patterns have an 0xEC byte on the fifth line, some don’t and have a 0xCD instead. This may be important data in flag verification.

All the patterns have an 0x11 byte at the top center.

在一个周期中,图案的中部有些是0xEC(输出图中显示为2),有些则是0xCD(输出图中显示为0),这或许对解flag有效

所有的图案都有0x11(输出图中显示为?)在顶上,然而这题被改过,所以可以看到有个部分的0x11后面凸起来了,同时在凸起来部分的下面可以看到有一段不自然的空白,可能这就是所说的“改编逻辑状态”了

接下来回到我们的IDA都不想反编译的函数

找到cmp 0x11的位置,看看这个0x11跳转到了哪里

1
2
3
4
5
6
7
8
9
10
.text:00005644A46C75D8                 cmp     al, 11h
.text:00005644A46C75DA jz loc_5644A46C7690

...

.text:00005644A46C7690 loc_5644A46C7690: ; CODE XREF: sub_5644A46C7570+6A↑j
.text:00005644A46C7690 xor eax, eax
.text:00005644A46C7692 call qword ptr [r13+0]
.text:00005644A46C7696 test al, al
.text:00005644A46C7698 jz short loc_5644A46C7680

It calls the function pointed by r13, which is the function pointer argument passed by main (read_one_bit). Therefore, for each of these patterns in the map, one bit of the user input is read. This confirms that the flag is indeed 232 / 8 = 29 bytes long.

这段跳转调用了一个r13寄存器内存储的函数,这个函数就是main函数中的read_ont_bit函数。因此,对于图中的每一个图案,都在0x11所在位置读入了一个用户输入的字节中的一个bit,而232/8=29Bytes,这也就解释了为什么输入是29个字符

由wireworld的规则,我们可以对输入的结果进行一个模拟,从而知道输入在图案中的运行过程,模拟的脚本如下:

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import sys
import os

data = list(open('./re', 'rb').read()[0x3018: 0x3018 + 0x526990])
file = open('test.txt', 'w')


def update(data, height, width):
_data = data[:]
for y in range(height):
for x in range(width):
c = data[width * y + x]
if c == 0xcd:
if check_pos(data, height, width, y, x):
_data[y * width + x] = 0xec
elif c == 0xea:
_data[y * width + x] = 0xcd
elif c == 0xec:
_data[y * width + x] = 0xea
elif c == 0x11:
_data[y * width + x] = [0xcd, 0xec][values.pop(0)]
elif c == 0x01:
pass
elif c == 0x80:
assert not check_pos(data, height, width, y, x)
return _data


def check_pos(data, height, width, y, x):
count = 0
for _x in range(x - 1, x + 2):
for _y in range(y - 1, y + 2):
if _x < 0 or _x >= width or _y < 0 or _y >= height or (_x == x and _y == y):
continue
if data[_y * width + _x] == 0xec:
count += 1
return count == 1 or count == 2


def show_state(data, height, width):
f = sys.stdout
for y in range(height):
for x in range(width):
c = data[width * y + x]
if c == 0x01:
f.write('@')
elif c == 0x80:
f.write('$')
elif c == 0x11:
f.write('?')
elif c == 0xec:
f.write('1')
elif c == 0xea:
f.write('2')
elif c == 0xcd:
f.write('3')
else:
f.write(' ')
f.write('\n')


_data = []
width = 66
for y in range(26, 50):
_data += data[y * 2324: y * 2324 + width]

values = [0, 1, 1, 0, 1, 1, 1, 0] # n的ascii码(由于flag的第一个字符是n,因此这个输入一定是对的)
# values = [1, 0, 0, 1, 0, 0, 0, 1] # n的ascii码的反码,结果一定是错误的

while 1:
os.system('cls')
show_state(_data, 50 - 26, width)
input()
_data = update(_data, 50 - 26, width)

输入在这些图案中会进行扩散操作

对正确输入和错误输入进行模拟,可以知道本题的目的就是不让电子头到达$的位置

所以就应该写脚本了(这个真不会,抄抄大哥的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
data = list(open('./re', 'rb').read()[0x3018: 0x3018 + 0x526990])

values = [0, 1, 1, 0]
values.append(1)
for i in range(232 - 5):
assert data[28 * 2324 + 63 + i * 10] == 0x11
last_input1 = values[-1]
assert data[32 * 2324 + 63 + i * 10 - 10] in [0xec, 0xcd]
last_input2 = int(data[32 * 2324 + 63 + i * 10 - 10] == 0xec)
assert data[32 * 2324 + 63 + i * 10 - 10 + 5] in [0x00, 0xcd]
gate_type = data[32 * 2324 + 63 + i * 10 - 10 + 5] == 0xcd
values.append(last_input1 ^ last_input2 ^ gate_type)

values = ''.join(str(j) for j in values)
print(values)

flag = []
for i in range(0, len(values), 8):
flag.append(int((values[i: i + 8]), 2))

print(bytes(flag))
input()
# b'nkctf{wiReWo-ld_1s_n0t_goOD!}'

算VM吗?我不知道(

不过原题WP提供了一种爆破的方法,据说要跑40min:

After around 40 minutes, the flag is succesfully leaked! And we still have no clue what the challenge is about.

而这题的官方WP写了这个:

可以发现虽然不正确,但是还是有部分正确的,这是因为虽然加入了逻辑与门和逻辑或门造成
了多解的情况,但是因为本身只有 0 和 1 的异或状态,结果有机会是重合的,利用这点可以随
便修改一些 0 和 1,就可以得到其它部分的字符串。

好吧,我选择相信大哥(