Reversing Cython .pyd Modules: Functions, Type Objects, and Wrapper Functions (Part 1)

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

While analyzing a Cython-compiled Python module (a .pyd), I ran into name obfuscation and a huge number of generated binary functions, so I dug into .pyd reverse engineering to analyze these samples efficiently. This documents the process for later reference. (There’s a lot to .pyd reversing — it took about a month to circle through it all.)

How .pyd works

How a .pyd is generated

Before reversing a .pyd, we need to understand how an interpreted Python program becomes one. Reading the Cython source makes it clear:

Cython compilation flow

Essentially it’s a script-language → C-language translation. So to reverse a .pyd, we first have to correctly recognize Python’s binary form — i.e. Cython-generated functions.

The key fact: a .pyd compiled on Windows is just a DLL (and a .so on Linux). Looking at the export table, a .pyd exports two functions: the DllMain and one more, detailed below.

Qa: how does the Python interpreter know the function info inside a .pyd?

The entry function PyInit_<module>.

As mentioned, a .pyd exports two symbols: a DllMain and PyInit_<module>. PyInit_<module> is the key. It’s implemented in ModuleNode.py:

    def generate_module_init_func(self, imported_modules, shared_utility_exporter, env, code):
        subfunction = self.mod_init_subfunction(self.pos, self.scope, code)

        self.generate_pymoduledef_struct(env, code)

        code.enter_cfunc_scope(self.scope)
        code.putln("")
        code.put_code_here(UtilityCode.load("PyModInitFuncType", "ModuleSetupCode.c"))

        modinit_func_name = EncodedString(f"PyInit_{env.module_name}")
        header3 = "__Pyx_PyMODINIT_FUNC %s(void)" % self.mod_init_func_cname('PyInit', env)
        # Optimise for small code size as the module init function is only executed once.
        code.putln("%s CYTHON_SMALL_CODE; /*proto*/" % header3)
        if self.scope.is_package:
            code.putln("#if !defined(CYTHON_NO_PYINIT_EXPORT) && (defined(_WIN32) || defined(WIN32) || defined(MS_WINDOWS))")
            code.putln("__Pyx_PyMODINIT_FUNC PyInit___init__(void) { return %s(); }" % (
                self.mod_init_func_cname('PyInit', env)))
            code.putln("#endif")
... ....

PyInit_<module> actually exports the function info of the module.

How Cython handles function info during compilation

This is the core. Reading the source, Cython goes through these steps:

  1. Collect function info
  2. Generate the method table
  3. Generate single entries
  4. Generate the module definition
  5. Reference the method table

For reversing, the things to focus on are the function table __pyx_moduledef and the module-definition struct __pyx_methods. These definitions live in the CPython interpreter’s include directory; Cython just includes them. Using 3.11 as an example, they’re in methodobject.h and moduleobject.h:

struct PyMethodDef {
    const char  *ml_name;   /* The name of the built-in function/method */
    PyCFunction ml_meth;    /* The C function that implements it */
    int         ml_flags;   /* Combination of METH_xxx flags */
    const char  *ml_doc;    /* The __doc__ attribute, or NULL */
};
struct PyModuleDef {
    PyModuleDef_Base m_base;
    const char* m_name;
    const char* m_doc;
    Py_ssize_t m_size;
    PyMethodDef *m_methods;      /* <- points to the function table */
    PyModuleDef_Slot *m_slots;
    traverseproc m_traverse;
    inquiry m_clear;
    freefunc m_free;
};
#define METH_VARARGS  0x0001
#define METH_KEYWORDS 0x0002
#define METH_NOARGS   0x0004
#define METH_O        0x0008

PyInit_<module> in the source (ModuleNode.py:3092-3098):

        code.putln(header3)

        # CPython 3.5+ supports multi-phase module initialisation (gives access to __spec__, __file__, etc.)
        code.putln("#if CYTHON_PEP489_MULTI_PHASE_INIT")
        code.putln("{")
        code.putln("return PyModuleDef_Init(&%s);" % Naming.pymoduledef_cname)
        code.putln("}")

The generated C code looks like:

PyMODINIT_FUNC PyInit_mymodule(void) {
    return PyModuleDef_Init(&__pyx_moduledef);  /* <- just this one line */
}

At the binary level, we can jump straight to what we want through the export:

Locating __pyx_moduledef via the export

This is the __pyx_moduledef we want — with it we can manually enumerate every Python function in the module. (Otherwise, at the interpreter level you’d need import xxx, an environment fixup, and there can be hidden issues.)

Define the IDA structs:

struct PyMethodDef {
    __int64 ml_name;
    __int64 ml_meth;
    int ml_flags;
    int _pad;
    __int64 ml_doc;
};

struct PyModuleDef {
    __int64 ob_refcnt;
    __int64 ob_type;
    __int64 m_init;
    __int64 m_index;
    __int64 m_copy;
    __int64 m_name;
    __int64 m_doc;
    __int64 m_size;
    __int64 m_methods;
    __int64 m_slots;
    __int64 m_traverse;
    __int64 m_clear;
    __int64 m_free;
};

In practice: a class-based model has no single function, so the function table is empty for those.

Qb: how does the interpreter perceive the module’s objects and object info?

When external script code calls into the .pyd, the simplified flow is:

import cert
    |
    v
PyInit_cert()                     <- module entry
    |
    +--> PyModuleDef_Init()        <- create the module object
    |
    +--> PyType_Ready(Certificate) <- initialize the class's PyTypeObject
    |
    +--> PyObject_SetAttr(module, "Certificate", &type)  <- add the class to the module

