富甲天下 3 是一个三国题材的大富翁类游戏,里面有一个赌场小游戏,游戏中随机生成 5 个象棋棋子,不同排列组合的 5 个棋子对应不同倍数的奖励。
本次调试的目标是拿到 5 个棋子完全相同的 10 倍奖励“五子登科", 先上最终成果图。
切入点: 游戏中点击'保留'以将棋子翻转至反面,同时'保留'键会被重新绘制为'换棋'。我们以通过观察按键转化时的内存数据变化,找到调试入口。
由于数据的初始值与增减性无从知晓, 因此使用 CE 时只能采取模糊搜索。先从'unknown initial value'开始搜索, 按键后再勾选'changed value'继续搜索,中间可以穿插一些无关的操作后勾选'unchanged value'以期大幅度减少搜索范围。
反复几次后,我们观察到一个可疑的地址:0x00492444。当按键为'保留'时,其值为0,当按键为'换棋'时,其值为1.
当全部五个按键为'换棋'状态时,0x00492444 到 0x00492454 的内存区间全部为小字节序 1。当全部五个按键为'保留'状态时,相同的内存区间全部为 0。由此可以推断,这段内存被用来表示棋子翻开的状态。接下来判断这个变量在进程空间中的位置,先看看会谁往这个地址里写东西。
根据之前观察到的现象,图中edx的作用很有可能是棋子的偏移量, 当 edx = 0,1,2,3,4时,对应第1,2,3,4,5个棋子。因此,0x00492454 位于进程数据段或 BSS 段,其地址不会随每次程序的装入而变更。CE 已经提供了足够的信息,下一步使用 OD 开始调试。
如图所示,棋子在'换棋'状态时棋子上的字符是不显示的,跳转至'保留'状态后才会重新显示。我们可以利用这一特点,搞清楚棋子翻转显示字符时的依据,进而搞清楚棋子的属性。举个例子,棋子'士'在'换棋'状态时是白板,在'保留'状态时才被赋值'士',当我们这个赋值动作依据什么知道这个白板应该被赋值'士'的,我们再修改这个依据,就可以把这个棋子更改为任意棋子。先将棋子翻转至'换棋',然后在0x00492444下断。翻转棋子,在写0的时候果然被断下来了。
棋子被翻出了角度,然而并没有出现字符,同时我们观察到了一个大循环,目测循环结束之后整个棋子就会逐渐显现字符并最终被翻转至'保留'。
蛋疼的一幕出现了,翻转过程中无论棋子上本来是什么都会显示'兵',这个循环略过。最终,棋子完全翻转成’保留’并且被赋值了正确的字符。完成这个动作的函数位于 0x00411790,我们进去看看。
进去后里面还有一个调用 0x00404D10,真正的换图发生在这个调用之后,再跟进去。
看到一个系统调用 bitblt。这个函数接受一个源 HDC 句柄,一个目标 HDC 句柄,并且把源 HDC 所属的图像资源粘贴至目标 HDC 句柄。很明显,这个源 HDC 就是写有'士'棋子的容器,这个目标 HDC 就是我们的白板棋子,我们看看它的来源。
当 0x00404D10 调用 bitblt 时,源 HDC 被存在 EDX 中压入栈,给EDX赋值的指令是'MOV EDX,DWORD PTR SS:[ESP+10'。从栈图中得知,将ESP+10压入栈的指令是调用者的'PUSH EDX',往上翻,调用者的 EDX 是在运行指令'MOV EDX, DWORD PTR DS:[4B181C]'后被赋值的。0X004B181C 是一个位于数据段的空间,被 5 个棋子共享。如果在这个地址下断,翻动棋子后谁对这个地址进行了修改,应该会很快找到存放棋子属性的内存地址。
很遗憾,由于本人水平有限,没有从这条线索挖掘到任何有价值的信息,被迫另辟蹊径。
回到翻转棋子动画的大循环。之前提到过,翻转棋子的第 1 步到第 N-1 步都显示'兵,无论棋子本来是什么。在大循环结束的最后一步翻转,却会打印正确的字符。这给我们提供另一个线索,就是最后一次翻转和前面的 N-1 次翻转的不同之处。显然,两次翻转前都调用了两次 0x00428400,这让我不得不怀疑这个函数是不是与最后的翻转结果有紧密的联系。为了验证这种联系,我决定爆破掉大循环外最后一次翻转的最后一次 0x00428400 调用。
果然,这次爆破导致最后一步翻转无法完成,这证明了之前的猜测是正确的。下一步要做的就是搞清楚函数 0x00428400 是如何决定翻转结果的。众所周知,函数的输入决定输出,一定是函数被传递的参数不同导致棋子翻转结果不同。
以上是翻转五个棋子的参数,为避免不必要的干扰,所有棋子均位于左起第一位且均为红色。可以看出,当参数为0x08时字符为'象',当参数为0x1E时字符为'兵'。我们把5个棋子的第二个参数都改成0x08验证一下新发现的规律。
成功了,五个棋子全改成了'相', 然而并没有什么卵用。实践证实,通过这种手段修改后只是单纯的修改了贴图而拿不到五子登科的十倍奖励,我们需要追踪0x08的源头。
由上表可以看出,0x08 是倒数第三个入栈的参数。翻回至函数调用可以看到倒数第三个参数存放于 ESI 中,而 ESI 中的值来自于一个循环。这个循环的结束条件与循环开始前存储于 EDX*4+0x0049121F8 的值有关系。根据之前 CE 的使用经验,很自然地联想到 EDX 中存放的就是棋子的偏移量,其中0,1,2,3,4分别对应第1,2,3,4,5枚棋子。我们需要知道当第一枚棋子被翻转前,0x0049121F8 存放的值会被谁写入,果断在这个地址下断。
这次在进赌场前就被断下来了,看来距离真相已经十分接近了。
下面的调试过程实在懒得写了,只说结论。
1. 函数 0x004124D5 负责生成一组代表棋子的初始数据段,该数据段的起点地址为0x0049233C,终点地址为0x004923BC。
2. 函数 0x004124DA 负责打乱函数 0x004124D5 生成的代表棋子的初始数据段,并在产生不大于该数据段长度的随机值,储存于 0x00492240。
左为打乱前的数据,右为打乱后的数据(之一)。
储存在 0x00492240 的随机值被用于初始化五个旗子。在打乱的数据段中,第一个棋子的特征值为 [0x0049233C+[0x00492240+0]],
第二个棋子到特征值为 [0x004923CC+[0x00492240+4]]...以此类推,完成五个棋子的初始化。由于数据段被打乱且[0x00492240]是随机数,
因此每次[0x0049233C+[0x00492240+0]]...[0x0049233C+[0x00492240+14]]的值总不相同,棋子因此看上去是随机生成的。
3. 一种棋子有最少五种不同的4字节数据表式。
举例: 红色的'兵',可以这样表示: 0x10000000, 0x20000000, 0x40000000, 0x08000000, 0x80000000.
4. 当有 N 种数据,N+M 个数据表示一种棋子时,奖励只按 N 个棋子相同结算。
举例: 棋盘上的五个兵被这样存储: 0x10000000, 0x10000000, 0x10000000, 0x08000000, 0x08000000.
结算奖励时只按两个'兵'相同结算。因为只有0x10000000, 0x08000000两种数据表示棋子'兵',其他三个重复的'兵'被无视。
举例: 棋盘上的五个兵被可以被五种数据表示: 0x10000000, 0x20000000, 0x40000000, 0x08000000, 0x80000000.
结算奖励时按五个'兵'相同结算。因为有五种不同的数据表示同一棋子'兵'。
根据以上结论,开始最后的调试。
左起分别填入五种'兵'的数据。
五子登科GET!!!
===========================
关键代码如下:
//返回地址
LPVOID retaddr = (LPVOID)(0x004124df);
LPVOID jretaddr = VirtualAllocEx(hproc, NULL, sizeof(jretaddr), MEM_COMMIT, PAGE_READWRITE);
if (!WriteProcessMemory(hproc, jretaddr, &retaddr, sizeof(retaddr), &written))
printf("%d\n", GetLastError());
//五子登科函数
byte func[] = {
//mov dword ptr [00492240], 0
0xC7, 0x05, //mov dword ptr
0x3C, 0x23, 0x49, 0x00, //[0x0049233C+0]
0,0,0,0x10, // 0x10000000
0xC7, 0x05, //mov dword ptr
0x40, 0x23, 0x49, 0x00, //[0x0049233C+4]
0,0,0,0x20, // 0x20000000
0xC7, 0x05, //mov dword ptr
0x44, 0x23, 0x49, 0x00, //[0x0049233C+8]
0,0,0,0x40, // 0x40000000
0xC7, 0x05, //mov dword ptr
0x48, 0x23, 0x49, 0x00, //[0x0049233C+C]
0,0,0,0x80, // 0x80000000
0xC7, 0x05, //mov dword ptr
0x4C, 0x23, 0x49, 0x00, //[0x0049233C+F]
0,0,0,0x08, // 0x08000000
0xFF, 0x25, //jmp far
0x90, 0x90, 0x90, 0x90 //address
};
LPVOID funcaddr = VirtualAllocEx(hproc, NULL, sizeof(func), MEM_COMMIT, PAGE_EXECUTE);
memcpy(func + sizeof(func) - sizeof(jretaddr), &jretaddr, sizeof(jretaddr));
if (!WriteProcessMemory(hproc, funcaddr, func, sizeof(func), &written))
printf("%d\n", GetLastError());
//五子登科函数地址
LPVOID jfuncaddr = VirtualAllocEx(hproc, NULL, sizeof(funcaddr), MEM_COMMIT, PAGE_READWRITE);
if (!WriteProcessMemory(hproc, jfuncaddr, &funcaddr, sizeof(funcaddr), &written))
printf("%d\n", GetLastError());
//中继节点跳转五子登科函数指令
LPVOID jcmdaddr = (LPVOID)(0x004124E5);
byte jcmd[] = {
0xFF, 0x25, //jmp far
0x90, 0x90, 0x90, 0x90 //address
};
memcpy(jcmd + sizeof(jcmd) - sizeof(jfuncaddr), &jfuncaddr, sizeof(jfuncaddr));
if (!WriteProcessMemory(hproc, jcmdaddr, jcmd, sizeof(jcmd), &written))
printf("%d\n", GetLastError());
//屏蔽棋子生成函数并跳转至中继节点
LPVOID nopaddr = (LPVOID)(0x004124DA);
byte ncmd[] = {
0xEB, 0x09, //jmp short
0x90, 0x90, 0x90 //nop
};
if (!WriteProcessMemory(hproc, nopaddr, ncmd, sizeof(ncmd), &written))
printf("%d\n", GetLastError());
往期热门内容推荐
更多优秀文章,长按下方二维码,“关注看雪学院公众号”查看!
看雪论坛:http://bbs.pediy.com/
微信公众号 ID:ikanxue
微博:看雪安全
投稿、合作:www.kanxue.com