首页
社区
课程
招聘
[原创]嵌入Python解释器的程序逆向 - 1
发表于: 2024-6-8 10:11 9986

[原创]嵌入Python解释器的程序逆向 - 1

2024-6-8 10:11
9986

最近在玩一款单机游戏,想试着改一下里面各类数据,却发现生命值、金币等数据都没办法修改。往里面一看,发现这游戏居然在完全单机的情况下,都会往一个本地的 Python 服务器发包。重要的逻辑都在这个服务器里面处理了。

这个 Python 服务器是以 DLL 的形式作为 Unity 插件引入的。DLL 导出了服务器初始化、发包、收包的函数,由 GameAssembly.dll 那边直接调用。

查了一下网上的信息,据说这款游戏之前逻辑是放客户端的,后来就一直在往这个 Python 服务器里挪,还把客户端的各种调试方法都删了。这可能也是为什么客户端里有一个类的方法通过 il2cpp dumper 出来后偏移都一样的原因。这个类包含了很多看名字就觉得很有用的调试方法,例如应用伤害等;可惜都是空的,ida里看这个地址就是 retn 0;
图片描述

细心的小伙伴可能会问了,虽然这是服务器,但它也在程序的进程内,内存空间是在一起的,怎么会没有办法修改呢?
简单的说,就是 Python 的值打一枪换一个地方,所以 CE 这种搜固定内存的变化的方法是很难直接找到对应的值进行修改的。你可以自己启动 Python,输入 a=1234, 用 CE 搜索,再输入 a = a+1,再用 CE 搜索,是搜不到任何对应内存的。
下面我用一段官方的示例代码,来说明一下。这段 Python 代码和 C 代码是等价的,用于将 dict[key] 自增 1.

可以看到 Python 底层对象都是 PyObject。存储很喜欢 dict 这种形式。每个类的函数、成员都是用 dict 存的。Hook 对 dict 的操作就能够监视到很多信息。
关注其中 incremented_item = PyNumber_Add(item, const_one); 在获取到 item 后,并不会直接对 item 内部的内存进行自增,而是调用函数进行加法,创建了一个新的对象 incremented_item,然后再 PyObject_SetItem(dict, key, incremented_item) 换回去。所以两个值在内存上并不会是同一个地址。
另外,注意其中 Py_XDECREF(item); 这是减少一次引用计数的意思。python 底层实现里 PyObject 都是有引用计数的。这也意味着我们如果直接修改内存中的值,会同时修改所有使用这个对象的地方。
所以,最好的方法还是在 Python 解释器以及前面的字节码这部分把问题解决掉,而不是在内存里解决。

那就来逆这个 Python 服务器吧。ida 搜索到大量 Python 相关的字符串,鉴定为 Cpython-36。
图片描述
弄一份 Cpython-36 的源码,同时安装一下可执行文件。源码链接
先试着用下载下来的带符号 python36.dll 的特征码搜索,发现搜不到。这可能是因为 build 版本不同,还可能是因为这个嵌入的 python 解释器是从源码编译的。
没法直接搜特征,所以只能对照着源码的字符串,去定位一些关键函数。这个步骤就和做数独一样。
个人感觉hook后能够获得大量信息的函数有 PyUnicode_FromString, _PyObject_GenericGetAttrWithDict, PyObject_SetItem, PyObject_Call。还有一些有用的辅助函数有PyObject_Repr, PyUnicode_AsUTF8, PyGILState_Ensure, PyGILState_Release

找到这些函数的地址后,写一个 dll 来进行 hook。可以 include 一下
Python36-x64\include\Python.h,虽然不能直接用自己这个 Python 解释器里的函数,但是头文件里很多宏是对对象直接操作的,还是比较有用的。
hook 之后如何获取运行信息呢? PyUnicode_FromString 的参数就是 const char * ,直接打印就好。但是其他几个参数都是 PyObject ,所以我们需要把 PyObject 转为可读的字符串,以便进行进一步的分析。
利用嵌入的 Python 解释器自己的 PyObject_Repr 和 PyUnicode_AsUTF8 就可以获得可读信息。下面是我用的代码。
函数地址是嵌入的 Python 解释器对应函数的地址。有些对象可以直接用自己下载的那个解释器的函数,应该是字符串对象这种不需要执行具体指令的对象。但是其他对象很容易崩。

