破解入门:解除 Strawberry Feels 的光盘加载依赖

记录一次用 Olly Debug 解除 Strawberry Feels 的光盘加载检测(即制作 NoDVD 破解)的过程。

提示:养成良好习惯,做破解前先备份原始文件!

1、引言

在搜索“片雾烈火”(源头忘记是哪里了,这是推荐项)的简历的时候,发现她除了负责过 Strawberry Nauts 的插入曲(实际上就是 ED 啦),还负责过一个名为 Strawberry Feels 的 gal game 的 OP。看简介,SF 也是一个纯爱系作品。不过这个在国内太小众,连百度的自动搜索推荐都只有 SN,而且搜索结果第一页中居然没有多个下载地址或者评测什么的。看来我大 HOOKSOFT 简直登上了纯爱白糖系的顶峰啊。我准备不下载的时候,橙光上的一个帖子宣称:

这游戏的立绘真是不咋地……

不过亮点是超清BG,基本每张BG都有1280×720和2560×1440两个分辨率的版本。

另外还包含少量看着还不错的战斗效果图。

嗯,看来挺有意思的。冲着超清 BG,看一看。旅程就这么开始了。

2、获取 Strawberry Feels

前面说过,这游戏比较小众,所以找到一个下载源还是不是一下就行的。百度搜索的结果中,最正常的就是贴吧里的资源贴了——不过早就失效了。

OK,我们换一个思路。请问哪里还有比较靠谱的发布站?除了各 PT 站,最好的恐怕就是苍雪(绯月)和 2DJ 了吧。由于我并不是 KFB 大富豪,就选择去 2DJ 碰运气。好了,关键点讲完了,这一步剩下的内容请同学们自己尝试一下,就当做一下锻炼吧

什么,找不到?再给出一个提示:2DJ 的搜索功能其实并不能满足我们的需求(Discuz 自带的帖子搜索表现一向很糟糕)。再换一种思路,2DJ 的游戏发布有两个区,新作区和历史区,但是不管哪一个都是基本上按照时间排序的。同时,纯爱系的更容易受到关注,出现在新作区的概率也更大。已知 SF 的发行时间是2012年3月30日。

什么,还是找不到?好吧,这里是链接。页面中的磁力链接用115离线下载下来就行了。

好了,虽然那帖子标题是“初回限定版“、“NoDVD添加”,不过估计放到了“更多资源”里了。那可是会员的世界,我这个渣渣连注册都没办法(“請找認識的現任會員朋友在站務區置頂帖推薦”,我哪里认识谁啊),只下载了公开的磁力链接的内容。内容就是一个压缩包,初回特典都没有呢……

2DJ 上的吐槽:

看到标题我还以为是Strawberry Nauts的FD兴奋了一下……

我才不说我也是呢……

3、运行测试

下载安装,安装的时候不要用转区工具,直接安装。路径会出现乱码不要管,因为卸载的时候并不会通过转区工具运行。

安装完毕后我们来运行一下。

出现了乱码

我怎么知道它在说什么!不过根据在 gal game 上积累的经验(咳咳),肯定是编码错误啦。

先下载 Locale Emulator。(在此澄清一下,在以前的博文中我曾经说那位是 LE 的作者,其实内核是 Amano 写的。)SF 好像不吃 AppLocale 的那一套,运行后只创建了进程,没有出现窗口。安装了 LE 后,用 LE 启动 StrawberyFeels.exe

好的,能正常加载了,映入眼帘的是另一幅景象:

被拒绝运行

(屏幕提示是“请将产品光盘放入光驱中”的意思。)

嗯,看来它有一个光盘插入验证。不觉得很麻烦吗,每次玩之前都要用虚拟光驱软件加载光盘。

终于进入正题了:我们要将光盘验证去掉。

4、调试分析