The core is still the PyInit_<module> function. Before that we need PyTypeObject, the struct that stores class info:

PyTypeObject structure

Implemented in the CPython source (cpython\Include\cpython\object.h):

struct _typeobject {
    PyObject_VAR_HEAD
    const char *tp_name; /* For printing, in format "<module>.<name>" */
    Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */

    /* Methods to implement standard operations */

    destructor tp_dealloc;
    Py_ssize_t tp_vectorcall_offset;
    getattrfunc tp_getattr;
    setattrfunc tp_setattr;
    PyAsyncMethods *tp_as_async; /* formerly known as tp_compare (Python 2)
                                    or tp_reserved (Python 3) */
    reprfunc tp_repr;

    /* Method suites for standard classes */

    PyNumberMethods *tp_as_number;
    PySequenceMethods *tp_as_sequence;
    PyMappingMethods *tp_as_mapping;

    /* More standard operations (here for binary compatibility) */

    hashfunc tp_hash;
    ternaryfunc tp_call;
    reprfunc tp_str;
    getattrofunc tp_getattro;
    setattrofunc tp_setattro;

    /* Functions to access object as input/output buffer */
    PyBufferProcs *tp_as_buffer;

    /* Flags to define presence of optional/expanded features */
    unsigned long tp_flags;

    const char *tp_doc; /* Documentation string */

    /* Assigned meaning in release 2.0 */
    /* call function for all accessible objects */
    traverseproc tp_traverse;

    /* delete references to contained objects */
    inquiry tp_clear;

    /* Assigned meaning in release 2.1 */
    /* rich comparisons */
    richcmpfunc tp_richcompare;

    /* weak reference enabler */
    Py_ssize_t tp_weaklistoffset;

    /* Iterators */
    getiterfunc tp_iter;
    iternextfunc tp_iternext;

    /* Attribute descriptor and subclassing stuff */
    PyMethodDef *tp_methods;
    PyMemberDef *tp_members;
    PyGetSetDef *tp_getset;
    // Strong reference on a heap type, borrowed reference on a static type
    PyTypeObject *tp_base;
    PyObject *tp_dict;
    descrgetfunc tp_descr_get;
    descrsetfunc tp_descr_set;
    Py_ssize_t tp_dictoffset;
    initproc tp_init;
    allocfunc tp_alloc;
    newfunc tp_new;
    freefunc tp_free; /* Low-level free-memory routine */
    inquiry tp_is_gc; /* For PyObject_IS_GC */
    PyObject *tp_bases;
    PyObject *tp_mro; /* method resolution order */
    PyObject *tp_cache;
    PyObject *tp_subclasses;
    PyObject *tp_weaklist;
    destructor tp_del;

    /* Type attribute cache version tag. Added in version 2.6 */
    unsigned int tp_version_tag;

    destructor tp_finalize;
    vectorcallfunc tp_vectorcall;
};

This struct holds all the class info of the module. The important fields:

struct _typeobject {
    // basics
    const char *tp_name;        // class name, e.g. "cert.Certificate"
    Py_ssize_t tp_basicsize;    // instance object size

    // special methods
    destructor tp_dealloc;      // destructor (__del__)
    reprfunc tp_repr;           // __repr__
    hashfunc tp_hash;           // __hash__
    ternaryfunc tp_call;        // __call__
    reprfunc tp_str;            // __str__
    getiterfunc tp_iter;        // __iter__
    iternextfunc tp_iternext;   // __next__
    initproc tp_init;           // __init__
    newfunc tp_new;             // __new__

    // * most important *
    PyMethodDef *tp_methods;    // the class's method table!
    PyMemberDef *tp_members;    // member variables
    PyGetSetDef *tp_getset;     // property

    // inheritance info
    PyTypeObject *tp_base;      // parent class
    PyObject *tp_bases;         // all base classes
    PyObject *tp_mro;           // method resolution order
};

With this we can fully parse the module’s contents. How to find this table struct? Back to how the system loads it. The (simplified) call chain:

PyInit_cert()
    |  ModuleNode.py:3097
    +--> return PyModuleDef_Init(&__pyx_moduledef);
              |
              |  ModuleNode.py:3668
              +--> m_slots = __pyx_moduledef_slots[]
                      |
                      |  ModuleNode.py:3630
                      +--> {Py_mod_exec, exec_func}
                                |
                                |  ModuleNode.py:3227-3228
                                +--> generate_type_init_code()
                                        |
                                        |  ModuleNode.py:3907-3914
                                        +--> for entry in c_class_entries:
                                                generate_type_ready_code(entry)
                                                    |
                                                    |  Nodes.py:5889
                                                    +--> __Pyx_PyType_Ready(&PyTypeObject)

So in IDA: PyModuleDef → m_slots → exec function → PyType_Ready call — that lands on the PyTypeObject (our _typeobject).

The slot struct:

struct PyModuleDef_Slot {
    int slot;
    void *value;
};

Slots are functions executed before the module loads, to initialize the whole .pyd. From the source, class initialization happens in m_slots[1], and it’s variable-length, ending at 0. The generated source is in cython\Cython\Compiler\ModuleNode.py:

Module slots source

The code.putln inserts a generated line. Tidied pseudocode (if several classes exist):

