Reverse Engineering Cython

Setelah berbagai obfuscator Python berhasil dibongkar, banyak orang sekarang mulai menggunakan Cython agar modulnya sulit dibongkar. Menurut saya ini cara yang sangat baik, karena selain lebih sulit dibongkar, kode yang dihasilkan juga sering kali lebih cepat dari modul Python yang ditulis langsung dalam Python.

Karena kebanyakan yang mencari tahu soal RE Cython ini adalah newbie yang berusaha mencuri kode Python orang lain, maka ini saya tuliskan di awal. Karena biasanya orang-orang ini tidak sabar membaca sampai akhir:

  • Jika punya file.so hasil kompilasi Cython, maka tidak bisa dikembalikan lagi menjadi .py secara otomatis.
  • Proses reversing bisa dilakukan manual, tapi butuh waktu lama dan butuh kesabaran tinggi

Mengenal Modul Python dalam C/C++

Kita bisa menulis modul Python dalam C/C++ dan juga bahasa lain. Bahasa yang resmi didukung hanya C/C++, tapi bahasa lain seperti Rust juga bisa dipakai. Bahkan sebenarnya banyak modul Python yang dipakai sehari-hari yang ditulis dalam C/C++, misalnya numpy, tensorflow, dan bebagai modul AI lain.

Ada berbagai cara untuk menulis modul Python, pertama adalah cara 100% manual. Manual artinya kita menulis sendiri kodenya dalam C/C++ tanpa library tambahan atau tool tambahan. Dokumentasi lengkap cara manual ini bisa dilihat situs resmi Python.

Membuat modul secara manual ini butuh waktu lama, maka ada berbagai cara lain untuk memudahkan pembuatan modul ptyhon. Tapi perlu dicatat bahwa semua cara lain ini pada akhirnya akan memanggil API Python yang sama seperti jika kita menggunakan cara manual. Jadi pertama kita perlu memahami dulu modul yang dibuat secara manual.

Untuk membuat modul yang berisi fungsi untuk menjumlahkan dengan cara manual dibutuhkan kode yang cukup panjang, seperti listing di bawah ini. Kerumitan ini perlu dipelajari untuk bisa melakukan reverse engineering sebuah modul Python.

#define PY_SSIZE_T_CLEAN
#include <Python.h>

static PyObject *
calc_plus(PyObject *self, PyObject *args)
{
    long n1;
    long n2;

    if (!PyArg_ParseTuple(args, "ll", &n1, &n2))
        return NULL;
    long res = n1 + n2;
    return PyLong_FromLong(res);
}

static PyMethodDef CalcMethods[] = {
    {"plus",  calc_plus, METH_VARARGS,
     "Adds two numbers"},
    {NULL, NULL, 0, NULL}        /* Sentinel */
};

static struct PyModuleDef plusmodule = {
    PyModuleDef_HEAD_INIT,
    "calc",   /* name of module */
    NULL, /* module documentation, may be NULL */
    -1,       /* size of per-interpreter state of the module,
                 or -1 if the module keeps state in global variables. */
   CalcMethods 
};

PyMODINIT_FUNC
PyInit_calc(void)
{
    return PyModule_Create(&plusmodule);
}

Seperti bisa dilihat:

  • Ada struktur yang berisi daftar fungsi dalam sebuah modul
  • Ada struktur yang berisi informasi mengenai modul itu sendiri
  • Untuk mengakses parameter, digunakan PyArg_ParseTuple
  • Bagian utama memakai syntax C biasa (res = n1 + n2)

Untuk mengcompile file di atas, kita bisa menggunakan cara manual yang spesifik untuk tiap sistem operasi. Tapi ada cara yang lebih mudah dengan membuat file setup.py

from distutils.core import setup, Extension

def main():
    setup(name="calc",
            version="1.0.0",
            description="desc",
            author="Yohanes Nugoho",
            author_email="[email protected]",
            ext_modules=[Extension("calc",["calc.c"])])

if __name__ == "__main__":
    main()

Dengan ini kita bisa melakukan python setup.py build atau python setup.py install.

Setelah itu modul bisa dipakai seperti biasa:

>> import calc
>> calc.plus(9, 7)
16

Seperti apa hasilnya jika kita melakukan reverse engineering terhadap modul yang sudah dicompile ini? Modul ini akan berekstensi .so di Linux/macOS atau .DLL (Windows). Modul ini berisi kode biner, sama seperti kalau kita mengkompilasi program C biasa dengan compiler C. Jadi untuk melakukan reverse engineering kita butuh decompiler C, misalnya Ghidra, IDA, atau Binary Ninja.

