IGS Arcade 逆向系列(二)- 游戏文件恢复

Sunday, May 25, 2025

上一篇文章写到游戏有个破坏分区的保护机制,本篇将深入分析。

作为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.gz

linuxthreads要解压到glibc目录

tar -zxvf glibc-2.3.2.tar.gz
cd glibc-2.3.2
tar -zxvf ../glibc-linuxthreads-2.3.2.tar.gz

GCC 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

差异项:

  1. 栈帧:目标程序大部分的函数返回都是 0xC9 leave,而我编译的都是先move esp, ebp,然后pop ebp。
  2. 内链函数:目标程序函数内部的call,一些中等长度的函数,会被优化成内联函数。

上述内容,即使我将编译优化级别设为O3,也几乎没有变化。手动配置fomit-frame-pointer、-finline-limit=n等信息,也没用。也许需要手动设置__inline__吧,我没时间去验证。 这个情况,用flare生成signature,几乎还原不了那种函数内部带有call的符号。

经过分析,PM2008v2这个程序,就是一个killdisk函数,静态编译了glibc。

killdisk.png

因此,如果执行inittab,是不可能启动游戏的。

系统初始化分析

网友的逆向成果

Nova之前告诉了我一些信息,实际上,仅从这个笔记,我看不出具体的加载流程,只能看出文件头需要还原,然后rc.0实际上是elf文件,启动时会执行。而且Submarine Crisis游戏文件和我的PM2008游戏文件又一些差异。

notes_for_hwtest.png

https://github.com/batteryshark/igstools/blob/main/scripts/igs_rofsv1_dumpexec.py

这个脚本是用于还原游戏文件的,我尝试了一下,可以生成一个ELF,但是放到IDA分析会出错。我并不知道生成的文件要如何运行。因此还是需要自己分析一遍。

分析内核启动过程

通过分析依赖和其他环境变量,并没有找到任何能启动游戏的路径。在上一篇文章,分析了文件系统挂载,在挂载之后,还有一系列操作。IDA遇到长度过大的函数,可能就会出错,无法反编译。但是问题不大,麻烦的不在这里。

call_usermodehelper.png

上一篇文章由于是分析基于开源代码魔改的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

call_usermodehelper1.png

根据上图代码,可以得知游戏初始化的第一步是先运行/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");    // 指向bash

Kernel Cmdline可以在parse_cmdline_early找到,并非LILO控制。

如果从外部设置了bootcmdline,其中有一个暗桩,会检测bootloader参数是否等于”JBoot“,如果不等于,就进入死循环。

jboot.png

这个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

zsh.png

目前不知道到底是基于什么GCC和GLibc改的,考虑到后面可能有很多程序也会用到glibc,所以需要确定是什么版本,看看有没有快速恢复符号的办法。

依赖关系分析

使用我五年前开发的YAFAF,可以很快找到相关的依赖关系。rc*.d应该是游戏的代码。

gcc_glibc.png 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.2

rc9

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的字符串,并筛选。 从筛选结果遍历交叉引用,将不重复且为函数的筛选出来。

  1. 如果文件A的这些函数有符号,就恢复到文件B;
  2. 字符串作为参数入栈的函数,找到当前函数空间的所有call,如果目标地址没有符号,也用这个办法去还原入栈的函数符号。
  3. 递归设置符号,比如恢复了这个call的函数,然后再去call函数内部寻找其他的函数。这个感觉没什么必要,因为会遇到很多情况。

IDA Pro 脚本,从目标A提取字符串和函数交叉引用

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import json
import ida_funcs
import ida_name
import ida_bytes
import ida_xref
import ida_idaapi
import ida_ua
import ida_segment
import idautils
try:
    import ida_allins
    HAS_ALLINS = True
except ImportError:
    HAS_ALLINS = False

def find_target_function(xref_ea):
    insn = ida_ua.insn_t()
    if not ida_ua.decode_insn(insn, xref_ea):
        return None
    current_func = ida_funcs.get_func(xref_ea)

    search_ea = xref_ea
    
    while search_ea < current_func.end_ea:
        search_ea = ida_bytes.next_head(search_ea, ida_idaapi.BADADDR)
        if search_ea == ida_idaapi.BADADDR:
            break
            
        if not ida_ua.decode_insn(insn, search_ea):
            continue
            
        if insn.itype in [ida_allins.NN_call, ida_allins.NN_callfi, ida_allins.NN_callni]:
            target_ea = insn.ops[0].addr if insn.ops[0].type == ida_ua.o_near else None
            if target_ea:
                target_func_name = ida_name.get_name(target_ea)
                if target_func_name:
                    return target_func_name
                else:
                    func = ida_funcs.get_func(target_ea)
                    if func:
                        return ida_funcs.get_func_name(func.start_ea)
    return None