在开始调试之前,请想清楚:如果你是程序设计者,你会用什么方式做光盘插入验证?要是我的话,肯定会对驱动器进行遍历,然后检查特定目录有没有特定的文件——这是最土、实现最简单的方法。(如果用其他的,例如调用底层函数直接读驱动器进行散列的话,就麻烦了。)

当然,已知以上思路的时候,就知道了关键函数:CreateFile()/CreateFileEx();不过有时候直接使用 fopen() 的话,就得下到 NtCreateFile()ZwCreateFile()IoCreateFile() 了,以上的调用是在 CRT 内进行的,跟踪起来很困难。辅助函数自然是 GetDriveTypeA()/GetDriveTypeW(),要不你怎么知道用户将光盘放入了光盘驱动器中呢?检查函数调用,正常的话只有一个(安装目录已经有了资源的拷贝,为什么还要去读光驱),这就是要 crack 的地方。

我在操作的时候没想过要用上面的函数,而是从是验证哪个文件的存在入手的。因此,我在 Olly Debug 中使用 Search for/Binary string(Ctrl+B)功能找了几个我认为关键的文件,不过都没找到匹配的结果。(后来证明,文件名是从资源中读取,然后 sprintf() 上去的。)

我突然想到,在发现没有光驱的时候,会弹出一个消息框。于是,现在的焦点放在弹出消息框的函数上了。问题又来了:请问是哪一个呢?

  1. MessageBoxA()
  2. MessageBoxW()
  3. MessageBoxExA()
  4. MessageBoxExW()
  5. MessageBoxIndirectA()
  6. MessageBoxIndirectW()

熟悉 Windows 编程的同学也知道,MessageBox()MessageBoxEx()MessageBoxIndirect() 是宏定义,在 UNICODE 这个宏定义了和没定义的时候分别被映射到 ANSI 和 UNICODE 的版本。

首先,5和6概率很小,显示一个简单的警告用不着自定义消息框吧。再结合之前提到的乱码问题,如果用了 UNICODE 的版本的话,字符串被处理之前都要转换成统一的 UNICODE 编码,是不会出现乱码的(见 KiriKiri 2/KAG3,内部使用的就是 UNICODE,不需要担心编码问题,不过文件编码是限定的)。考虑到程序员偷懒的天性,不用 MessageBoxEx() 的时候就不用(因为有附加参数啊),那么答案就是:显示消息框采用的很可能是 MessageBoxA()

正是因为采用的都是 ANSI 版本的 API,所以字符串在解读时都会用本地字符集翻译——在日文平台上是 Shift-JIS,在中文平台上是 GBK。自然,二者在高码段不兼容,就会出现乱码了。所以我猜刚才看到的乱码大概是想说“本游戏不能在日本以外的国家启动”——可惜弄巧成拙,其他国家的用户一般也不用日文平台,看到的就会是乱码,永远都不会知道那是什么意思。

我们继续。打开名称表窗口(Search for/Names,Ctrl+N)。可以看到,果然导入的是 MessageBoxA()(类型为 Import):

导入 MessageBoxA()

找到这一项了,就查找调用点(Find references,Ctrl+R):

MessageBoxA() 的引用者

好了,这么多项,请问哪一个是我们需要的呢?

不知道,只好一个一个试了。_(:з」∠)_

值得注意的是,所有的调用都间接的,通过 apphelp.dll 的一个未公开的函数。看接受的参数,恐怕是 assert() 一类的,某个函数调用失败时能显示一个错误。

结果是,位于 00403AFD 的那一项是我们需要的。展开看看有什么:

