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:

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:
- Collect function info
- Generate the method table
- Generate single entries
- Generate the module definition
- 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:

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:

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:

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:

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;
};

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:

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:

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:

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:

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:

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:
- It always has
PyExc_TypeErrorexception handling - It always does argument checking
- Its final
retalways 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
.pydprogram? - 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:

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

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