Defaultnya hasil dekompilasinya kurang benar karena IDA tidak tahu bahwa fungsi _PyArg_ParseTuple_SizeT memiliki variable arguments.

Hasil dekompilasi default: v3 dan v4 tidak jelas dari mana nilainya

Ini bisa diperbaiki dengan mengubah deklarasinya:

Tekan y pada deklarasi fungsi untuk mengubah signaturenya

Sekarang hasilnya sudah jelas bahwa v4 dan v3 disi oleh _PyArg_ParseTuple_SizeT

Untuk fungsi sederhana, hasil dekompilasi cukup mudah dipahami

Di dalam kode C kita bisa memanggil apa saja, dan bisa melakukan hal-hal yang tidak bisa dilakukan oleh Python, misalnya:

  • Memakai inline assembly untuk memakai instruksi spesific prosessor (misalnya instruksi AVX)
  • Mengakses memori secara langsung
  • Memanggil kode di library C yang lain

Cara lain membuat modul Python

Jadi setelah tahu bahwa untuk membuat fungsi sederhana diperlukan puluhan baris kode, banyak orang membuat teknologi untuk memudahkan pembuatan modul. Berbagai cara lain untuk membuat modul Python misalnya:

  • Boost Python: ini memudahkan kita membuat kode C++ yang dipanggil atau memanggil Python
  • SWIG (Simplified Wrapper and Interface Generator), ini bisa dipakai untuk menjembatani kode C/C++ dengan berbagai bahasa (Python, Lua, dsb)
  • Cython (C-Extensions for Python), kita menulis modul dalam bahasa Python, tapi akan diterjemahkan menjadi C, dan dikompilasi seperti modul yang ditulis manual dalam C.

Di tulisan ini saya tidak akan membahas cara lain, hanya Cython saja.

Mengenal Cython

Untuk membuat modul dengan Cython, kita bisa menulis modul seperti menulis kode python biasa dengan ekstensi .pyx. Mari kita lihat contoh untuk membuat modul yang berisi fungsi untuk menjumlahkan dua bilangan:

def plus(a, b):
    return a+b

Kita juga perlu membuat setup.py untuk mengkompilasi modulnya:

from setuptools import setup
from Cython.Build import cythonize

setup(
    name='Calculator',
    ext_modules=cythonize("calc.pyx"),
    zip_safe=False,
)

Untuk mengcompilenya: python3 setup.py build_ext --inplace. Hasilnya adalah file calc.c hasil terjemahan dari calc.pyx, dan juga modul yang sudah dikompilasi (.so atau .dll). Berbeda dengan kode C manual yang hanya puluhan baris, hasil translasi dari 2 baris Python calc.pyx menjadi 2826 baris.

Kenapa sangat panjang: karena kode manual dalam C yang kita buat tidak menangani banyak hal, hanya menangani kasus ini: jika kedua input bukan integer, maka kembalikan nol. Tapi versi yang dihasilkan oleh Cython akan melemparkan exception jika ada masalah, termasuk juga memberi tahu di baris mana lokasi masalahnya dalam versi pyx. Versi Cython juga mendukung floating point, dan juga mendukung kelas yang memiliki method __add__.

Ini hanya fungsi plusnya saja yang dihasilkan:

Kode C yang dihasilkan Cython untuk fungsi plus

Fungsi yang hanya beberapa puluh baris ini memakai banyak fitur macro di C, dan sebenarnya jumlah barisnya lebih banyak lagi. Ketika dikompilasi dan kemudian dibuka dengan IDA hasilnya menjadi lebih dari 200 baris, dengan intinya ada di beberapa baris terakhir yang memanggi PyNumber_Add.

Hasil dekompilasi file calc.so

Jika memang niat kita hanya menjumlahkan integer, kode Python yang saya berikan sebelumnya bisa dioptimasi agar Cython hanya menangani integer saja. Caranya adalah dengan menambahkan Type Annotation. Dalam kasus ini kita ingin menyatakan bahwa a dan b adalah int.

import cython
def plus_int(a: cython.int, b: cython.int):
   return a+b

Hasilnya: Cython bisa menggunakan operator + di C dan tidak memanggil PyNumber_Add

Hasil dekompilasi plus_int

Perlu dicatat bahwa untuk fungsi yang lebih rumit, translasinya bisa menjadi ribuan baris. Contohnya untuk fungsi sederhana ini (mengenkrip sebuah string dengan Xor):

def enc(st, key):
    return "".join([chr(ord(x)^key) for x in st])

Jika dikompilasi akan menghasilkan kode C dalam bentuk loop. Ini hanya bagian loopnya saja, masih ada bagian lain yang tidak saya tunjukkan. Versi hasil dekompilasi menjadi lebih dari 600 baris untuk fungsi yang isinya hanya satu baris kode.