CPU Disasm
Address   Hex dump          Command                                            Comments
00403A5A      C74424 10 000 MOV DWORD PTR SS:[ESP+10],0
00403A62  />  8B0D 3C824A00 /MOV ECX,DWORD PTR DS:[4A823C]                     ; ASCII "a:\"
00403A68  |.  8A5C24 10     |MOV BL,BYTE PTR SS:[ESP+10]
00403A6C  |.  8D9424 1C0300 |LEA EDX,[ESP+31C]
00403A73  |.  898C24 1C0300 |MOV DWORD PTR SS:[ESP+31C],ECX
00403A7A  |.  80C3 61       |ADD BL,61
00403A7D  |.  52            |PUSH EDX                                          ; /RootPath
00403A7E  |.  889C24 200300 |MOV BYTE PTR SS:[ESP+320],BL                      ; |
00403A85  |.  FF15 E4014A00 |CALL DWORD PTR DS:[<&KERNEL32.GetDriveTypeA>]     ; \KERNEL32.GetDriveTypeA
00403A8B  |.  83F8 05       |CMP EAX,5                                         ; CONST 5 => DRIVE_CDROM
00403A8E  |.  75 36         |JNE SHORT 00403AC6
00403A90  |.  6A 01         |PUSH 1
00403A92  |.  FFD5          |CALL EBP
00403A94  |.  8BF8          |MOV EDI,EAX
00403A96  |.  8D4424 18     |LEA EAX,[ESP+18]
00403A9A  |.  68 38824A00   |PUSH OFFSET 004A8238                              ; /Arg2 = ASCII "rb"
00403A9F  |.  50            |PUSH EAX                                          ; |Arg1
00403AA0  |.  885C24 20     |MOV BYTE PTR SS:[ESP+20],BL                       ; |
00403AA4  |.  E8 D00A0600   |CALL 00464579                                     ; \StrawberryFeels.00464579
00403AA9  |.  8BF0          |MOV ESI,EAX
00403AAB  |.  83C4 08       |ADD ESP,8
00403AAE  |.  85F6          |TEST ESI,ESI
00403AB0  |.  74 09         |JE SHORT 00403ABB
00403AB2  |.  56            |PUSH ESI                                          ; /Arg1
00403AB3  |.  E8 DA080600   |CALL 00464392                                     ; \StrawberryFeels.00464392
00403AB8  |.  83C4 04       |ADD ESP,4
00403ABB  |>  57            |PUSH EDI
00403ABC  |.  FFD5          |CALL EBP
00403ABE  |.  85F6          |TEST ESI,ESI
00403AC0  |.  75 77         |JNE SHORT 00403B39
00403AC2  |.  8B7424 14     |MOV ESI,DWORD PTR SS:[ESP+14]
00403AC6  |>  8B4424 10     |MOV EAX,DWORD PTR SS:[ESP+10]
00403ACA  |.  40            |INC EAX
00403ACB  |.  83F8 1A       |CMP EAX,1A
00403ACE  |.  894424 10     |MOV DWORD PTR SS:[ESP+10],EAX
00403AD2  |.^ 7C 8E         \JL SHORT 00403A62
00403AD4  |.  E8 F7850400   CALL 0044C0D0                                      ; [StrawberryFeels.0044C0D0
00403AD9  |.  E8 82C30400   CALL 0044FE60                                      ; [StrawberryFeels.0044FE60
00403ADE  |.  E8 ADBE0400   CALL 0044F990
00403AE3  |.  50            PUSH EAX                                           ; /hWnd
00403AE4  |.  FF15 64024A00 CALL DWORD PTR DS:[<&USER32.SetForegroundWindow>]  ; \USER32.SetForegroundWindow
00403AEA  |.  6A 05         PUSH 5                                             ; /Arg4 = 5
00403AEC  |.  E8 AFBE0400   CALL 0044F9A0                                      ; |
00403AF1  |.  50            PUSH EAX                                           ; |Arg3
00403AF2  |.  68 0C824A00   PUSH OFFSET 004A820C                               ; |Arg2 = StrawberryFeels.4A820C
00403AF7  |.  E8 94BE0400   CALL 0044F990                                      ; |
00403AFC  |.  50            PUSH EAX                                           ; |Arg1
00403AFD  |.  FF15 5C024A00 CALL DWORD PTR DS:[<&USER32.MessageBoxA>]          ; \apphelp.732324A0
00403B03  |.  83F8 02       CMP EAX,2
00403B06  |.^ 0F85 CBFEFFFF JNE 004039D7
00403B0C  |.  6A 00         PUSH 0                                             ; /Arg1 = 0
00403B0E  |.  E8 3D2F0200   CALL 00426A50                                      ; \StrawberryFeels.00426A50
00403B13  |.  83C4 04       ADD ESP,4
00403B16  |.  E8 D5360200   CALL 004271F0                                      ; [StrawberryFeels.004271F0
00403B1B  |.  E8 D0CE0000   CALL 004109F0                                      ; [StrawberryFeels.004109F0
00403B20  |.  E8 9BA10200   CALL 0042DCC0                                      ; [StrawberryFeels.0042DCC0
00403B25  |.  E8 26A80200   CALL 0042E350                                      ; [StrawberryFeels.0042E350
00403B2A  |.  6A 00         PUSH 0                                             ; /ExitCode = 0
00403B2C  |.  FF15 F8024A00 CALL DWORD PTR DS:[<&USER32.PostQuitMessage>]      ; \USER32.PostQuitMessage
00403B32  |.  6A 00         PUSH 0
00403B34  |.  E8 CC160600   CALL 00465205
00403B39  |>  6A 00         PUSH 0                                             ; /Arg1 = 0
00403B3B  |.  E8 102F0200   CALL 00426A50                                      ; \StrawberryFeels.00426A50
00403B40  |>  83C4 04       ADD ESP,4
00403B43  |.  5F            POP EDI
00403B44  |.  5E            POP ESI
00403B45  |.  5D            POP EBP
00403B46  |.  5B            POP EBX
00403B47  |.  81C4 10040000 ADD ESP,410
00403B4D  \.  C3            RETN

在 Olly Debug 中,这一段是当前函数的第二部分,很明显是一个整体。之所以说它重要,是因为这里:

对 PostQuitMessage() 的调用

00403B2C 处,有一个对 PostQuitMessage() 的调用——这个调用说明要发送一个 WM_QUIT 消息,准备终止消息泵了。什么时候要终止消息泵呢?在无法在脚本中处理(意味着脚本引擎没有初始化)的时候,或者正常退出的时候。再看 00403A85,一个对 GetDriveTypeA() 的调用!00403A9A 的参数 "rb" 肯定是传递给 fopen() 或者类似物的,而且被硬编码了(一般就是这么写的嘛)。看来这里就是要找的了。

根据这个循环(00403A6200403AD2)和接下来的函数,我们可以知道这里的逻辑:循环检测各个驱动器是否是光盘驱动器,然后检查是否有需要的文件;如果26个可能的位置都没有,那就说明没插入光盘,显示提示,用户选择“取消”的话就准备退出。

有兴趣的同学可以看看这往前一点点的内容,看看 sprintf() 的调用参数,就在不到20行之前。

于是我们现在要看看跳到哪里。这里写成伪代码大概是这种形式(为什么是这种别扭的形式……汇编的代码啊):

char root_path[4] = "a:\\";
bool found = false;

while (true) {
    for (char c = 0; c < 26; c++) {
        cstr = "a:\\";
        char drive_letter = cstr[0] + c;
        root_path[0] = drive_letter;
        int drive_type = GetDriveType(root_path);
        if (drive_type == DRIVE_CDROM) {
            file_path_print(full_path, drive_path);
            if (FILE *fp = fopen(full_path, fp)) {
                found = true;
                fclose(fp);
                break;
            }
        }
    }
    if (!found) {
        SetForegroundWindow(thisHwnd); // 强制让当前窗口获得焦点
        UINT ret = MessageBox(GetAppHWnd(), TextToDisplay, GetAppTitle(), MB_RETRYCANCEL);
        // 不展开了
        if (is_cancel_pressed(ret)) {
            MyExit();
        }
    }
}
DoSomeWork(0);
return;

可以看到,关键跳转的地方是找到了光驱和验证完成时的跳转。可以在 00403AC0 找到一句 JNE SHORT 00403B39,这是往后跳出了整个循环的,对应的就是那个 break;

看看前后,可以确认 00403B39 的位置是那个 DoSomeWork() 的调用,之后是 return; 所编译出的恢复环境的代码了。

函数的退出

另外 00403B34 处的那个调用是在干什么?翻开一看居然调用了 GetCurrentProcess()TerminateProcess()……我说你们真狠,连一个体面的结束都不给,至少也得返回一个验证失败然后逐层退到入口函数来退出,手动回收各种资源啊,不要用终止进程的方法偷懒啊!

00403B3B 的代码的时候,发现了“Majiro”的字样。一查,原来就是这引擎的名字。此外,澄空上已经有人分析了一些

好了,分析了这么多,我们知道了:只有检测到不正常的情况下,才会调用 TerminateProcess() 直接终止进程,否则什么都不做。

原型估计是这样的:

void EnsureDiskIsPlacedIn();

调用估计是这样的:

void OnLoad() {
    EnsureDiskIsPlacedIn();
    DoOtherWork();
}

5、代码修改

说了这么多,大家应该意识到了,我们直接将这个控制逻辑短路了不就好了?我们往里面放一个 JMP 跳转不就行了?

在32位环境下,我们要放的是一个 JMP(空间限制,无法 JMP SHORT)长度是5字节(1字节操作码和附加信息+4字节操作数),于是我们要找一个合适的容器放下这个指令。我选择的就是位于 00403A5A 的这一句:

MOV DWORD PTR SS:[ESP+10], 0

明显是一个对局部变量赋值的语句。可以看到,长度是8字节,满足要求。于是使用汇编功能(空格或双击指令),写入 JMP 00403B39,选上“保持长度”(Keep size)和“用 NOP 填充”(Fill rest with NOPs)。汇编,写入,可以看到多余的3个字节被 NOP 指令填充了。然后保存(Edit/Copy to executable,然后 Save file)到一个新的文件(如 StrawberryFeels_cracked.exe)。

然后再用 Locale Emulator 启动。发现了什么?

大功告成


附记:

我以前看过一点汇编,Win32 汇编程序编写。当时也不太清楚什么道理。不过近年来我通过 C/C++ 对运行机制有了一点了解,加上本学期的微机原理补充了16位汇编的基础,这次我能看懂多一些代码了。问学的16位看32位是否有困难——我想说,原理是一样的,如果死揪课本里的那些东西当然会有困难(有同学考前死背“JMP 指令占3字节”,但是却不了解指令结构,看到32位下的5字节会很惊讶的吧),但是理解了实质后应用那就是指哪儿打哪儿。

试玩了一下,立绘的确不怎么样,游戏系统也是十分糟糕(操作居然都放到了 Windows 菜单里!)。

我没破解区域验证,是因为现在还没人做这个东西的汉化,破解了也没意义——本来就是 Shift-JIS 的内容,用 ANSI 版 API,你却硬要做区域破解,系统自然会按照 GBK 来读;到时候显示在眼前的就是满屏的乱码了。反正我有 LE,能玩。

要问为什么菜单、安装的对话框没有乱码(但是获取安装路径时有)?那是因为使用了资源模板啊!资源区域应该是设为了 1041(日语-日本)了吧!

第一次独立破解,做 NoDVD 的教程什么的也没有,摸索摸索走出一条路。不过确实有大神做用于汉化的破解教程的。原理差不多,我觉得,搜索调用点,修改、短路。因为我读过,也觉得自己有一点储备,所以才有信心认为能独立实践一次。Olly Debug 也是第一次用,感觉真是十分难用,也十分强大。


呜呼,原来以为只需要写一小时的东西,来来去去写了快三个小时,从00:22到03:15。没事,我还能躺30分钟,在起来准备东西,然后和大家四点半集合赶七点的火车……但是我本来预计要在车上看工艺文档的说,估计要全程睡觉了……

分享到 评论