ChCore-lab0

Lab 0: 爆弾!!!

这是OS操作系统的前序实验。。。
(p.s. 怎么一上来就让人这么累。。。)

1. 准备实验环境

基于Ubuntu 22.04.2 ARM64.
首先,我们将我们的学号填入到student-number.txt中。

其次,在linux上安装所需要的东西。如果没有gcc,g++(编译器),gdb(调试器)则首先运行:

sudo apt-get install -y build-essential
sudo apt-get install -y gcc
sudo apt-get install -y gdb
sudo apt-get install -y g++
sudo apt-get install -y make

其中-y是默认做出yes选择。如果出现资源占用,最简单粗暴的方法就是重启我们的linux。(doge)
安装完成后我们检查一下我们的编译器。

which命令用于发现编译器藏在哪里,剩下的version就是展示我们的版本号。

接下来,我们在终端中输入

make bomb

这样就会收获一个独属于自己的专属炸弹(真好。。。)

介于我的ubuntu本身就是aarch64架构,因此完全不用担心gdb用不了。这里还是基于x86/64来进行使用。其实只需要再安装qemu和gdb-multiarch即可。
输入:

sudo apt-get install -y qemu
sudo apt-get install -y gdb-multiarch

接下来我们需要开启两个终端,分别运行:

make qemu-gdb
make gdb

出现这样的画面:

第一个终端:

第二个终端:

就可以准备开始实验了。

2. 実験が始める!

开始我们的实验前,我们需要了解一些基本的汇编级别调试。

  1. disassemble:显示目前正在运行的函数/模块的机器级源码。
  2. nexti:机器级源码层面向下运行1步。nexti 5 则代表向下运行5步。但是不会进入新的函数/模块,会继续在当前模块向下运行。
  3. stepi:机器级源码层面向下运行1步。stepi 5 则代表向下运行5步。遇到调用新的函数/模块是会进入该模块。
  4. p (char) x3:x3p/cx3来代替p (char).
  5. x $x3:显示x3所存地址指向的内存空间里的值。x/s 为打印字符串, x/c为打印字符。
  6. info register/breakpoints:显示当前所有寄存器/断点的情况。有助于我们判断如何读取寄存器和管理断点。
  7. continue/c:继续运行一直到遇到断点停止或程序退出。
  8. break main:为main函数打上断点。没有符号名时,采用 break *0x400a00来为具体的机器级源码打上断点。

2.1 phase_0

首先我们先给main函数打上断点。然后运行到main处显示机器级源码。

break main
c
disassemble

我们可以看到main里的机器级源码了!!!

从中我们可以发现我们有6个炸弹需要拆除。我们首先在第一个阶段上打上断点,这样在拆除炸弹时就可以跳转到入口处。

在另一个终端上随便输入一些东西。我们假设输入1,然后我们stepi进入到第一个阶段的炸弹拆除中。

前两行函数其实就是正常的入栈操作,将存有调用入口的地址和传入参数分别入栈后,再将栈顶的值存入寄存器中,有助于递归回调函数。

观察+12到+24处的函数,我们可以发现,函数从我们的输入中读入一个int型整数,并将其传给w0寄存器。w1从一个我们无法看到的内存空间(0x4a0084)中读取了一个数字,并与我们的输入进行比较。一旦有所不同,我们的炸弹将立刻被引爆。所以在读入数字后,我们需要查看w1里存了什么。

我们可以看到答案是2022. 一旦炸弹爆炸,我们需要重启make qemu-gdb 和 make gdb。但是我们可以将答案存入ans.txt中,采用以下命令输入答案。

make qemu-gdb < ans.txt

这样就不需要每次用手输入新的答案。

2.2 phase_1

很明显它已经暴露了。看到了strcmp就说明我们需要进行字符串的比较。

答案已经显而易见。

2.3 phase_2

打好断点,我们来看phase_2.

标签首先提示我们需要读入8个数。第一个数存储于栈顶sp+0x20(32)处,一个int型数占4byte。因此接下来+20到+36就是读取我们输入的第一个和第二个数,并且第一和第二个数必须为1.

接下来后面的行为就是:读取最后一个数和前一个数,新的数相当于这两个数的和再加上4.很像fibonacci数列。