Kode C ini masih memiliki komentar di dalamnya, sehingga masih bisa dipahami, tapi kode biner yang dihasilkan tidak lagi mengandung komentar. Hasil dekompilasi kode ini sudah sangat sulit dibaca.

Reverse Engineering Cython

Saat ini setahu saya tidak ada cara otomatis untuk mengubah kode C yang dihasilkan oleh Cython kembali menjadi Python. Demikian juga tidak ada decompiler yang langsung mengembalikan .so kembali ke Python. Jadi cara reverse engineering secara umum adalah: dengan decompiler (menjadi bahasa C), lalu dipahami secara manual.

Saya ulangi lagi yang sudah ditulis di awal artikel ini:

  • jika punya kode Python yang dicompile menjadi .so dengan Cython, maka tidak bisa dikembalikan lagi menjadi .py secara otomatis.
  • Proses reversing bisa dilakukan manual, tapi butuh waktu lama dan butuh kesabaran

Andaikan ada yang membuat decompiler otomatis sekalipun, akan mudah sekali dibuat menjadi bingung/error. Kode dalam C yang dihasilkan oleh Cython bisa diedit manual. Atau kita bisa menuliskan langsung kode C di dalam kode Cython-nya. Misalnya: kita bisa menambahkan assembly ke dalam kode C, atau membuat fungsi-fungsi yang tidak mungkin bisa diterjemahkan balik ke Python. Selain itu compiler C yang digunakan bisa juga yang memakai obfuscator, sehingga hasil dekompilasinya akan terlihat aneh.

Langkah berikutnya tergantung dari tujuan reverse engineering. Kita bisa menggunakan interpreter python dan secara interaktif bisa melihat daftar fungsi yang ada dan memanggil fungsi-fungsi yang ada.

>>> import calc
>>> dir(calc)
['__builtins__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', '__test__', 'plus']

Jarang sekali ketika melakukan reverse engineering kita bisa mendapatkan kode semula 100%, dan jarang sekali ini dibutuhkan. Biasanya hanya orang yang kehilangan source code (dan ingin dikembalikan), atau orang yang ingin mencuri source code yang ingin mendapatkan kembali kode Python 100%.

Biasanya ketika reverse engineering kita hanya ingin tahu:

  • Nilai tertentu (misalnya secret value)
  • Algoritma tertentu (misalnya hashing)

Contoh Jika kita sekedar ingin mengetahui kode Python memanggil URL apa saja dan apa parameternya, ada banyak cara untuk melakukan ini tanpa harus mendapatkan keseluruhan source codenya:

  • kita bisa menggunakan Python debugger (baik command line, atau dari IDE) dan mengeset breakpoint di fungsi yang dipakai mengakses URL (misalnya requests.get)
  • kita bisa mengeset intercepting proxy dan melihat traffic via Proxy
  • kita bisa membuat modul sendiri menggantikan modul yang ada. Misalnya modul requests, agar modul kita yang dipanggil jika script berusaha mengakses web

Dalam analisis dinamik, kita bisa menggunakan debugger untuk menginspeksi nilai tertentu. Tapi perlu diperhatikan bahwa apa tipe parameter untuk sebuah fungsi. Tidak seperti mendebug kode C/C++ di mana nilainya adalah primitif (angka, atau pointer ke string), nilai parameter adalah PyObject.

Untuk bisa memahami reverse engineering berbagai modul Cython, pertama: cobalah membuat modul sendiri dan cobalah melakukan reverse engineering modul Anda sendiri. Kalau Anda belum bisa memahami reversing kode sendiri, jangan harap bisa memahami kode orang lain.

Penutup

Untuk melakukan reverse engineering sebuah teknologi, perlu dipahami bagaimana teknologi itu bekerja. Tidak semua yang bisa dikompilasi bisa kembali 100% menjadi kode semula. Saya sering kali menjelaskan: pahami dulu cara kompilasinya, supaya paham reverse engineeringnya, tapi kebanyakan malas belajar.

Sebagai catatan: contoh lain teknologi yang perlu dipahami untuk bisa melakukan RE adalah Flutter, yang pernah saya tulis di blog saya yang berbahasa Inggris.

Tepatnya saya menulis artikel ini setelah gemes melihat teman yang menjelaskan bahwa: pahami dulu kompilasinya, malah dibalas “I want to try reversing not compiling”

Saya tidak sensor namanya karena itu bukan nama asli orangnya. Ini yang bikin kadang saya malas menjawab sebagian orang, karena mereka maunya instan saja.

Tinggalkan Balasan

Situs ini menggunakan Akismet untuk mengurangi spam. Pelajari bagaimana data komentar Anda diproses.