static CYTHON_SMALL_CODE int __pyx_pymod_exec_cert(PyObject *__pyx_m)
{
    /*--- Global type/function init code ---*/

    // class 1: Certificate
    if (__Pyx_PyType_Ready(&__pyx_type_4cert_Certificate) < 0) __PYX_ERR(...)
    if (PyObject_SetAttr(__pyx_m, __pyx_n_s_Certificate, (PyObject *)&__pyx_type_4cert_Certificate) < 0) __PYX_ERR(...)

    // class 2: User
    if (__Pyx_PyType_Ready(&__pyx_type_4cert_User) < 0) __PYX_ERR(...)
    if (PyObject_SetAttr(__pyx_m, __pyx_n_s_User, (PyObject *)&__pyx_type_4cert_User) < 0) __PYX_ERR(...)

    // class 3: Session
    if (__Pyx_PyType_Ready(&__pyx_type_4cert_Session) < 0) __PYX_ERR(...)
    if (PyObject_SetAttr(__pyx_m, __pyx_n_s_Session, (PyObject *)&__pyx_type_4cert_Session) < 0) __PYX_ERR(...)

    // class N...

    return 0;
}

Verify the flow in IDA:

Verifying the flow in IDA (1) Verifying the flow in IDA (2) Verifying the flow in IDA (3) Verifying the flow in IDA (4)

Define the PyTypeObject struct to parse it:

typedef __int64 Py_ssize_t;
typedef unsigned __int64 size_t;

struct PyObject {
    Py_ssize_t ob_refcnt;
    void *ob_type;
};

struct PyVarObject {
    struct PyObject ob_base;
    Py_ssize_t ob_size;
};

struct PyMethodDef {
    const char *ml_name;
    void *ml_meth;
    int ml_flags;
    const char *ml_doc;
};

struct PyTypeObject {
    struct PyVarObject ob_base;
    const char *tp_name;
    Py_ssize_t tp_basicsize;
    Py_ssize_t tp_itemsize;
    void *tp_dealloc;
    Py_ssize_t tp_vectorcall_offset;
    void *tp_getattr;
    void *tp_setattr;
    void *tp_as_async;
    void *tp_repr;
    void *tp_as_number;
    void *tp_as_sequence;
    void *tp_as_mapping;
    void *tp_hash;
    void *tp_call;
    void *tp_str;
    void *tp_getattro;
    void *tp_setattro;
    void *tp_as_buffer;
    unsigned __int64 tp_flags;
    const char *tp_doc;
    void *tp_traverse;
    void *tp_clear;
    void *tp_richcompare;
    Py_ssize_t tp_weaklistoffset;
    void *tp_iter;
    void *tp_iternext;
    struct PyMethodDef *tp_methods;
    void *tp_members;
    void *tp_getset;
    struct PyTypeObject *tp_base;
    struct PyObject *tp_dict;
    void *tp_descr_get;
    void *tp_descr_set;
    Py_ssize_t tp_dictoffset;
    void *tp_init;
    void *tp_alloc;
    void *tp_new;
    void *tp_free;
    void *tp_is_gc;
    struct PyObject *tp_bases;
    struct PyObject *tp_mro;
    struct PyObject *tp_cache;
    struct PyObject *tp_subclasses;
    struct PyObject *tp_weaklist;
    void *tp_del;
    unsigned int tp_version_tag;
    int _padding;
    void *tp_finalize;
    void *tp_vectorcall;
};

Parsing PyTypeObject in IDA

Caution: what you find this way are statically created objects — internal objects, not the exported ones!

The exported classes are dynamically created. Let’s look at dynamic creation.

When does creation happen?

import cert
    |
    v
PyInit_cert()                    <- Python call entry
    |
    v
returns PyModuleDef (with m_slots)
    |
    v
Python sees the Py_mod_exec slot
    |
    v
calls the exec function (sub_18000A570)   <- creation starts here!
    |
    +-- 1. init internal scope struct (PyType_Ready)
    +-- 2. import dependent modules
    +-- 3. create the method dict = {}
    +-- 4. create each method object, put into dict
    +-- 5. type("Certificate", (), dict)  <- the class is born here!
    +-- 6. module.__dict__["Certificate"] = class

    v
import done, Certificate is available

It’s all inside the exec function. If you’re interested in the implementation, look at generate_execution_code. Here’s a taste of what the dynamically-created Cython code looks like:

/* ==================== step 1: create the class dict ==================== */
// from: Nodes.py:5334 - self.dict.generate_evaluation_code(code)
// ObjectHandling.c:1419 - PyDict_New

__pyx_t_1 = PyDict_New();  // create empty dict
if (unlikely(!__pyx_t_1)) __PYX_ERR(0, 10, __pyx_L1_error)
__Pyx_GOTREF(__pyx_t_1);


/* ==================== step 2: create methods and add to dict ==================== */
// from: CythonFunction.c:1335 - __Pyx_CyFunction_New
// from: ObjectHandling.c:1423 - __Pyx_SetNameInClass

// create the __init__ method
__pyx_t_2 = __Pyx_CyFunction_New(
    &__pyx_mdef_4cert_11Certificate_1__init__,  // PyMethodDef*
    0,                                           // flags
    __pyx_n_s_Certificate___init__,              // qualname
    NULL,                                        // closure
    __pyx_n_s_cert,                              // module name
    __pyx_d,                                     // globals
    NULL                                         // code
);
if (unlikely(!__pyx_t_2)) __PYX_ERR(0, 11, __pyx_L1_error)
__Pyx_GOTREF(__pyx_t_2);

