58anY 解析
本题是 egelloc.nwp 的 reverse 的最后一题,为了避免泄露答案,所有的单词均以倒序拼写。根据前面几关的练习,已知这道题自定义了一个模拟器,拥有自己的指令,寄存器和内存,需要你输入模拟器指令来拿到 flag。但是与前面的题目不同的是,指令和寄存器的标志都是随机的,不是固定的,无法通过静态分析获得,因此需要根据现有已知的条件动态推断出所有的指令和寄存器等。
58anY 的体系结构
内存
内存由一个 byte[1024] 的数组 a 构成。内存的前 768 个 byte 为代码段,保存用户输入的指令。768 后的内存为数据段,数据存储的地址从 0 开始。因为栈寄存器起始为 0,a[768] 往后的一段空间应仅供栈使用,避免出现冲突。
寄存器
寄存器包括4个通用寄存器 a b c d, 栈寄存器 s,指令寄存器i以及标志寄存器 f。寄存器地址紧跟在内存后面,因此在代码中也用 a[1024]\ ~ a[1030] 来访问。在指令中由不同的操作数代表不同寄存器,每个操作数的二进制只有一位为 1,即用 1,2,4,8,16,32,64,128 中的 7 个来代表寄存器。由于模拟器运行时会随机打乱操作数的顺序,无法通过静态分析知道操作数与寄存器的对应关系
指令
每个指令长度为 3 bytes,指令与两个操作数各用一个 byte,但是指令与操作数的位置每道题不一样,仅在最后构造读取flag指令的时候需要注意。每个指令也由 $2^0$ ~ $2^7$ 这 8 个数来表示,对应关系未知。
指令 | 指令描述 |
---|---|
imm op1, op2 | 将 op2 表示的数存入 op1 表示的寄存器 |
add op1, op2 | 将 op2 表示的寄存器的值加到 op1 表示的寄存器 |
stk op1, op2 | 栈操作,op2 不为 0 时,将 op2 表示的寄存器中的值入栈,op1 不为 0 时,再将栈顶数出栈于 op1 表示的寄存器 |
stm op1, op2 | 以 op1 表示的寄存器中的值作为地址,写入 op2 表示的寄存器中的值 |
ldm op1, op2 | 以 op2 表示的寄存器中的值作为地址,读取数据并存入 op1 表示的寄存器 |
cmp op1, op2 | 比较 op1 和 op2 表示的寄存器中的值,并根据大小,是否相等设置相关标志位 |
jmp op1, op2 | op1 为 0 时,无条件跳转到 op2 表示的寄存器中的值,op1 不为 0 时,判断 op1 和标志寄存器与的结果,不为 0 则跳转到 op2 表示的寄存器中的值,通过修改指令寄存器的值实现跳转 |
sys op1, op2 | 根据 op1 的值进行不同的系统调用,并将结果存入 op2 表示的寄存器(exit 不需要将结果存入寄存器)。系统调用需要的参数通过寄存器 a, b, c 传入 |
系统调用
该模拟器实现的系统调用有 open, read, write, sleep, exit 等,使用 open, read, write 即可读取 flag 并打印。
已知条件
虽然随机打乱了寄存器、指令、系统调用和标志位的顺序,我们仍可以观察程序运行的状态来推测出一些指令和寄存器的标志。以下是代码流程中一些需要注意的地方:
- 随机打乱的函数中调用的是 rand() 而非没有 srand(),即每次产生的随机数序列都是相同的,寄存器,指令与操作码的对应关系不会改变。
- 判断指令使用的是 and 操作,顺序参照上表顺序,若指令有多位为 1,则会执行多条指令,若指令为0,则没有对应指令执行,不会报错,继续执行下一条指令。
- 指令寄存器 i 从 0 开始每执行一条指令就加 1,代码段最开始都会置 0,如果程序没有终止,那指令寄存器就会从 0 一直加到 0xFF,之后加一溢出重新变为 0,达到死循环状态。
- 与寄存器相关的操作都使用 == 进行判断操作数对应哪个寄存器,如果没找到对应寄存器或使用0为操作数,则会报错输出 "unknown register" 并退出程序。
- 判断系统调用使用 and 操作,若有多位位 1,则会执行多个系统调用,若没找到对应系统调用,不会报错,继续执行下一条指令。
推断过程
通过构建不同的指令,根据程序执行的状态是退出或者死循环来逐步推测出指令,寄存器,和系统调用的对应关系。
寻找无效寄存器
将指令码设置为 0xff,那么一定会执行 imm,再遍历所有寄存器标志,如果程序退出并输出 "unknown register",那么该标志为 register_invalid
。
for register in set([0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]):
0xff register, register
寻找 sys 指令和 exit 系统调用
遍历指令码和系统调用,并将 op2 设置为 register_invalid
,那么当程序退出且没有输出 "unknown register" 的时候,即为通过 exit 退出。
# try to construct:
# [sys] [exit], register_invalid
possible_values = set([0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80])
for instruction in possible_values:
for syscall in possible_values:
instruction syscall, register_invalid
寻找 imm 指令和指令寄存器 i
现在已知 sys 指令,从剩余指令和所有寄存器中遍历,并将 op2 设置为 register_invalid
,然后添加一条无效寄存器的指令来终止程序。在剩下的指令中 add,stk,stm,ldm,cmp,jmp 都会因为 op2 为无效寄存器而退出,只有 imm 改变指令寄存器i设置的情况会使程序进入死循环。另外要注意特殊情况,当 register_invalid == 1 的时候,指令寄存器 i 被置为 1,执行第二条无效语句也会退出,因此多加一条 imm 语句使终止程序的语句后移。
# try to construct:
# [imm] [i], register_invalid
# 0xff register_invalid, register_invalid
for inst in get_possible_instructions():
for op1 in get_possible_registers():
inst op1, register_invalid
if register_invalid == 1:
inst op1, register_invalid
0xff register_invalid, register_invalid # 用来终止程序
寻找 stk 指令
使用 stk 指令将寄存器 i 置为 0 以达到死循环的目的,若指令不为 stk,则会导致程序退出。
# try to construct:
# [stk] i, 0
# imm register_invalid, 0
for inst in get_possible_instructions():
inst i, 0
imm register_invalid, 0
寻找栈寄存器 s
使用 stk 指令操作栈寄存器 s,注意 stk 指令在两个操作数均不为 0 时会依次进行入栈和出栈操作,入栈时会先将栈寄存器加一,再存入数据。因此使用指令 stk i, s
会使指令寄存器变为 1 进而执行无效寄存器指令退出。其它寄存器会使指令寄存器始终为 0 进入死循环。
# try to construct:
# stk i, [s]
# imm register_invalid, 0
for register_s in get_possible_registers():
stk i, register_s
imm register_invalid, 0
寻找 ldm 指令
目前未知的指令还有 add,stm,ldm,cmp,jmp,使用指令 ldm i, s
会使指令寄存器始终为 0 进入死循环,而其它指令会继续执行遇到无效寄存器指令退出。
# try to construct:
# [ldm] i, s
# imm register_invalid, 0
for inst in get_possible_instructions():
inst i, s
imm register_invalid, 0
寻找 stm 指令
目前未知的指令还有 add,stm,cmp,jmp,已知寄存器只有 s 和 i。寻找 stm 的思路就是随机选择另一个寄存器 reg
(通用寄存器或标志寄存器),配合 stm 与 ldm 来对指令寄存器赋值,跳转到第 4 条无效寄存器指令退出。如果是 add,cmp 指令,内存中不会写入 4,赋给指令寄存器的值为 0,进入死循环。如果是 jmp 指令 jmp reg, s
,需要判断 reg
是否为标志寄存器,若不为标志寄存器,则不会发生跳转,若刚好随机选择了标志寄存器,为了防止跳转到第 4 条指令,需要使跳转条件不成立,因此将 reg
的值设为 0xff-reg
,两个数的与为 0,不会跳转。
# try to construct:
# imm reg, 0xff-reg
# imm s, 4
# [stm] reg, s
# ldm i, reg
# imm register_invalid, 0
reg = get_possible_registers().pop()
for inst in get_possible_instructions():
imm reg, 0xff-reg
imm s, 4
inst reg, s
ldm i, reg
imm register_invalid, 0
寻找 write 系统调用
虽然仍然不能区分剩下的通用寄存器和标志寄存器,但是我们知道标准输出的描述符是 1,因此可以将这五个寄存器的的值都设为 1,在内存地址1的地方写入一个特殊字符,遍历系统调用检查是否打印了特殊字符。
# try to construct:
# imm abcdf, 1
# imm s, 0x99
# stm a, s
# sys [write], register_invalid
for syscall in get_possible_syscalls():
for r in get_possible_registers():
imm r, 1
imm s, 0x99
stm a, s
sys syscall, register_invalid
寻找通用寄存器 abc
知道 write 系统调用后,可以尝试构造一个特殊字符串,遍历寄存器 abc 并调用 write 检查输出来找到这三个寄存器。
# try to construct:
# imm [a], 1
# imm [b], 0x92
# stm [b], [b]
# imm [b], 0x91
# stm [b], [b]
# imm b, 0x90
# stm [b], [b]
# imm [c], 3
# sys write, register_invalid
寻找 sleep 系统调用
将寄存器 a 设为一个较大的值,遍历剩余系统调用,过了很久还没退出的则是 sleep。
# try to construct:
# imm a, 80
# imm b, 0
# imm c, 0
# sys [sleep], register_invalid
寻找 open read 系统调用
在本机新建一个临时文本文件,遍历剩余系统调用尝试打开文件并读取文件内容,然后使用 write 输出,若能成功输出内容则找到了 open 和 read。代码略。
总结
在推测出 open,read 后,就可以读取 flag 并用 write 输出的控制台了。