def get_string_xrefs_with_functions():
    """
    获取IDAPro中所有字符串及其交叉引用信息
    返回JSON格式数据,包含字符串地址、内容、长度和引用它的函数名
    """
    results = []

    # 初始化字符串列表
    strings = idautils.Strings()
    strings.setup()

    print("[+] 开始扫描字符串...")

    for i, string_item in enumerate(strings):
        # 获取字符串基本信息
        str_ea = string_item.ea
        str_length = string_item.length
        str_type = string_item.strtype

        # 获取字符串内容
        try:
            str_content = str(string_item)
        except:
            str_content = ""

        # 获取段信息
        seg = ida_segment.getseg(str_ea)
        if seg:
            seg_start = seg.start_ea
            seg_name = ida_segment.get_segm_name(seg)
            if not seg_name:
                seg_name = f"seg_{seg_start:08X}"
        else:
            seg_start = 0
            seg_name = "unknown"

        # 收集所有引用该字符串的地址
        xref_functions = []

        # 获取段信息
        seg = ida_segment.getseg(str_ea)
        if seg:
            seg_start = seg.start_ea
            seg_name = ida_segment.get_segm_name(seg)
            if not seg_name:
                seg_name = f"seg_{seg_start:08X}"
        else:
            seg_start = 0
            seg_name = "unknown"
        target_functions = []  # 存储目标函数名
        # 使用XrefsTo获取所有引用
        for xref in idautils.XrefsTo(str_ea):
            xref_ea = xref.frm

            # 获取引用地址所在的函数
            func = ida_funcs.get_func(xref_ea)
            if func:
                # 获取函数名
                func_name = ida_funcs.get_func_name(func.start_ea)
                if func_name and func_name not in xref_functions:
                    xref_functions.append(func_name)
                
                # 分析目标函数
                target_func = find_target_function(xref_ea)
                if target_func and target_func not in target_functions:
                    target_functions.append(target_func)
            # else:
            #     # 如果不在函数中,尝试获取该地址的名称
            #     name = ida_name.get_name(xref_ea)
            #     if name and name not in xref_functions:
            #         xref_functions.append(f"@{name}")
            #     else:
            #         # 如果没有名称,使用地址
            #         addr_name = f"addr_0x{xref_ea:X}"
            #         if addr_name not in xref_functions:
            #             xref_functions.append(addr_name)

        # 添加数据引用
        # for xref_ea in idautils.DataRefsTo(str_ea):
        #     func = ida_funcs.get_func(xref_ea)
        #     if func:
        #         func_name = ida_funcs.get_func_name(func.start_ea)
        #         if func_name and func_name not in xref_functions:
        #             xref_functions.append(func_name)
                
        #         # 分析目标函数
        #         target_func = find_target_function(xref_ea)
        #         if target_func and target_func not in target_functions:
        #             target_functions.append(target_func)
        #     else:
        #         name = ida_name.get_name(xref_ea)
        #         if name and name not in xref_functions:
        #             xref_functions.append(f"@{name}")
        #         else:
        #             addr_name = f"addr_0x{xref_ea:X}"
        #             if addr_name not in xref_functions:
        #                 xref_functions.append(addr_name)

        # 创建结果条目
        string_info = {
            "ea": str_ea,
            "str": str_content,
            "len": str_length,
            "seg_addr": seg_start,
            "seg_name": seg_name,
            "xrefs": xref_functions,
            "target_func_name": target_functions  # 新增字段
        }

        results.append(string_info)

        if (i + 1) % 100 == 0:
            print(f"[+] 已处理 {i + 1} 个字符串...")

    print(f"[+] 总共找到 {len(results)} 个字符串")
    return results