// add __init__ to the class dict
if (__Pyx_SetNameInClass(__pyx_t_1, __pyx_n_s_init, __pyx_t_2) < 0)
    __PYX_ERR(0, 11, __pyx_L1_error)
__Pyx_DECREF(__pyx_t_2); __pyx_t_2 = 0;

// create the verify method
__pyx_t_2 = __Pyx_CyFunction_New(
    &__pyx_mdef_4cert_11Certificate_3verify,    // PyMethodDef*
    0,
    __pyx_n_s_Certificate_verify,
    NULL, __pyx_n_s_cert, __pyx_d, NULL
);
if (unlikely(!__pyx_t_2)) __PYX_ERR(0, 14, __pyx_L1_error)
__Pyx_GOTREF(__pyx_t_2);

// add verify to the class dict
if (__Pyx_SetNameInClass(__pyx_t_1, __pyx_n_s_verify, __pyx_t_2) < 0)
    __PYX_ERR(0, 14, __pyx_L1_error)
__Pyx_DECREF(__pyx_t_2); __pyx_t_2 = 0;


/* ==================== step 3: call type() to create the class ==================== */
// from: ExprNodes.py:10180 - __Pyx_Py3ClassCreate
// from: ObjectHandling.c:1257

__pyx_t_2 = __Pyx_Py3ClassCreate(
    (PyObject*)&PyType_Type,    // metaclass = type
    __pyx_n_s_Certificate,      // name = "Certificate"
    __pyx_empty_tuple,          // bases = ()
    __pyx_t_1,                  // dict = {__init__: func, verify: func}
    NULL,                       // mkw = NULL
    0,                          // calculate_metaclass
    0                           // allow_py2_metaclass
);
if (unlikely(!__pyx_t_2)) __PYX_ERR(0, 10, __pyx_L1_error)
__Pyx_GOTREF(__pyx_t_2);
__Pyx_DECREF(__pyx_t_1); __pyx_t_1 = 0;


/* ==================== step 4: register into the module dict ==================== */
// from: Nodes.py:5364 - self.target.generate_assignment_code

if (PyDict_SetItem(__pyx_d, __pyx_n_s_Certificate, __pyx_t_2) < 0)
    __PYX_ERR(0, 10, __pyx_L1_error)
__Pyx_DECREF(__pyx_t_2); __pyx_t_2 = 0;

How to find it? The key: inside the function, find the PyObject_Call(PyType_Type, ...) — the core is PyType_Type:

Finding PyType_Type in the exec function

So dynamically created classes (ordinary classes) have no static object-info struct, but we can still recover the function table from the code. The core is to find __Pyx_CyFunction_New:

Finding __Pyx_CyFunction_New (1) Finding __Pyx_CyFunction_New (2)

With a signature, you can reconstruct the entire class info without fixing up the environment, and print function addresses. These are all member methods of the module:

Recovered member methods

Argument 1 is the PyMethodDef.

Qc: how do the interpreter layer and native layer call each other?

The core question when reversing: how do these binary functions enter the interpreter layer, and how do they call external functions/methods? Cases:

1. Calls within the same .pyd. a.xx -> b.xx inside one .pyd uses the optimized __Pyx_PyObject call; since it’s optimized it doesn’t enter the interpreter layer, which speeds execution.

2. One .pyd calling another .pyd. a.xx -> pyd_b.c.xxx also goes through __Pyx_PyObject, but it must first obtain the external .pyd’s function. Flow:

1. PyImport_ImportModule("pyd_B")
   -> get pyd_B's module object

2. PyObject_GetAttr(pyd_B_module, "some_func")
   -> get the function object (a PyCFunction)

3. __Pyx_PyObject_FastCall(func_obj, args, nargs)
   -> the wrapper function inside pyd_A

4. check func_obj's type and flags
   +-- if METH_O: directly call func_obj->ml_meth(self, arg)
   |              this pointer points to pyd_B's C function
   |
   +-- else: PyVectorcall_Function() or PyObject_Call()

5. pyd_B's __pyx_pw_some_func() executes

An example:

Cross-.pyd call (1) Cross-.pyd call (2)

3. A .pyd calling interpreter functions. This directly calls exported functions in pythonxx.dll, mainly:

        "PyObject_Call",
        "PyObject_CallObject",
        "PyObject_CallNoArgs",
        "PyObject_CallOneArg",
        "_PyObject_Call"

The core is PyObject_Call:

PyObject_Call

Calling Python functions:

Cython function Source location Purpose
__Pyx_PyObject_Call ObjectHandling.c:2544 generic call (func, args, kwargs)
__Pyx_PyObject_FastCall ObjectHandling.c:2268 fast call (vectorcall)
__Pyx_PyObject_CallNoArg ObjectHandling.c:2626 no-argument call
__Pyx_PyObject_CallOneArg ObjectHandling.c:2613 one-argument call
__Pyx_PyObject_Call2Args ObjectHandling.c:2600 two-argument call
__Pyx_PyObject_FastCallMethod ObjectHandling.c:2321 method call

What these do, briefly:

  • __Pyx_PyObject_Call — generic call:
static CYTHON_INLINE PyObject* __Pyx_PyObject_Call(
    PyObject *func,   // callable object
    PyObject *arg,    // args tuple
    PyObject *kw      // kwargs dict (may be NULL)
) {
    ternaryfunc call = Py_TYPE(func)->tp_call;
    if (unlikely(!call))
        return PyObject_Call(func, arg, kw);

    Py_EnterRecursiveCall(" while calling a Python object");
    result = (*call)(func, arg, kw);  // call through the tp_call slot
    Py_LeaveRecursiveCall();
    return result;
}