尽管这样,对于一些对象还是会在 oPyObject_Repr 里似乎是死锁崩溃。对 Python 对象操作前据说要先拿全局锁,但我试着用 oPyGILState_Ensure 获取 Python C API 全局锁,并没有解决崩溃的问题。
因此,有些对象不方便用这种方法获取可读信息,就可以只获取其类型信息,用string obj_class = Py_TYPE(obj)->tp_name;。同时,尽量减少查看的类型,过滤不感兴趣的调用可以有效减少崩溃。
下面是对 _PyObject_GenericGetAttrWithDict 的 Hook 示例。

这个函数里,检测到获取 Player 类的 m_HP 变量时,就将变量存储的值修改为想要的值。示例中是通过打印信息提前知道了返回值在这种情况下是 int,所以用 ((PyLongObject*)ret)->ob_digit[0] = 10000;设置。
这样直接改存储值其实不好,一方面所有用同一个对象的地方都会被修改,一方面存储0这样的常量的地方是改不了。更好的做法是像 Python 那样新建对象、设置对象、减少引用。但无所谓,现在玩家的 m_HP 已经在 Python 解释器层面无法被减少了。

只是改值满足不了我,我还想进行更多修改,怎么办?
PyObject_Call 能够监听很多东西。看日志发现程序导入 zlib,从一个特殊格式的文件里读入信息,并进行解压。解压后立即进行了模块导入。看解压的内容,'\33\r\r\n'刚好是 Python36 字节码的 MaigcNumber。可以推断这是实际运行的业务代码。类名、函数名都是能够从字节码里还原出来的。
图片描述
然而,尝试了 python-uncompyle6 ,以及 python3.6里直接 dis.dis(data[16:]) 后,都失败了。
根据日志的 PyObject_Call 看到嵌入的解释器能够直接导入该字节码的。
一方面可以试试纯用 Python C API 接口,例如 PyObject_Dir 在嵌入的解释器里查,但是这样逆向写起来实在算不上方便;
一方面说不定能够用 PyRun_InteractiveLoop 调出交互命令行来,这样应该更加方便。
还有纯逆向直接调用 PyObject_Call,但是想想都觉得很麻烦。
如果接下来想要获得更大的自由度,应该考虑看一看 Python 字节码层面。直接摆弄解释器还是太底层了。

本文介绍了一种 Hook Python 解释器底层函数进行逆向分析的方法,在解释器层面实现了获取特定值的劫持。这对于嵌入 Python 或者魔改了解释器的 Python 程序分析有一定的帮助。但个人感觉有点弄复杂了,希望各位大佬指教。

def incr_item(dict, key):
    try:
        item = dict[key]
    except KeyError:
        item = 0
    dict[key] = item + 1
def incr_item(dict, key):
    try:
        item = dict[key]
    except KeyError:
        item = 0
    dict[key] = item + 1
int
incr_item(PyObject *dict, PyObject *key)
{
    /* Objects all initialized to NULL for Py_XDECREF */
    PyObject *item = NULL, *const_one = NULL, *incremented_item = NULL;
    int rv = -1; /* Return value initialized to -1 (failure) */
 
    item = PyObject_GetItem(dict, key);
    if (item == NULL) {
        /* Handle KeyError only: */
        if (!PyErr_ExceptionMatches(PyExc_KeyError))
            goto error;
 
        /* Clear the error and use zero: */
        PyErr_Clear();
        item = PyLong_FromLong(0L);
        if (item == NULL)
            goto error;
    }
    const_one = PyLong_FromLong(1L);
    if (const_one == NULL)
        goto error;
 
    incremented_item = PyNumber_Add(item, const_one);
    if (incremented_item == NULL)
        goto error;
 
    if (PyObject_SetItem(dict, key, incremented_item) < 0)
        goto error;
    rv = 0; /* Success */
    /* Continue with cleanup code */
 
 error:
    /* Cleanup code, shared by success and failure path */
 
    /* Use Py_XDECREF() to ignore NULL references */
    Py_XDECREF(item);
    Py_XDECREF(const_one);
    Py_XDECREF(incremented_item);
 
    return rv; /* -1 for error, 0 for success */
}
int
incr_item(PyObject *dict, PyObject *key)
{
    /* Objects all initialized to NULL for Py_XDECREF */
    PyObject *item = NULL, *const_one = NULL, *incremented_item = NULL;
    int rv = -1; /* Return value initialized to -1 (failure) */
 
    item = PyObject_GetItem(dict, key);
    if (item == NULL) {
        /* Handle KeyError only: */
        if (!PyErr_ExceptionMatches(PyExc_KeyError))
            goto error;
 
        /* Clear the error and use zero: */
        PyErr_Clear();
        item = PyLong_FromLong(0L);
        if (item == NULL)
            goto error;
    }
    const_one = PyLong_FromLong(1L);
    if (const_one == NULL)
        goto error;
 
    incremented_item = PyNumber_Add(item, const_one);
    if (incremented_item == NULL)
        goto error;
 
    if (PyObject_SetItem(dict, key, incremented_item) < 0)
        goto error;
    rv = 0; /* Success */
    /* Continue with cleanup code */
 
 error:
    /* Cleanup code, shared by success and failure path */
 
    /* Use Py_XDECREF() to ignore NULL references */
    Py_XDECREF(item);
    Py_XDECREF(const_one);
    Py_XDECREF(incremented_item);
 
    return rv; /* -1 for error, 0 for success */
}

