IGS Arcade 逆向系列(二)- 游戏文件恢复
上一篇文章写到游戏有个破坏分区的保护机制,本篇将深入分析。
作为2007年发布的游戏,此游戏加固机制还是比较落后的,主要是靠一些拼接,修改特征的方法来加固。并没有现代APP加固的那么卷的特性。主要是加固的环节有点多,并且每个游戏都不一样。然后由于开发上的特性,(编译优化,代码风格),使得逆向分析变麻烦了。
要提取游戏其实比较简单,等游戏运行时通过shell从内存或者从文件系统(如果文件落地)dump就行了。但是如果要提取不同游戏,这样就太麻烦了,还是先静态分析吧。
总之,这个逆向过程像是在做MISC题一样,需要一些逻辑推理。
逆向陷阱一般情况下,分析文件系统的内容,很少会先从内核入手,一般先看init相关文件。 第一步一般都是看 /etc/inittab,脚本首先启动rc,然后启动图形界面
# Begin /etc/inittab id:4:initdefault: si::sysinit:/etc/rc.d/init.d/rc x:4:respawn:/etc/X11/IGS &> /dev/null # End /etc/inittab /etc/rc.d/init.d/rc #!/bin/bash PATH=/bin:/sbin:/usr/bin:/usr/sbin export PATH mount -n -o remount,rw / mount -n -t ramfs tmp /tmp mount -n -t proc proc /proc mount -n -t usbdevfs usbdevfs /proc/bus/usb #echo "copy for etc" cp -a /etc/* /tmp mount -n -t ramfs etc /etc cp -a /tmp/* /etc rm -rf /tmp/* #echo "copy for dev" cp -a /dev/* /tmp mount -n -t ramfs dev /dev cp -a /tmp/* /dev rm -rf /tmp/* mount -n -t devpts pts /dev/pts mount -n -t tmpfs shm /dev/shm #echo "copy for var" cp -a /var/* /tmp mount -n -t ramfs var /var cp -a /tmp/* /var rm -rf /tmp/* #echo "copy for root" cp -a /root/.b* /tmp mount -n -t ramfs root /root cp -a /tmp/.b* /root rm -rf /tmp/.b* /sbin/hdparm -c1 -d1 -k1 -Xudma4 /dev/hdc &> /dev/null /etc/X11/IGS配置环境变量,启动X,然后启动读卡器,最后启动游戏,除了这个循环有一点反常,其他都非常正常。到这个时候肯定就认为/PM2008v2/PM2008v2是游戏本体了
#!/bin/sh TZ="UCT" TERM="xterm" TempFile="/tmp/XTemp" HZ="100" PATH=/sbin:/usr/sbin:/bin:/usr/bin:/usr/X11R6/bin LD_LIBRARY_PATH=/usr/X11R6/lib:/usr/X11R6/lib/modules/extensions DISPLAY=:0 export PATH LD_LIBRARY_PATH DISPLAY TERM HZ TZ ps -A | grep XFree86 | ( while read pid tty time command; do kill -9 $pid; done ) XFree86 &> /dev/null& mwm &> /dev/null & /usr/X11R6/bin/xsetroot -cursor /usr/X11R6/bitmaps/empty_ptr /usr/X11R6/bitmaps/empty_ptr if [ -f $TempFile ];then rm -rf $TempFile sleep 10 exit 0 else touch $TempFile fi /etc/rc.d/init.d/cardreader &> /dev/null& export TZ="CST" #Run Game cd /PM2008v2 while [ 1 ] do ./PM2008v2 &> /dev/null sleep 5 done接下来分析 PM2008v2,第一眼看上去,里面有很多程序装载的代码。联想到之前有很多文件没有magic,猜测可能是拿来动态加载代码文件还原成elf的。但是后来 Nova 和我说这东西不是游戏,我才知道了它的异常。这个文件有很多glibc特征,感觉就是一个静态链接glibc的程序。
为了方便逆向和后期的游戏移植,我需要确认GCC和GLibc版本,PM2008v2: GCC: (GNU) 3.3.1,但是没有GLibc的版本信息。 因此我直接找到系统的libc.so,版本暂定为glibc 2.3.2。在最新的linux下不好编译,docker下也出了问题。
分析伪游戏程序 编译 GCC 3.3.1由于目标版本内核是i686的,我使用CentOS4编译,运行在VMware,为了保证优化之后的汇编代码一致,我要使用相同GCC的版本,然后编译。并且搭建这个环境,也能方便以后对辅助分析该平台其他游戏,也有一些帮助。前期准备工作多一点,后期就可以少走一些弯路。
wget http://mirrors.aliyun.com/gnu/gcc/gcc-3.3.1/gcc-3.3.1.tar.gz使用阿里云的vault源,先安装开发环境依赖。
yum groupinstall "Development Tools"使用下列配置,指定版本为i686,旧版的gcc,最好不要并发编译,可能会出问题(3.3.1没有遇到,3.2.2遇到了),然后删除系统自带的gcc,再安装。
../gcc-3.3.1/configure --prefix=/opt/gcc_3.3.1 --infodir=/usr/share/info --enable-shared --enable-threads=posix --disable-checking --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --host=i686-pc-gnu-linux --build=i686-pc-linux-gnu --target=i686-pc-linux-gnu make -j8 yum remove gcc make install 编译 Glibc 2.3.2 wget http://mirrors.aliyun.com/gnu/glibc/glibc-2.3.2.tar.gz wget http://mirrors.aliyun.com/gnu/glibc/glibc-linuxthreads-2.3.2.tar.gzlinuxthreads要解压到glibc目录
tar -zxvf glibc-2.3.2.tar.gz cd glibc-2.3.2 tar -zxvf ../glibc-linuxthreads-2.3.2.tar.gzGCC 2.3.2编译可能会遇到一些bug,需要打patch,刚好E2000平台的Linux也是LFS版本,可以去这里下载 LFS Glibc patches
patch -p1 < ../patches/glibc-2.3.2-sscanf-1.patch patch -p1 < ../patches/glibc-2.3.2-inlining_fixes-2.patch patch -p1 < ../patches/glibc-2.3.2-test_lfs-1.patch接下来配置编译选项,先暂时这么用吧,因为即使设置细分的优化选项,最后的汇编内容都和目标文件差异很大。
CC=/opt/gcc_3.3.1/bin/gcc CFLAGS="-march=pentium4 -O2" ../glibc-2.3.2/configure --prefix=/lib --disable-profile --enable-add-ons --libexecdir=/usr/lib --with-headers=/usr/include差异项:
- 栈帧:目标程序大部分的函数返回都是 0xC9 leave,而我编译的都是先move esp, ebp,然后pop ebp。
- 内链函数:目标程序函数内部的call,一些中等长度的函数,会被优化成内联函数。
上述内容,即使我将编译优化级别设为O3,也几乎没有变化。手动配置fomit-frame-pointer、-finline-limit=n等信息,也没用。也许需要手动设置__inline__吧,我没时间去验证。 这个情况,用flare生成signature,几乎还原不了那种函数内部带有call的符号。
经过分析,PM2008v2这个程序,就是一个killdisk函数,静态编译了glibc。
因此,如果执行inittab,是不可能启动游戏的。
系统初始化分析 网友的逆向成果Nova之前告诉了我一些信息,实际上,仅从这个笔记,我看不出具体的加载流程,只能看出文件头需要还原,然后rc.0实际上是elf文件,启动时会执行。而且Submarine Crisis游戏文件和我的PM2008游戏文件又一些差异。
https://github.com/batteryshark/igstools/blob/main/scripts/igs_rofsv1_dumpexec.py
这个脚本是用于还原游戏文件的,我尝试了一下,可以生成一个ELF,但是放到IDA分析会出错。我并不知道生成的文件要如何运行。因此还是需要自己分析一遍。
分析内核启动过程通过分析依赖和其他环境变量,并没有找到任何能启动游戏的路径。在上一篇文章,分析了文件系统挂载,在挂载之后,还有一系列操作。IDA遇到长度过大的函数,可能就会出错,无法反编译。但是问题不大,麻烦的不在这里。
上一篇文章由于是分析基于开源代码魔改的filesystem,可以直接对照逆向,因此不还原其他符号,问题也不大。该内核版本是2.4,bzImage没有携带符号表。这里的代码很多是IGS自己开发的,有些系统调用不是通过int来调用的。如果有用到syscall,在符号没还原的情况下分析,还是有一些麻烦,如果用bindiff,就需要在ida 8下用。我没有时间去移植到Mac上。Linux Kernel有一个syscall table,如果IGS没有自定义过syscall,那么可以直接将自己编译的kernel的syscall 符号照搬过来。
另外,IDA9对这个老的Linux 2.4内核解析不太好,很多交叉引用和汇编指令都没有识别出来,需要手动修复。
修复立即数交叉引用 import ida_ida import ida_bytes import ida_ua import idautils def find_immediate_values_and_convert_to_offset(start_range, end_range): converted_count = 0 checked_count = 0 min_ea = ida_ida.inf_get_min_ea() max_ea = ida_ida.inf_get_max_ea() print(f"EA range: 0x{start_range:X} - 0x{end_range:X}") print(f"Immediate Value Range:0x{min_ea:X} - 0x{max_ea:X}") for ea in idautils.Heads(): if not ida_bytes.is_code(ida_bytes.get_flags(ea)): continue insn = ida_ua.insn_t() if ida_ua.decode_insn(insn, ea) == 0: continue if start_range <= ea and ea <= end_range: for op_num in range(ida_ida.UA_MAXOP): op = insn.ops[op_num] if op.type == ida_ua.o_void: break if op.type == ida_ua.o_imm: imm_value = op.value if op.value > 0xFFFFFFFF: imm_value = (0xFFFFFFFF & op.value) checked_count += 1 if min_ea <= imm_value and imm_value <= max_ea: if idc.op_offset(ea, op_num, REF_OFF32): converted_count += 1 else: print(f" -> Convert Failed: 0x{ea:X}[{op_num}]") print(f"Immediate Value: {checked_count}, Converted: {converted_count}") 修复字符串数据可能是因为交叉引用没有识别完全,字符串识别总是会错失前面几个bytes,需要手动修复字符串。
def get_the_firsstr_ea(ea): addr = ea - 1 last_byte = ida_bytes.get_byte(addr) if 32 < last_byte and last_byte < 127: ea = get_the_firsstr_ea(addr) return ea def find_str_address(start_ea, end_ea): current_ea = start_ea found_count = 0 while current_ea < end_ea: if current_ea == ida_idaapi.BADADDR: break address_flags = ida_bytes.get_flags(current_ea) if ida_bytes.is_strlit(address_flags): str_size = ida_bytes.get_item_size(current_ea) the_first_str_addr = get_the_firsstr_ea(current_ea) if the_first_str_addr != current_ea: len = current_ea - the_first_str_addr + str_size ida_bytes.create_strlit(the_first_str_addr, len, 0) print(f"Fix str at 0x{current_ea:X}, before: {str_size}, after: {len}") current_ea += ida_bytes.get_item_size(current_ea) continue Kernel Thread根据上图代码,可以得知游戏初始化的第一步是先运行/bin/zsh,并且携带这些参数。
export HOME=/ export TERM=linux export PATH=/bin:/usr/bin:/sbin:/usr/sbin /bin/zsh /etc/rc.d/rc0.d __KERNEL__ -no-print -PM2008v2下一步就是运行/mnt/GECA文件
export HOME=/ export TERM=linux export PATH=/bin:/usr/bin:/sbin:/usr/sbin /mnt/GECA /etc/rc.d/rc0.d __KERNEL__ -no-print -PM2008v2最后运行这些
if ( execute_command ) run_init_process((const char *)execute_command); run_init_process("/sbin/init"); // 存在 run_init_process("/etc/init"); // 不存在 run_init_process("/bin/init"); // 不存在 run_init_process("/bin/sh"); // 指向bashKernel Cmdline可以在parse_cmdline_early找到,并非LILO控制。
如果从外部设置了bootcmdline,其中有一个暗桩,会检测bootloader参数是否等于”JBoot“,如果不等于,就进入死循环。
这个JBoot是不是代表James写的Bootloader?👀
bootloader=JBoot内核自带的启动命令
root=/dev/hdc2 ro console=ttyS1,115200 BOOT_IMAGE=PM2008v2并没有设置init=,因此肯定会执行/sbin/init,然后执行/etc/inittab
恢复ZSH符号受阻线索到了/bin/zsh,这个程序入口和PM2008v2一样,我判断也是基于glibc改的,但是我导入FLIRT Signature,只能识别出最里层的函数,还是那个编译优化的原因。
/Applications/IDA\ Professional\ 9.1.app/Contents/MacOS/tools/flair/sigmake ~/RE/igs/libc.pat ~/RE/igs/libc2.3.2.o2.sig /Applications/IDA\ Professional\ 9.1.app/Contents/MacOS/tools/flair/pelf ~/RE/igs/libc.a ~/RE/igs/libc.pat目前不知道到底是基于什么GCC和GLibc改的,考虑到后面可能有很多程序也会用到glibc,所以需要确定是什么版本,看看有没有快速恢复符号的办法。
依赖关系分析使用我五年前开发的YAFAF,可以很快找到相关的依赖关系。rc*.d应该是游戏的代码。
rc0.d
GLIBC_2.1 GLIBC_2.0 GCC: (GNU) 3.2.2 20030222 (Red Hat Linux 3.2.2-5)rc2.d
GCC_3.0 GLIBC_2.0 GLIBC_2.1 GLIBC_2.2.3 GLIBC_2.1.3 GLIBC_2.3 GLIBC_2.2 GLIBC_2.3.2 GLIBC_2.0 GLIBC_2.1 GLIBCPP_3.2 GLIBC_2.2 GLIBC_2.1.3 GLIBC_2.3 GLIBC_2.3.2rc9
GCC: (GNU) 3.3.1 GCC: (GNU) 3.2.1 20021207 (Red Hat Linux 8.0 3.2.1-2) GCC: (GNU) 3.2.1 20030202 (Red Hat Linux 8.0 3.2.1-7) GCC: (GNU) 3.2.2 20030222 (Red Hat Linux 3.2.2-4) GCC: (GNU) 3.2.2 20030222 (Red Hat Linux 3.2.2-5)从这些特征来看,这几个文件,应该是多个不同环境编译的elf文件的碎片构成。
并且/sbin/init也是基于glibc2.3.X,所以用gcc3.2.2编译glibc2.3.2吧,基于centos3,编译这一版GCC,不能用并发,否则会出错。还好我以前编译openwrt遇到过类似错误,不然又要卡很久,搜都搜不到是啥原因。
../gcc-3.2.2/configure --prefix=/opt/gcc_3.2.2 --infodir=/usr/share/info --enable-shared --enable-threads=posix --disable-checking --with-system-zlib --enable-__cxa_atexit make make install编译GLibc,不管是用O2还是O3,最后几乎都没有内联函数优化。目前还搞不明白是什么原因,也搜不到,问AI也没用。
CC=/opt/gcc_3.2.2/bin/gcc CFLAGS="-march=pentium4 -O2" ../glibc-2.3.2/configure --prefix=/lib --disable-profile --enable-add-ons --libexecdir=/usr/lib --with-headers=/usr/include CC=/opt/gcc_3.2.2/bin/gcc CFLAGS="-O3" ../glibc-2.3.2/configure --prefix=/lib --disable-profile --enable-add-ons --libexecdir=/usr/lib --with-headers=/usr/include 恢复分析 ZSH 符号我不喜欢做机械而重复的工作,如果要我直接逆向zsh,我会觉得非常无聊。 这个版本ida对识别instruments不是太好,很多地方需要手动恢复。用下图脚本恢复完后,下一步就是恢复function entry,在我上一篇文章有类似的脚本。
def find_and_make_instrument(start_ea, end_ea): image_base = idaapi.get_imagebase() current_ea = start_ea found_count = 0 while current_ea < end_ea: if current_ea == ida_idaapi.BADADDR: break address_flags = ida_bytes.get_flags(current_ea) if ida_bytes.is_code(address_flags): current_ea += ida_bytes.get_item_size(current_ea) continue else: ida_bytes.del_items(0x8052606, 0, 1) if ida_ua.can_decode(current_ea): print("Decode instruments at 0x{:X}".format(current_ea)) insn_size = ida_ua.decode_insn(ida_ua.insn_t(), current_ea) ida_bytes.del_items(current_ea, 0, insn_size) offset = ida_ua.create_insn(current_ea) if offset > 0: found_count += 1 if idc.get_func_flags(current_ea) != -1: current_ea += offset continue else: print("Decode instruments failed at 0x{:X}".format(current_ea)) return else: print("Create instruments failed at 0x{:X}".format(current_ea)) current_ea += 1 continue print("Search finished, {} instruments created".format(found_count)) find_and_make_instrument(0x080480B4, 0x0808F000)因为没有办法用glibc的signature,我想了一个比较傻的办法,但是速度比调用MCP分析要快。
先把文件A(自己编译的)的glibc字符串完全恢复,恢复函数入口。 在文件B,先修复函数入口,修复立即数偏移,修复字符串,去重,去除过短字符串,然后逐个匹配文件A的字符串,并筛选。 从筛选结果遍历交叉引用,将不重复且为函数的筛选出来。
- 如果文件A的这些函数有符号,就恢复到文件B;
- 字符串作为参数入栈的函数,找到当前函数空间的所有call,如果目标地址没有符号,也用这个办法去还原入栈的函数符号。
- 递归设置符号,比如恢复了这个call的函数,然后再去call函数内部寻找其他的函数。这个感觉没什么必要,因为会遇到很多情况。
这些这几个脚本比较糙,还需打磨,主要是现在够用了。而且可能作用不是很大,因为做了这几项优化后,很快就能看出这几个ELF,实际上没有魔改Glibc,只是静态链接了而已。并且都是用 libc_start_main 来启动主函数。
修复GECA和rc0.d从 /dev/hdc1 的 0x1B44 * 512 位置读取 0x400 字节,这个是一个ELF Header,写入到 /mnt/head 里。
IGS应该是把这个ELF头藏在了FAT分区的空白处。因为分区是连续的,无法直接从分区布局看出藏了内容。
从 /bin/arch 的末尾的位置读取 0x400 字节,写入到 /mnt/GECA 里。
然后将 /etc/init.d/rc0.d 追加到 /mnt/GECA 末尾
修复游戏文件在我开始分析之前,Nova 把他和 BatteryShark 研究的进度分享给我了,他们已经将 Speed Driver 2 的ELF还原,但是这个ELF还是有问题的,并且不能用在其他游戏,比如Percussion Master 2008。 IGS Dump EXEC
因此还是要自己动手,恰好我也要逆向分析还原游戏文件的代码。
在内核启动阶段,GECA 被 zsh 修复后,立刻就会用相同的环境变量和参数运行。
rc* 的拼接顺序,是通过一个字符串变量来设定的,每个游戏都不一样。
这个数字字符,刚好对应 /etc/rc.d 的文件顺序
2 1 3 5 8 4 7 6 9GECA 代码运行过程:先将/etc/rc.d的碎片,根据不同顺序来设置剪切尺寸,输出到/mnt目录
dd if=/etc/rc.d/rc2.d of=/mnt/rc2 bs=1K count=[file_size / 1024 - 400] &> /dev/null dd if=/etc/rc.d/rc1.d of=/mnt/rc1 bs=1K count=[file_size / 1024 - 400 + 7] &> /dev/null dd if=/etc/rc.d/rc3.d of=/mnt/rc3 bs=1K count=[file_size / 1024 - 400 + 14] &> /dev/null dd if=/etc/rc.d/rc5.d of=/mnt/rc5 bs=1K count=[file_size / 1024 - 400 + 21] &> /dev/null dd if=/etc/rc.d/rc8.d of=/mnt/rc8 bs=1K count=[file_size / 1024 - 400 + 28] &> /dev/null dd if=/etc/rc.d/rc4.d of=/mnt/rc4 bs=1K count=[file_size / 1024 - 400 + 35] &> /dev/null dd if=/etc/rc.d/rc7.d of=/mnt/rc7 bs=1K count=[file_size / 1024 - 400 + 42] &> /dev/null dd if=/etc/rc.d/rc6.d of=/mnt/rc6 bs=1K count=[file_size / 1024 - 400 + 49] &> /dev/null dd if=/etc/rc.d/rc9.d of=/mnt/rc9 bs=1 count=[file_size - 400 * 1024] &> /dev/null然后挂载ramfs在/exec,将这些文件拼接成真正的游戏文件,替换掉原本的硬盘破坏程序。 接下来的启动过程就符合前面的 /etc/X11/IGS 了。
mount -n -t ramfs GameExecution /exec &> /dev/null cat /mnt/head /mnt/rc2 /mnt/rc1 /mnt/rc3 /mnt/rc5 /mnt/rc8 /mnt/rc4 /mnt/rc7 /mnt/rc6 /mnt/rc9 > /exec/PM2008v2 && chmod 777 /exec/PM2008v2 &> /dev/null # 删除临时文件 umount /mnt &>/dev/null rm -rf /mnt &>/dev/null可以编写一个脚本来实现这个过程
#!/usr/bin/env python3 import os import argparse def main(): parser = argparse.ArgumentParser(description='Recover game from IGS E2000 platform') parser.add_argument('head_file', type=str, help='head file to read') parser.add_argument('rc_dir', type=str, help='game parts dir to read') parser.add_argument('game_file', type=str, help='game file to write') args = parser.parse_args() rc_order = "213584769" rc_order_len = len(rc_order) block_num = 400 ignore_count = 7 step_type = 2 head = open(args.head_file, 'rb').read() with open(args.game_file, 'wb') as game_fd: game_fd.write(head) for i in range(rc_order_len): rc_file_path = os.path.join(args.rc_dir, f"rc{rc_order[i]}.d") rc_data = open(rc_file_path, 'rb').read() rc_data_size = len(rc_data) write_size = rc_data_size - block_num * 1024 print(f"rc{rc_order[i]}.d size: 0x{rc_data_size:08X}, write 0x{write_size:08X} bytes to game file, write block {int(write_size/1024)} skip block_num: {block_num}") # head = head + rc_data[0:write_size] if step_type == 2: block_num -= ignore_count else: block_num += ignore_count game_fd.write(rc_data[0:write_size]) if __name__ == "__main__": main() (base) ➜ python ./recover_game.py ./head.img ./part2/etc/rc.d ./pm2008_game rc2.d size: 0x00080000, write 0x0001C000 bytes to game file, write block 112 skip block_num: 400 rc1.d size: 0x00080000, write 0x0001DC00 bytes to game file, write block 119 skip block_num: 393 rc3.d size: 0x00080000, write 0x0001F800 bytes to game file, write block 126 skip block_num: 386 rc5.d size: 0x00080000, write 0x00021400 bytes to game file, write block 133 skip block_num: 379 rc8.d size: 0x00080000, write 0x00023000 bytes to game file, write block 140 skip block_num: 372 rc4.d size: 0x00080000, write 0x00024C00 bytes to game file, write block 147 skip block_num: 365 rc7.d size: 0x00080000, write 0x00026800 bytes to game file, write block 154 skip block_num: 358 rc6.d size: 0x00080000, write 0x00028400 bytes to game file, write block 161 skip block_num: 351 rc9.d size: 0x003CA6D8, write 0x003746D8 bytes to game file, write block 3537 skip block_num: 344修复后,虽然避开了多个版本的glibc特征,但ELF可能还有问题,因为每个游戏的主程序恢复算法还是有略微差异。PM2008的恢复逻辑输入就比SD2多两个参数,先分析PM2008吧。
还需要动态分析一下内存里的ELF文件是怎么装载的,但是现在发现,在游戏机接网线或者键盘,就会自动死机,不知道是不是安全机制。下一篇将分析如何在设备上动态调试。