Generally for variadic calls: func(*args, **kwargs).

  • __Pyx_PyObject_FastCall — fast call:
#define __Pyx_PyObject_FastCall(func, args, nargs) \
    __Pyx_PyObject_FastCallDict(func, args, (size_t)(nargs), NULL)

static CYTHON_INLINE PyObject* __Pyx_PyObject_FastCallDict(
    PyObject *func,
    PyObject *const *args,  // argument array (not a tuple!)
    size_t nargs,           // argument count
    PyObject *kwargs
) {
    // optimization: 0 args and a C function
    if (nargs == 0 && kwargs == NULL) {
        if (__Pyx_CyOrPyCFunction_Check(func) && (flags & METH_NOARGS))
            return __Pyx_PyObject_CallMethO(func, NULL);
    }
    // optimization: 1 arg and a C function
    if (nargs == 1 && kwargs == NULL) {
        if (__Pyx_CyOrPyCFunction_Check(func) && (flags & METH_O))
            return __Pyx_PyObject_CallMethO(func, args[0]);
    }

    // use the vectorcall protocol (Python 3.9+)
    vectorcallfunc f = __Pyx_PyVectorcall_Function(func);
    if (f) return f(func, args, nargs, NULL);

    // fall back to a normal call
    return __Pyx_PyObject_Call(func, args_tuple, kwargs);
}

Generally for fixed-argument calls: func(a, b, c). This is the most common one I’ve seen.

  • __Pyx_PyObject_CallNoArg — no-argument call:
static CYTHON_INLINE PyObject* __Pyx_PyObject_CallNoArg(PyObject *func) {
    PyObject *arg[2] = {NULL, NULL};
    return __Pyx_PyObject_FastCall(func, arg + 1, 0 | __Pyx_PY_VECTORCALL_ARGUMENTS_OFFSET);
}

For obj.method().

  • __Pyx_PyObject_CallOneArg — one-argument call (ObjectHandling.c:2540):
static CYTHON_INLINE PyObject* __Pyx_PyObject_CallOneArg(PyObject *func, PyObject *arg) {
    PyObject *args[2] = {NULL, arg};
    return __Pyx_PyObject_FastCall(func, args + 1, 1 | __Pyx_PY_VECTORCALL_ARGUMENTS_OFFSET);
}
  • __Pyx_PyObject_Call2Args — two-argument call:
static CYTHON_INLINE PyObject* __Pyx_PyObject_Call2Args(
    PyObject* function,
    PyObject* arg1,
    PyObject* arg2
) {
    PyObject *args[3] = {NULL, arg1, arg2};
    return __Pyx_PyObject_FastCall(function, args + 1, 2 | __Pyx_PY_VECTORCALL_ARGUMENTS_OFFSET);
}
  • __Pyx_PyObject_FastCallMethod — method call:
static PyObject *__Pyx_PyObject_FastCallMethod(
    PyObject *name,           // method name (string object)
    PyObject *const *args,    // args[0] = self object
    size_t nargsf             // argument count (including self)
) {
    PyObject *result;

    // 1. get the method from self
    PyObject *attr = PyObject_GetAttr(args[0], name);
    if (unlikely(!attr))
        return NULL;

    // 2. call the method (skip args[0]; the bound method already includes self)
    result = __Pyx_PyObject_FastCall(attr, args + 1, nargsf - 1);

    Py_DECREF(attr);
    return result;
}

For obj.fun() — also very common.

These functions have no symbol names; you either build a signature for IDA to recognize them, or recognize them by eye. (But this is a very exploitable point, more later.)

Qd: what does a Python function become?

This is clearest at the binary level. Python is dynamically typed, but it meets a strongly typed language (C), so a wrapper function must handle the argument conversion. Take a method from the sample’s driver code (F5 from IDA):

_QWORD *__fastcall sub_180005100(__int64 a1, __int64 *a2, __int64 a3, __int64 a4)
{
  __int64 *v5; // rbp
  int v7; // esi
  __int64 v8; // rax
  __int64 v9; // rbx
  __int64 v10; // rbx
  __int64 v11; // rdx
  _QWORD v13[5]; // [rsp+40h] [rbp-28h] BYREF
  __int64 v14; // [rsp+78h] [rbp+10h] BYREF

  v5 = &a2[a3];
  v14 = 0;
  v7 = a4;
  v13[0] = (char *)off_180020888 + 1152;
  v13[1] = 0;
  if ( !a4 )
  {
    if ( a3 == 1 )
    {
      v8 = *a2;
      return sub_180005270(a1, v8);
    }
    goto LABEL_14;
  }
  if ( a3 )
  {
    if ( a3 == 1 )
    {
      v8 = *a2;
      v9 = *(_QWORD *)(a4 + 16);
      v14 = *a2;
      goto LABEL_7;
    }
LABEL_14:
    PyErr_Format(
      PyExc_TypeError,
      "%.200s() takes %.8s %zd positional argument%.1s (%zd given)",
      "recv",
      "exactly",
      1u,
      byte_18001B650,
      a3);
    v11 = 5147;
    goto LABEL_15;
  }
  v10 = *(_QWORD *)(a4 + 16);
  v8 = sub_180014CE0(a4, v5, *((_QWORD *)off_180020888 + 144));
  v14 = v8;
  if ( !v8 )
  {
    if ( PyErr_Occurred() )
    {
      v11 = 5131;
      goto LABEL_15;
    }
    goto LABEL_14;
  }
  v9 = v10 - 1;
LABEL_7:
  if ( v9 > 0 )
  {
    if ( (int)sub_180014F00(v7, (_DWORD)v5, (unsigned int)v13, a4, (__int64)&v14, a3, (__int64)"recv") < 0 )
    {
      v11 = 5136;
LABEL_15:
      sub_180017F40("divertx.Divert.recv", v11, 112, "divertx.py");
      return 0;
    }
    v8 = v14;
  }
  return sub_180005270(a1, v8);
}