[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 2024-6-8 15:00 被mb_fefksfsl编辑 ,原因: 修错字
收藏
免费 2
支持
分享
最新回复 (9)
雪    币: 5137
活跃值: (6862)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
可否贴一下hook时候的dll源码
2024-6-8 22:21
0
雪    币: 1516
活跃值: (913)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
3
霸气压萝莉 可否贴一下hook时候的dll源码

普通的minHook创建hook而已。找偏移地址人工去找的,所以贴出来没太大参考价值。

稍微注意就是可以等它嵌入的python解释器初始化了再做操作,不过影响不大,因为这里只是在hook时调用解释器的函数,并没有主动去调用。

using PyObject_GetAttrString_t = void* (__cdecl*)(void* obj, const char* name);
PyObject_GetAttrString_t oPyObject_GetAttrString = nullptr;

#define MAKE_HOOK(x) do { \
    x##_t x = (x##_t) ((uintptr_t)hmodule + offset::##x); \
    ostringstream oss; \
    oss << "Hmodule: " << std::hex << hmodule << " " #x " " << (uintptr_t) x; \
    LogMsg(oss.str()); \
    if (MH_OK != MH_CreateHook(x, My##x, (void**)&o##x)) \
    { LogMsg(#x " MH_CreateHook failed" ); } \
    else if (MH_OK != MH_EnableHook(x)) \
    { LogMsg(#x " MH_EnableHook failed"); } \
    } while(0)

    
void MyShellCode() {
    MH_STATUS status = MH_OK;
    status = MH_Initialize();
    if (status != MH_OK) {
	LogMsg("MH_Initialize failed");
    }
    auto hmodule = GetModuleHandleW(L"gameserver.dll");
    while (!hmodule) {
        Sleep(1000);
        hmodule = GetModuleHandleW(L"gameserver.dll");
    }
    Sleep(5000); // Wait python initialize
    
    MAKE_HOOK(_PyObject_GenericGetAttrWithDict);
    
    OutputDebugStringA("[plugin] Shellcode finished");
}

namespace offset{
    constexpr unsigned long long _PyObject_GenericSetAttrWithDict = 0xdeadbeef;
}


最后于 2024-6-9 17:18 被mb_fefksfsl编辑 ,原因:
2024-6-9 17:10
1
雪    币: 35
活跃值: (612)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
学习了, 以前只弄能反编译的,不能就放弃; 这是个不错的办法.  py op数量太多恢复太累了; 而且高版本还不支持
2024-6-12 19:20
1
雪    币: 1205
活跃值: (708)
能力值: ( LV5,RANK:71 )
在线值:
发帖
回帖
粉丝
5
您好,请问可以贴一下那串字节码吗?看前面确实符合pyc结构的,想研究一下为何失败(p.s. dis失败是因为Python 3.6的pyc文件头只有12字节,dis.dis(data[16:])那必然是有问题的 XD
2024-6-27 09:57
0
雪    币: 1516
活跃值: (913)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
6

光看字节码可能作用不大吧,完全有可能解压出来后做了别的修复操作,这个得看其他逻辑才好具体分析。

附件是一段比较短的解压出来的结果,010editor报的是unkown type code。

data =  b'3\r\r\nPU\\d\xf6\x01\x00\x00\xe3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00@\x00\x00\x00\x1a\x00\x00\x00s4\x00\x00\x00||\xfc\xf8\xefT||\xfc\xef\xdd\xc4|\xc7\x95\xfd\xc5\xf5\xc4||\xb7\xef|\x13j\x00\x01\x00\x01\x01\x01\x00\x02\x02\x02\x00\x03\x03\x04\x00\x07\x13\x14\x00\x05\x04\x05\x00\x06\x02\x01)\x06\xe9\x00\x00\x00\x00)\x01\xda\x0bPythonErrorN\xda\x0bg_CustomClsc\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\t\x00\x00\x00C\x00\x00\x00&\x00\x00\x00sO\x00\x00\x00\x7fo\xfd\xc5|\x13\x7fo\xfd\xc5\xdco\xaa|\x7f\x0f\x95\xfa\x7f\xaao\x7f)d\xc3TTTo\x95T|\x13o\x7f3\x13j\x00\x00\x07\x06\x07\x00\x01\x00\x01\x07!%\x0e\x0f\x02\x03\x01\x00\x01\x01\x01\x01\x04\x01\x00\x01\x01\x08\x08\x01\x01\x01\x05\x00\x01\x00\x01\x01\x00\x01\x01)\x02Nz!cl_platformdata.custom.weapon.i%d)\x06\xda\x0fg_CustomDefinesr\x03\x00\x00\x00\xda\timportlib\xda\rimport_moduleZ\x07CCustomr\x02\x00\x00\x00)\x02Z\x07iCustom\xda\x03mod\xa9\x00r\x08\x00\x00\x00\xfa8..\\clientlogic\\cl_platformdata\\custom\\weapon\\__init__.py\xda\x0cGetCustomCls\r\x00\x00\x00s\x14\x00\x00\x00\x00\x03\x04\x01\x02\x01\x04\x01\x01\x01\x07\x01\x07\x01\x03\x01\x03\x01\x02\x01r\n\x00\x00\x00)\x07Z\x07cl_onlyr\x02\x00\x00\x00r\x05\x00\x00\x00r\x04\x00\x00\x00\xda\x07globalsr\x03\x00\x00\x00r\n\x00\x00\x00r\x08\x00\x00\x00r\x08\x00\x00\x00r\x08\x00\x00\x00r\t\x00\x00\x00\xda\x08<module>\x02\x00\x00\x00s\n\x00\x00\x00\x06\x02\x04\x03\x02\x02\x05\x01\x02\x03'


上传的附件:
2024-6-27 16:34
0
雪    币: 1516
活跃值: (913)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
7
c10udlnk 您好,请问可以贴一下那串字节码吗?看前面确实符合pyc结构的,想研究一下为何失败(p.s. dis失败是因为Python 3.6的pyc文件头只有12字节,dis.dis(data[16:])那必然是 ...
已经贴上了。我之前看到是python 2文件头12字节,python3文件头16字节来着
2024-6-27 16:37
0
雪    币: 0
活跃值: (70)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
多益的游戏哇,我这儿有个样本魔改了所有的opcode,可以用bindiff对比python36.dll,尝试还原。我只还原了部分。但是看你贴出来的这个样本,并没有魔改多少opcode。
2024-8-9 14:21
0
雪    币: 0
活跃值: (70)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9
如果样本中,解释器的compile函数没有被屏蔽的话,可以试试采用hook的方式,直接调用底层的PyRun_String,这样就可以打印所有的module的结构,然后编写python脚本进行执行了。调用前后还要调用解释器中获取线程上下文和释放上下文的两个函数。
2024-8-9 14:25
0
雪    币: 1516
活跃值: (913)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
10
xieemengxin 如果样本中,解释器的compile函数没有被屏蔽的话,可以试试采用hook的方式,直接调用底层的PyRun_String,这样就可以打印所有的module的结构,然后编写python脚本进行执行了。调 ...
2024-8-9 19:56
0
游客
登录 | 注册 方可回帖
返回
//