0x4007b8 <+48>: add x19, sp, #0x20 // 保存第一个数的地址
0x4007bc <+52>: add x20, sp, #0x38 // 保存最后一个数的地址
0x4007c0 <+56>: b 0x4007d0 <phase_2+72> // 跳转。
0x4007c4 <+60>: add x19, x19, #0x4 // 将指针移到第二个数。
0x4007c8 <+64>: cmp x19, x20 // 检查是否到了结尾,如果结尾则返回。
0x4007cc <+68>: b.eq 0x4007f4 <phase_2+108>
0x4007d0 <+72>: ldr w0, [x19] // 读入第一个数的内容。
0x4007d4 <+76>: ldr w1, [x19, #4] // 读入第二个数的内容。
0x4007d8 <+80>: add w0, w0, w1 // 两数相加
0x4007dc <+84>: add w0, w0, #0x4 // 再加4
0x4007e0 <+88>: ldr w1, [x19, #8]
0x4007e4 <+92>: cmp w1, w0 // 比较第三个数是不是按照前面规则得到的数字。
0x4007e8 <+96>: b.eq 0x4007c4 <phase_2+60>
0x4007ec <+100>: bl 0x400af4 <explode>
0x4007f0 <+104>: b 0x4007c4 <phase_2+60>
0x4007f4 <+108>: ldp x19, x20, [sp, #16]
0x4007f8 <+112>: ldp x29, x30, [sp], #64
0x4007fc <+116>: ret

这样我们需要填入的数就是
1 1 6 11 21 36 61 101

phase_3

难度有些上升了,不过不怕,慢慢来看。
(真长啊,以前的人是怎么用汇编写游戏的。。。)

这个__isoc99_sscanf函数用于识别参数个数并将输入的数压入栈中。我们需要清醒地认识到,入栈时,栈顶指针的地址将减小。因此先压入第一个参数,再压入第二个参数,则第二个参数的地址要小于第一个参数的地址。在这里的机器语言中,我们可以发现[sp+28][sp+24]是第二个参数的位置。

从+36行开始读入第一个数,我们可以从后面看见,读入的数可以有3种选择。2,3,4均可。否则我们就会粉身碎骨。

我们首先来看首位数字是2的情况。

0x400844 <+68>: ldp x29, x30, [sp], #32
0x400848 <+72>: ret
...
0x400884 <+132>: ldr w0, [sp, #24] //读入第二个数字,第一个数字存在sp+28处
0x400888 <+136>: eor w0, w0, w0, asr #3 //将w0进行算术右移3位后,加上w0,并与自己进行异或,存入w0。
0x40088c <+140>: and w0, w0, #0x7 //取消高位1,即将其处理为不大于7的正数。
0x400890 <+144>: ldr w1, [sp, #28] // 将得到的数与第一个输入的数字比较,相等则成功拆弹。
0x400894 <+148>: cmp w0, w1
0x400898 <+152>: b.eq 0x400844 <phase_3+68>
0x40089c <+156>: bl 0x400af4 <explode>

这样我们很明显可以发现,我们有多个答案。这里我们给出一个最简单的答案:2 2.这样在+132处得到的w0即为2,与w1相同。

接下来来看首位数字是3的情况。

0x400844 <+68>: ldp x29, x30, [sp], #32
0x400848 <+72>: ret
...
0x400854 <+84>: ldr w2, [sp, #24]
0x400858 <+88>: mov w0, #0x6667 // #26215存入w0中
0x40085c <+92>: movk w0, #0x6666, lsl #16 // w0=0x66666667.
0x400860 <+96>: smull x0, w2, w0 // w2与w0相乘,存入x0。
0x400864 <+100>: asr x0, x0, #34 // 将x0算术右移34位。
0x400868 <+104>: sub w0, w0, w2, asr #31 // w0=w0-w2算术右移31位。
0x40086c <+108>: add w1, w0, w0, lsl #2 // w1=w0+w0逻辑左移两位
0x400870 <+112>: sub w1, w2, w1, lsl #1 // w2-w1逻辑左移两位
0x400874 <+116>: add w0, w1, w0 // w0=w1+w0,并比较w0是否为3.
0x400878 <+120>: cmp w0, #0x3
0x40087c <+124>: b.eq 0x400844 <phase_3+68>
0x400880 <+128>: bl 0x400af4 <explode>

很明显最简单的答案为3 3.

最后是首位数字为4:

0x400884 <+132>: ldr w0, [sp, #24]
0x400888 <+136>: eor w0, w0, w0, asr #3
0x40088c <+140>: and w0, w0, #0x7
0x400890 <+144>: ldr w1, [sp, #28]
0x400894 <+148>: cmp w0, w1
0x400898 <+152>: b.eq 0x400844 <phase_3+68>
0x40089c <+156>: bl 0x400af4 <explode>

和之前的套路类似,我们就不在赘述了。最简单的答案为4 4.

phase_4

坏了,看到了encrypt,还进行了两段加密。我们慢慢来解开其到底是如何进行加密的。
首先我们先随便输入一串文字:ABCDEFGHIJK。

运行到+16处,我们观察一下x0(也就是w0,只是w0为x0的低32位)是什么。可以看到,在+12处我们将x0指向的字符串的地址复制给了x19。现在x19指向的是我们刚刚输入的字符串。

运行到+24处,我们观察一下存入x20的是什么,也就是x0发生了什么变化。

可以看到,x0=11,也就是我们输入的字符串的长度。那么刚刚在+16调用的函数就是统计输入字符串的长度,并且其长度不能大于10.一旦大于10将粉身碎骨。

于是我们将输入改成ABCDEFGHIJ。

x20存入的是我们的字符串长度,x19指向我们输入字符串的第一个地址。这样我们将这两个作为参数传入到encrypt_method1当中。
step进入其中:

0x4008e8 <+32>: mov x4, x2
0x4008ec <+36>: mov x2, #0x0
//进入循环
0x4008f0 <+40>: lsl x5, x2, #1
0x4008f4 <+44>: ldrb w5, [x0, x5]
0x4008f8 <+48>: strb w5, [x4], #1
0x4008fc <+52>: add x2, x2, #0x1
0x400900 <+56>: cmp w3, w2
0x400904 <+60>: b.gt 0x4008f0 <encrypt_method1+40>

这个循环就是提取字符串中的奇数位的字符,并将其存储在一块内存中。

0x400918 <+80>: sub w4, w1, w5
0x40091c <+84>: sub w2, w5, w3
0x400920 <+88>: add x2, x0, w2, sxtw #1
0x400924 <+92>: add x2, x2, #0x1
0x400928 <+96>: mov x1, #0x0
0x40092c <+100>: add x3, sp, #0x10
0x400930 <+104>: add x5, x3, w5, sxtw
0x400934 <+108>: lsl x3, x1, #1
0x400938 <+112>: ldrb w3, [x2, x3]
0x40093c <+116>: strb w3, [x5, x1]
0x400940 <+120>: add x1, x1, #0x1
0x400944 <+124>: cmp x1, x4
0x400948 <+128>: b.ne 0x400934 <encrypt_method1+108>
0x40094c <+132>: add x1, sp, #0x10
0x400950 <+136>: bl 0x421cc0 <strcpy>

完成这一部分之后,我们的字符串将会重新变成奇数位拼接偶数位,即ACEGIBDFHJ。
在encrypt_method2打上断点,continue跑步进入encrypted_method2!


前面从+0到+40全部都是读入数据。在读入数据完成后,我们需要关注的是x19,x20,x21,x22这四个寄存器的值,因为他们是除了帧指针x29和栈指针x30以外保存了有关x0和x1两个参数的相关指针。

我们可以看到,x19指向我们前面处理过的字符串,x20保存了字符串的长度数据。x21和x22目前我们还不知道是用来做什么的,但是别着急,我们接着往下。

我们发现这里进行了一些操作,首先将x19存有的指向字符串的首地址存于x20,然后获取首个字符,检查该字符ascii是否大于0x61(97).那么我们通过man ascii可以看到:

必须是小写字母即之后的一些符号,炸了。😦
那我们重新再来,输入abcdefghij试一试。

通过像486一样的能力,我们再一次回到了被炸死的地方。这一次,我们成功通过,并来到了+44处。

0x400990 <+44>: ldrb w1, [x20]
0x400994 <+48>: ldr x0, [x22, #8]
0x400998 <+52>: add x0, x0, x1
0x40099c <+56>: ldurb w0, [x0, #-97]
0x4009a0 <+60>: strb w0, [x20]
0x4009a4 <+64>: add x19, x19, #0x1
0x4009a8 <+68>: cmp x19, x21

我们来观察一下这段。首先,我们从x20处载入一个字节,也就是一个字符。接着,我们载入了前面神秘的x22+8处内存的内容到x0。这到底是什么呢?

可以看见这是一个字符串!所以此时x0存储的是这个字符串的地址。那么接下来的三行就很明显了,我们建立了一个如下的映射:
新字符=原字符+x0字符串-97.用人话来讲就是:

原字符:abcdefghijklmnopqrstuvwxyz
新字符:qwertyuiopasdfghjklzxcvbnm

回到phase_4.观察到在进入strcmp之前,x1获得了某个神秘的地址:

我们可以看到加密后的密文:

经过我们刚刚发现的加密过程,逆向思考可以得到:

isggstsvke
hloolelwrc
helloworlc

因此我们的答案即为:helloworlc.

phase_5

终于来到了最后一个实验。老规矩,先看看这个模块里是什么:

短,好,喜欢。首先,提示我们要读入一个整数。然后我们会让x1获得某个地址(说明该地址是某个数据结构/字符串的开始,然后进入一个名为func_5的函数,最终的结果是让x0内存有3这样一个数。否则粉身碎骨。。。

接下来我们进入func_5.

从+0到+28,我们获得神秘的数据结构的第一个内容,并将其与我们的输入比较。如果相等,我们就功亏一篑。如果等于0,说明我们遍历了整个数据结构。返回。

首个存储的数是49.我们的输入为5.因此没有爆炸!

那么我们就很好奇这个数据结构里面存储着什么?我们需要查看一下这部分的汇编语言。我们先以十进制打印一下x19的第一个数据,发现其位于一个叫做search_tree的数据结构中。

采用disassemble命令。我们通过试探法得知search_tree数据结构的边缘在+168.因此我们打印:

我们可以发现有以下比较重要的部分:

0x4a0070 <search_tree+0>: udf #49
0x4a0088 <search_tree+24>: udf #20
0x4a00a0 <search_tree+48>: udf #88
0x4a00b8 <search_tree+72>: udf #3
0x4a00d0 <search_tree+96>: udf #37
0x4a00e8 <search_tree+120>: udf #55
0x4a0100 <search_tree+144>: udf #91

在arm汇编指令中,.inst指令可以强制干涉PC的指向,使其直接指向所需要指向的地址。

接着我们对比上面func_5中的指令:

<func_5>
... // 检查是否有相等,出现则爆炸,当出现读取到0的时候返回。
0x400a74 <+40>: cmp w0, w20
0x400a78 <+44>: b.le 0x400aa0 <func_5+84>
0x400a7c <+48>: ldr x1, [x19, #8]
0x400a80 <+52>: mov w0, w20
0x400a84 <+56>: bl 0x400a4c <func_5>
0x400a88 <+60>: lsl w0, w0, #1
...
ret
...
0x400aa0 <+84>: ldr x1, [x19, #16]
0x400aa4 <+88>: mov w0, w20
0x400aa8 <+92>: bl 0x400a4c <func_5>
0x400aac <+96>: lsl w0, w0, #1
0x400ab0 <+100>: add w0, w0, #0x1
...
ret

结合前面的指令我们可以发现:
当我们输入的数大于当前节点的数时,我们寻找的是当前节点+16的节点,进入递归。当输入的数小于当前节点的数时,我们寻找当前节点+8的节点,进入递归。当输入等于当前节点的数时,虽然进入了+16的递归但是很快就会爆炸。这很像是“遍历AVL树”。我们可以画出这样一个树:

那么接下来我们就要思考如何才能使得w0最终等于3.我们发现,在递归调用函数后,每次返回时分别有以下两种操作:

  1. 输入的数字大于当前节点数字,w0=w0*2,w0=w0+1.
  2. 输入的数字小于当前节点数字,w0=w0*2.

树只有三层,因此我们递归时也只能选择以上操作三次。我们则需要进行操作:2->1->1.由于这是函数返回时进行的操作,所以我们应该选择数字,使得他能够在递归调用时采取1->1->2的方式行动。这样一来我们有(假设输入数字为a):

  1. a>49.
  2. a>88.
  3. 88<a<91.
    所以合理的答案就应该为:89,90这两个数中的任意一个。

拆弹结束。


Edited by mumujun12345. 20240918
勿忘国耻。

posted @   木木ちゃん  阅读(58)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
点击右上角即可分享
微信分享提示