This is a standard wrapper function — the Python entry. Its key markers:

  1. It always has PyExc_TypeError exception handling
  2. It always does argument checking
  3. Its final ret always calls the real Python function

From these markers we can learn how many arguments the function takes — very useful. Here’s how to statically determine argument count, function name, and encoding info.

The Python traceback info tells us which member method the function corresponds to:

sub_180017F40("divertx.Divert.recv", v11, 112, "divertx.py");

From the source, the arguments are (Exceptions.c:840-841 and 972-1012):

// function signature
static void __Pyx_AddTraceback(
    const char *funcname,   // a1: fully qualified name, e.g. "divertx.Divert.recv"
    int c_line,             // a2: C source line (in the generated .c file)
    int py_line,            // a3: Python source line (in the original .py)
    const char *filename    // a4: Python source filename, e.g. "divertx.py"
);

Argument count is directly visible in the pseudocode where the check happens:

if ( a3 == 1 )

Finally, how the wrapper is generated in Cython. There are several variants, generated per argument shape to optimize the call:

Flag Value Python call C function signature
METH_NOARGS 0x0004 obj.method() PyObject* func(PyObject *self)
METH_O 0x0008 obj.method(x) PyObject* func(PyObject *self, PyObject *arg)
METH_VARARGS 0x0001 obj.method(*args) PyObject* func(PyObject *self, PyObject *args)
METH_VARARGS | METH_KEYWORDS 0x0003 obj.method(*args, **kw) PyObject* func(PyObject *self, PyObject *args, PyObject *kw)
METH_FASTCALL 0x0080 obj.method(a, b) PyObject* func(PyObject *self, PyObject *const *args, Py_ssize_t nargs)
METH_FASTCALL | METH_KEYWORDS 0x0083 obj.method(a, b, x=1) PyObject* func(PyObject *self, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames)

Analysis techniques and exploitable points

Having understood all this, one fatal weakness of .pyd emerges: every external call depends on the wrapper functions. Three questions:

  • How do you locate a .pyd program?
  • How do you handle name obfuscation?
  • How do you handle the huge number of compiled functions?

The right way to reverse and recover a .pyd’s functions

First you need an anchor point. Two main ways:

Approach 1: if you can run it, or can fix up the environment, process it at the interpreter level. Print the .pyd metadata and guess the functions you need to analyze.

Approach 2: static analysis. There are several techniques; mainly, write an IDA script to extract and identify all functions and methods. The wrapper functions’ __Pyx_AddTraceback calls give you every function.

Here’s a script:

import idaapi
import idautils
import idc

TRACEBACK_ADDR = 0x180017F40

SKIP_FUNCS = [
    "__security_check_cookie",
    "__GSHandlerCheck",
    "_guard_dispatch_icall",
]

def get_string_at(ea):
    s = idc.get_strlit_contents(ea)
    if s:
        try:
            return s.decode()
        except:
            pass
    ret = ""
    while True:
        c = idc.get_wide_byte(ea)
        if c == 0 or c is None:
            break
        if c < 0x20 or c > 0x7E:
            break
        ret += chr(c)
        ea += 1
        if len(ret) > 500:
            break
    return ret if ret else None

def make_valid_name(name):
    ret = ""
    for c in name:
        if c.isalnum() or c == "_":
            ret += c
        else:
            ret += "_"
    if ret and ret[0].isdigit():
        ret = "_" + ret
    return ret

def find_impl_func(wrapper_ea):
    func = idaapi.get_func(wrapper_ea)
    if not func:
        return None
    ea = func.start_ea
    end = func.end_ea
    candidates = []
    while ea < end:
        if idc.print_insn_mnem(ea) == "call":
            target = idc.get_operand_value(ea, 0)
            if target and target != TRACEBACK_ADDR:
                target_name = idc.get_func_name(target)
                skip = False
                if target_name:
                    for sf in SKIP_FUNCS:
                        if sf in target_name:
                            skip = True
                            break
                if not skip:
                    candidates.append(target)
        ea = idc.next_head(ea)
    if not candidates:
        return None
    for c in candidates:
        name = idc.get_func_name(c)
        if name and name.startswith("sub_"):
            return c
    return candidates[0] if candidates else None

def analyze_call(call_ea):
    ea = call_ea
    for _ in range(30):
        ea = idc.prev_head(ea)
        if ea == idc.BADADDR:
            break
        mnem = idc.print_insn_mnem(ea)
        if mnem in ["call", "jmp", "ret", "retn"]:
            break
        if mnem in ["lea", "mov"]:
            op0 = idc.print_operand(ea, 0).lower()
            if op0 in ["rcx", "ecx"]:
                val = idc.get_operand_value(ea, 1)
                if val:
                    s = get_string_at(val)
                    if s:
                        return s
    return None

