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

作为2007年发布的游戏,此游戏加固机制还是比较落后的,主要是靠一些拼接,修改特征的方法来加固。并没有现代APP加固的那么卷的特性。主要是加固的环节有点多,并且每个游戏都不一样。然后由于开发上的特性,(编译优化,代码风格),使得逆向分析变麻烦了。

要提取游戏其实比较简单,等游戏运行时通过shell从内存或者从文件系统(如果文件落地)dump就行了。但是如果要提取不同游戏,这样就太麻烦了,还是先静态分析吧。

总之,这个逆向过程像是在做MISC题一样,需要一些逻辑推理。

逆向陷阱

一般情况下,分析文件系统的内容,很少会先从内核入手,一般先看init相关文件。
第一步一般都是看 /etc/inittab,脚本首先启动rc,然后启动图形界面

1
2
3
4
5
# 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

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
#!/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是游戏本体了

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
#!/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的版本,然后编译。并且搭建这个环境,也能方便以后对辅助分析该平台其他游戏,也有一些帮助。前期准备工作多一点,后期就可以少走一些弯路。

1
wget http://mirrors.aliyun.com/gnu/gcc/gcc-3.3.1/gcc-3.3.1.tar.gz

使用阿里云的vault源,先安装开发环境依赖。

1
yum groupinstall "Development Tools"

使用下列配置,指定版本为i686,旧版的gcc,最好不要并发编译,可能会出问题(3.3.1没有遇到,3.2.2遇到了),然后删除系统自带的gcc,再安装。

1
2
3
4
../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

1
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目录

1
2
3
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

1
2
3
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

接下来配置编译选项,先暂时这么用吧,因为即使设置细分的优化选项,最后的汇编内容都和目标文件差异很大。

1
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内核解析不太好,很多交叉引用和汇编指令都没有识别出来,需要手动修复。

修复立即数交叉引用

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
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,需要手动修复字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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,并且携带这些参数。

1
2
3
4
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文件

1
2
3
4
export HOME=/
export TERM=linux
export PATH=/bin:/usr/bin:/sbin:/usr/sbin
/mnt/GECA /etc/rc.d/rc0.d __KERNEL__ -no-print -PM2008v2

最后运行这些

1
2
3
4
5
6
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?👀

1
bootloader=JBoot

内核自带的启动命令

1
root=/dev/hdc2 ro console=ttyS1,115200 BOOT_IMAGE=PM2008v2

并没有设置init=,因此肯定会执行/sbin/init,然后执行/etc/inittab

恢复ZSH符号受阻

线索到了/bin/zsh,这个程序入口和PM2008v2一样,我判断也是基于glibc改的,但是我导入FLIRT Signature,只能识别出最里层的函数,还是那个编译优化的原因。

1
2
/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

1
2
3
GLIBC_2.1
GLIBC_2.0
GCC: (GNU) 3.2.2 20030222 (Red Hat Linux 3.2.2-5)

rc2.d

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

1
2
3
4
5
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遇到过类似错误,不然又要卡很久,搜都搜不到是啥原因。

1
2
3
4
../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也没用。

1
2
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,在我上一篇文章有类似的脚本。

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
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提取字符串和函数交叉引用

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/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

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
#!/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 的文件顺序

1
2 1 3 5 8 4 7 6 9

GECA 代码运行过程:先将/etc/rc.d的碎片,根据不同顺序来设置剪切尺寸,输出到/mnt目录

1
2
3
4
5
6
7
8
9
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 了。

1
2
3
4
5
6
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

可以编写一个脚本来实现这个过程

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
#!/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(head)

if __name__ == "__main__":
main()

修复后,虽然避开了多个版本的glibc特征,但这个ELF还是有问题的,可能还有很多保护机制吧。IGS 就是纯靠堆加固措施的数量来提升破解门槛,这样才避免了生命周期内被破解。

还需要动态分析一下内存里的ELF文件是怎么装载的,但是现在发现,在游戏机接网线或者键盘,就会自动死机,不知道是不是安全机制。下一篇将分析如何在设备上动态调试吧。