def main():
    # 获取并保存字符串信息
    strings_data = get_string_xrefs_with_functions()
    unique_data = []

    for i, string_info in enumerate(strings_data):
        duplicated = False
        if len(string_info['str']) > 10 and string_info['seg_name'] == '.rodata' and string_info['xrefs']:
            for j, string_info1 in enumerate(strings_data):
                if string_info1['str'] == string_info['str'] and string_info['ea'] != string_info1['ea']:
                    print(f"\n{i+1}. 重复内容地址: 0x{string_info['ea']:08X} 0x{string_info1['ea']:08X}  字符串: {repr(string_info1['str'])}")
                    duplicated = True
                    break
            if not duplicated:
                unique_data.append(string_info)
                
    with open("strings_with_xrefs.json", "w") as f:
        json.dump(unique_data, f, ensure_ascii=False, indent=2)
    print(f"\n[+] 分析完成! 总共发现 {len(unique_data)} 个字符串")

if __name__ == "__main__":
    main()

IDA Pro 脚本,将A到交叉引用符号加载到B

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import json
import ida_bytes
import ida_name
import ida_funcs
import ida_xref
import ida_kernwin
import ida_segment
import idautils
import ida_idaapi
try:
    import ida_allins
    HAS_ALLINS = True
except ImportError:
    HAS_ALLINS = False

def find_target_function(xref_ea):
    insn = ida_ua.insn_t()
    if not ida_ua.decode_insn(insn, xref_ea):
        return None
    current_func = ida_funcs.get_func(xref_ea)

    search_ea = xref_ea
    
    while search_ea < current_func.end_ea:
        search_ea = ida_bytes.next_head(search_ea, ida_idaapi.BADADDR)
        if search_ea == ida_idaapi.BADADDR:
            break
            
        if not ida_ua.decode_insn(insn, search_ea):
            continue
            
        if insn.itype in [ida_allins.NN_call, ida_allins.NN_callfi, ida_allins.NN_callni]:
            target_ea = insn.ops[0].addr if insn.ops[0].type == ida_ua.o_near else None
            if target_ea:
                target_func_name = ida_name.get_name(target_ea)
                if target_func_name:
                    return target_func_name, target_ea
                else:
                    func = ida_funcs.get_func(target_ea)
                    if func:
                        return ida_funcs.get_func_name(func.start_ea), func.start_ea
    return None, None
                    
def process_string_data(json_file_path):
    """
    从JSON文件读取字符串数据,在rodata段搜索字符串,
    跳转到第一个交叉引用的函数,并根据需要重命名函数
    
    参数:
        json_file_path: JSON文件路径
    """
    
    try:
        # 读取JSON文件
        with open(json_file_path, 'r', encoding='utf-8') as f:
            string_data_list = json.load(f)
        
        print(f"成功读取JSON文件,包含 {len(string_data_list)} 个字符串条目")
        
        # 获取.rodata段
        rodata_seg = None
        for seg in idautils.Segments():
            seg_name = ida_segment.get_segm_name(ida_segment.getseg(seg))
            if seg_name == ".rodata":
                rodata_seg = ida_segment.getseg(seg)
                break
        
        if not rodata_seg:
            print("错误:无法找到.rodata段")
            return
        
        print(f"找到.rodata段: 0x{rodata_seg.start_ea:X} - 0x{rodata_seg.end_ea:X}")
        
        # 处理每个字符串条目
        for idx, item in enumerate(string_data_list):
            # print(f"\n[{idx + 1}/{len(string_data_list)}] 处理字符串: '{item['str']}'")
            
            # 在rodata段搜索字符串
            string_to_search = item['str']
            found_ea = search_string_in_segment(string_to_search, rodata_seg)
            
            if found_ea == ida_idaapi.BADADDR:
                # print(f"  未在.rodata段找到字符串 '{string_to_search}'")
                continue
            
            # 获取字符串的交叉引用
            xrefs = get_xrefs_to_address(found_ea)
            
            if not xrefs:
                print(f"  字符串在 0x{found_ea:X} 没有交叉引用")
                continue
            
            print(f"  找到 {len(xrefs)} 个交叉引用")
            
            # 获取第一个交叉引用
            first_xref = xrefs[0]
            
            # 找到包含该交叉引用的函数
            func = ida_funcs.get_func(first_xref)
            if not func:
                print(f"  0x{first_xref:X} 不在任何函数中")
                continue

            target_func_name, target_func_ea = find_target_function(first_xref)
            if target_func_name:
                if target_func_name.startswith('sub_') and item['target_func_name']:
                    rename_function(target_func_ea, item['target_func_name'][0])
                

            func_ea = func.start_ea
            current_func_name = ida_funcs.get_func_name(func_ea)
            
            print(f"  目标函数地址: 0x{func_ea:X}")
            print(f"  当前函数名: {current_func_name}")
            
            # 检查函数是否有自定义名称
            if is_auto_generated_name(current_func_name):
                # 使用JSON中提供的xrefs第一项作为新的函数名
                if 'xrefs' in item and item['xrefs'] and len(item['xrefs']) > 0:
                    new_name = item['xrefs'][0]
                    if rename_function(func_ea, new_name):
                        print(f"  ✓ 函数重命名为: {new_name}")
                    else:
                        print(f"  ✗ 函数重命名失败")
                else:
                    print(f"  未提供xrefs信息,跳过重命名")
            else:
                print(f"  函数已有自定义名称,跳过重命名")
            
            # 跳转到函数
            ida_kernwin.jumpto(func_ea)
            print(f"  已跳转到函数 0x{func_ea:X}")
        
        print(f"\n处理完成!共处理了 {len(string_data_list)} 个字符串条目")
    
    except FileNotFoundError:
        print(f"错误:无法找到文件 {json_file_path}")
    except json.JSONDecodeError as e:
        print(f"错误:JSON文件格式错误 - {e}")
    except Exception as e:
        print(f"错误:处理过程中出现异常 - {e}")

def search_string_in_segment(target_string, segment):
    """
    在指定段中搜索字符串
    
    参数:
        target_string: 要搜索的字符串
        segment: 目标段
    
    返回:
        找到的地址,如果未找到则返回BADADDR
    """
    start_ea = segment.start_ea
    end_ea = segment.end_ea
    
    # 尝试搜索字符串
    found_ea = ida_bytes.find_string(
        target_string,
        start_ea,
        range_end=end_ea,
        flags=ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW
    )
    
    # 如果在段范围内找到,返回地址
    if found_ea != ida_idaapi.BADADDR and start_ea <= found_ea < end_ea:
        return found_ea
    
    return ida_idaapi.BADADDR

def get_xrefs_to_address(ea):
    """
    获取指向指定地址的所有交叉引用
    
    参数:
        ea: 目标地址
    
    返回:
        交叉引用地址列表
    """
    xrefs = []
    
    # 获取数据引用
    for xref in idautils.DataRefsTo(ea):
        xrefs.append(xref)
    
    # 获取代码引用
    for xref in idautils.CodeRefsTo(ea, 0):
        xrefs.append(xref)
    
    return xrefs

def is_auto_generated_name(func_name):
    """
    检查函数名是否为自动生成的名称
    
    参数:
        func_name: 函数名
    
    返回:
        True 如果是自动生成的名称,False 否则
    """
    # IDA自动生成的函数名通常以sub_、loc_、unk_等开头
    auto_prefixes = ['sub_', 'loc_', 'unk_', 'nullsub_', 'j_']
    
    for prefix in auto_prefixes:
        if func_name.startswith(prefix):
            return True
    
    return False

def rename_function(func_ea, new_name):
    """
    重命名函数
    
    参数:
        func_ea: 函数地址
        new_name: 新函数名
    
    返回:
        True 如果重命名成功,False 否则
    """
    try:
        # 使用ida_name.set_name设置函数名
        result = ida_name.set_name(func_ea, new_name, ida_name.SN_CHECK)
        return result
    except Exception as e:
        print(f"重命名函数时出错: {e}")
        return False

def main():
    """
    主函数 - 提示用户选择JSON文件并开始处理
    """
    # 获取JSON文件路径
    json_file = ida_kernwin.ask_file(0, "*.json", "选择包含字符串数据的JSON文件")
    
    if json_file:
        print(f"开始处理文件: {json_file}")
        process_string_data(json_file)
    else:
        print("未选择文件,操作取消")

if __name__ == "__main__":
    main()

这些这几个脚本比较糙,还需打磨,主要是现在够用了。而且可能作用不是很大,因为做了这几项优化后,很快就能看出这几个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* 的拼接顺序,是通过一个字符串变量来设定的,每个游戏都不一样。

geca_params.png

这个数字字符,刚好对应 /etc/rc.d 的文件顺序

2 1 3 5 8 4 7 6 9

GECA 代码运行过程:先将/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文件是怎么装载的,但是现在发现,在游戏机接网线或者键盘,就会自动死机,不知道是不是安全机制。下一篇将分析如何在设备上动态调试。

逆向工程IGSArcadeCrack鈊象电子E2000

IGS Arcade 逆向系列(三)- Getshell

IGS Arcade 逆向系列(一)- E2000平台分析