def main():
    print("=" * 60)
    print("Cython Function Extractor & Renamer")
    print("=" * 60)

    xrefs = list(idautils.XrefsTo(TRACEBACK_ADDR))
    print("[+] Found " + str(len(xrefs)) + " xrefs to __Pyx_AddTraceback")
    print("")

    functions = []
    seen_wrappers = set()

    for xref in xrefs:
        if xref.type not in [idaapi.fl_CN, idaapi.fl_CF]:
            continue

        funcname = analyze_call(xref.frm)
        if not funcname:
            continue

        func = idaapi.get_func(xref.frm)
        if not func:
            continue

        if func.start_ea in seen_wrappers:
            continue
        seen_wrappers.add(func.start_ea)

        impl = find_impl_func(func.start_ea)
        functions.append((funcname, func.start_ea, impl))

    print("-" * 60)
    print("%-50s %s" % ("Function", "Address"))
    print("-" * 60)

    renamed_count = 0

    for funcname, wrapper, impl in functions:
        short_name = funcname
        if len(short_name) > 48:
            short_name = short_name[:45] + "..."

        print("%-50s %s" % (short_name, hex(wrapper)))

        wrapper_name = "__pyx_pw_" + make_valid_name(funcname)
        old_name = idc.get_func_name(wrapper)
        if old_name and old_name.startswith("sub_"):
            idc.set_name(wrapper, wrapper_name, idc.SN_NOWARN)
            print("  -> " + wrapper_name)
            renamed_count += 1

        if impl:
            impl_name = "__pyx_pf_" + make_valid_name(funcname)
            old_name = idc.get_func_name(impl)
            if old_name and old_name.startswith("sub_"):
                idc.set_name(impl, impl_name, idc.SN_NOWARN)
                print("  -> " + impl_name + " (impl)")
                renamed_count += 1

    print("-" * 60)
    print("[+] Total: " + str(len(functions)) + " functions")
    print("[+] Renamed: " + str(renamed_count) + " functions")

main()

Result — the function names are recovered:

Function names recovered

Handling name obfuscation

One challenge I ran into: authors sometimes obfuscate names at compile time to slow analysis down:

Name obfuscation

Brute-forcing this isn’t pleasant. Instead, hook the key functions with frida-python-bridge. The hooks target:

        "PyObject_Call",
        "PyObject_CallObject",
        "PyObject_CallNoArgs",
        "PyObject_CallOneArg",
        "_PyObject_Call"

The script:

var PYD_NAME = "your_module.cp311-win_amd64.pyd";  // change to your pyd name
var PYTHON_DLL = "python311.dll";  // change to your python version

var pyd = null;
var python = null;

function waitForModules() {
    return new Promise(function(resolve) {
        var check = setInterval(function() {
            pyd = Process.findModuleByName(PYD_NAME);
            python = Process.findModuleByName(PYTHON_DLL);
            if (pyd && python) {
                clearInterval(check);
                resolve();
            }
        }, 100);
    });
}

function setupHooks() {
    console.log("[+] pyd base: " + pyd.base);
    console.log("[+] python base: " + python.base);

    var PyObject_Repr = new NativeFunction(
        python.getExportByName("PyObject_Repr"),
        'pointer', ['pointer']
    );
    var PyUnicode_AsUTF8 = new NativeFunction(
        python.getExportByName("PyUnicode_AsUTF8"),
        'pointer', ['pointer']
    );
    var PyObject_GetAttrString = new NativeFunction(
        python.getExportByName("PyObject_GetAttrString"),
        'pointer', ['pointer', 'pointer']
    );
    var PyErr_Clear = new NativeFunction(
        python.getExportByName("PyErr_Clear"),
        'void', []
    );

    var Py_True = python.getExportByName("_Py_TrueStruct");
    var Py_False = python.getExportByName("_Py_FalseStruct");
    var Py_None = python.getExportByName("_Py_NoneStruct");

    function reprObj(obj) {
        if (obj.isNull()) return "NULL";
        if (obj.equals(Py_True)) return "True";
        if (obj.equals(Py_False)) return "False";
        if (obj.equals(Py_None)) return "None";
        try {
            var repr = PyObject_Repr(obj);
            if (!repr.isNull()) {
                var str = PyUnicode_AsUTF8(repr);
                if (!str.isNull()) {
                    var s = str.readUtf8String();
                    if (s.length > 200) s = s.substring(0, 200) + "...";
                    return s;
                }
            }
        } catch(e) {}
        return "<?>";
    }

    function getAttr(obj, name) {
        if (obj.isNull()) return null;
        try {
            var namePtr = Memory.allocUtf8String(name);
            var attr = PyObject_GetAttrString(obj, namePtr);
            if (!attr.isNull()) {
                var str = PyUnicode_AsUTF8(attr);
                if (!str.isNull()) {
                    return str.readUtf8String();
                }
            }
            PyErr_Clear();
        } catch(e) {}
        return null;
    }

    function getCallableName(callable) {
        if (callable.isNull()) return "?";
        var module = getAttr(callable, "__module__");
        var qualname = getAttr(callable, "__qualname__");
        var name = getAttr(callable, "__name__");

        var result = "";
        if (module) result = module + ".";
        if (qualname) {
            result += qualname;
        } else if (name) {
            result += name;
        } else {
            result += "?";
        }
        return result;
    }

    function isFromPyd(addr) {
        return addr.compare(pyd.base) >= 0 && addr.compare(pyd.base.add(pyd.size)) < 0;
    }

    var seq = 0;

    var callFuncs = [
        "PyObject_Call",
        "PyObject_CallObject",
        "PyObject_CallNoArgs",
        "PyObject_CallOneArg",
        "_PyObject_Call"
    ];

    callFuncs.forEach(function(funcName) {
        try {
            var addr = python.getExportByName(funcName);
            if (addr) {
                Interceptor.attach(addr, {
                    onEnter: function(args) {
                        if (!isFromPyd(this.returnAddress)) return;

                        this.active = true;
                        this.callable = args[0];
                        this.funcName = getCallableName(args[0]);
                        this.offset = this.returnAddress.sub(pyd.base).toInt32();

                        if (funcName === "PyObject_CallOneArg") {
                            this.arg1 = reprObj(args[1]);
                        }
                    },
                    onLeave: function(retval) {
                        if (!this.active) return;
                        seq++;
                        var result = reprObj(retval);
                        var line = "[" + seq + "] " + this.funcName;
                        if (this.arg1) {
                            line += "(" + this.arg1 + ")";
                        } else {
                            line += "()";
                        }
                        line += " -> " + result;
                        line += "  [+" + this.offset.toString(16) + "]";
                        console.log(line);
                    }
                });
                console.log("[+] Hooked " + funcName);
            }
        } catch(e) {
            console.log("[-] Failed to hook " + funcName + ": " + e);
        }
    });

    // Hook PyObject_Vectorcall (Python 3.9+)
    try {
        var vectorcall = python.getExportByName("PyObject_Vectorcall");
        if (vectorcall) {
            Interceptor.attach(vectorcall, {
                onEnter: function(args) {
                    if (!isFromPyd(this.returnAddress)) return;
                    this.active = true;
                    this.funcName = getCallableName(args[0]);
                    this.offset = this.returnAddress.sub(pyd.base).toInt32();
                },
                onLeave: function(retval) {
                    if (!this.active) return;
                    seq++;
                    var result = reprObj(retval);
                    console.log("[" + seq + "] " + this.funcName + "() -> " + result + "  [+" + this.offset.toString(16) + "]");
                }
            });
            console.log("[+] Hooked PyObject_Vectorcall");
        }
    } catch(e) {}

    // Hook _PyObject_FastCall
    try {
        var fastcall = python.getExportByName("_PyObject_FastCall");
        if (fastcall) {
            Interceptor.attach(fastcall, {
                onEnter: function(args) {
                    if (!isFromPyd(this.returnAddress)) return;
                    this.active = true;
                    this.funcName = getCallableName(args[0]);
                    this.offset = this.returnAddress.sub(pyd.base).toInt32();
                },
                onLeave: function(retval) {
                    if (!this.active) return;
                    seq++;
                    var result = reprObj(retval);
                    console.log("[" + seq + "] " + this.funcName + "() -> " + result + "  [+" + this.offset.toString(16) + "]");
                }
            });
            console.log("[+] Hooked _PyObject_FastCall");
        }
    } catch(e) {}

    console.log("\n[+] Trace started. Waiting for calls from " + PYD_NAME + "...\n");
}

waitForModules().then(setupHooks);

Once hooked, printing arguments and return values gives you a rough idea of what each function does — nothing stays hidden. You can also do small-range tracing on the few functions you highly suspect (a large range tends to crash). If you need to handle intra-function calls, you’ll have to build a signature for these internal fast calls (see section 2 for details).

Handling the huge number of compiled functions

Treat this as a special kind of obfuscation. The functions are huge because — to be C-like-fast while remaining interpreter-compatible — there’s a ton of exception handling, memory management, GIL management, etc. The actually meaningful code is tiny.

Analyzing them is just removing the redundant junk and keeping only the real Python function calls. Two approaches:

  • Traditional. Since Python → C follows a fixed set of rules with corresponding match modules in Cython, you can pattern-match against them. (I won’t show this here — leaving something in reserve.) Source locations for the template conversions:

    Template file Size Purpose
    ObjectHandling.c 124KB object calls, attribute access (__Pyx_PyObject_Call, etc.)
    ModuleSetupCode.c 123KB module init code
    Exceptions.c 36KB exception handling (__Pyx_AddTraceback)
    FunctionArguments.c 33KB argument parsing (__Pyx_ArgTypeTest, etc.)
    CythonFunction.c 67KB Cython function object
    TypeConversion.c 58KB type conversion
    Coroutine.c 87KB coroutine/async support
    ExtensionTypes.c 35KB extension types (cdef class)
  • The shortcut. It’s really about identifying and deleting junk code — and unlike traditional obfuscation there are no fake control-flow tricks here, just “junk code”. You can write a script that calls an AI interface to chunk-identify junk functions and delete/optimize them (I’ve already reduced some huge functions this way).

    The flow: copy the function out (via an MCP tool) → have the AI slice-and-identify the redundant code and delete it → write the result to funxxx.c.

    A sample prompt (this prompt matters — plain chat gets you nowhere; drive it through MCP or code):

### Task
You are a Cython junk-code remover. Identify Cython-specific functions and delete them, keeping the native Python call functions.
### Workflow
Read the functions in the xxx file, create a working directory, and launch an optimizer when a branch block is identified. Write the optimized result into the xxx.c file, and record the current project path and summary to avoid losing context.
### Note
Do not modify the original branches, only delete redundant code.
### Current task
Based on my task report, split the task, create a task flow, and record it. Each time you finish one, write it into the log document.

Summary

Overall, .pyd reversing is mostly about volume — the traditional approach is too slow, so it’s best to exploit its own machinery to locate things. Next time I’ll fill in the variables/members part.

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch