Bermain dengan Python Bytecode

Let’s Play ..

Beberapa hari terakhir saya banyak mempelajari lebih dalam tentang python. Lumayan banyak percobaan dan eksperimen yang saya lakukan, salah satu hasilnya adalah string obfuscator untuk python yang saya buat, sederhananya program tersebut memanfaatkan operasi artimatika pada nilai boolean yang nantinya akan menghasilkan nilai integer, selanjutnya bisa dimanfaatkan untuk menghasilkan karakter ascii dan hasilnya digabungkan menjadi string. Oke, kita tidak akan membahas itu, tapi sekarang kita akan bermain dengan python bytecode 😀

Bahasa python termasuk Interpreted Language, artinya kita tidak perlu mengcompile script python untuk bisa menjalankannya. Tapi, agar bisa dieksekusi, script python membutuhkan program lain yang berjalan dibawahnya, program ini bertugas menerjemahkan script python kedalam fungsi atau tindakan yang nantinya dapat dieksekusi oleh komputer.

Sebenarnya sebelum dieksekusi script python akan dicompile terlebih dahulu. lah kok dicompile dulu? tadi katanya nggak?, Hehe, jadi gini. Tidak seperti compiler pada umumnya yang mengubah code menjadi bahasa mesin atau binary. Sebelum dieksekusi, compiler python akan mengubah script python kedalam bentuk bytecode.

Bytecode adalah instruksi set yang sudah dirancang agar dapat dimengerti oleh interpreter. Dinamakan bytecode karena bytecode sebenarnya adalah array of byte, dan biasanya 1 byte mewakili 1 intruksi tertentu dan diikuti parameter untuk intruksi tersebut di byte berikutnya. Jadi script python akan diterjemahkan kedalam bentuk bytecode, setelah itu bytecode akan dieksekusi oleh interpreter. Dan begitulah ceritanya..

Function Code Object

Di python, fungsi itu seperti objek, dia memiliki atribut dan objek-objek lain didalamnya. Salah satunya adalah code object yang dimiliki oleh suatu fungsi

Python 2.7.13 (default, Jan 19 2017, 14:48:08)
[GCC 6.3.0 20170118] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> def func(x, y):
...     z = 2
...     return x + y - z
...
>>> func
<function func at 0x7f962ac522a8>
>>> dir(func)
['__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__doc__', '__format__', '__get__', '__getattribute__', '__globals__', '__hash__', '__init__', '__module__', '__name__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'func_closure', 'func_code', 'func_defaults', 'func_dict', 'func_doc', 'func_globals', 'func_name']
>>> func.__code__
<code object func at 0x7f962ac37130, file "<stdin>", line 1>
>>> func.func_code
<code object func at 0x7f962ac37130, file "<stdin>", line 1>

Code object bisa diakses menggunakan __code__ atau func_code. Dengan code object kita bisa mendapatkan informasi dari fungi tersebut, seperti berapa banyak argumen yang dibutuhkan, variable apa saja yang ada didalam fungsi tersebut, bahkan kode yang ada didalamnya, dlsb. tentu ini akan semakin menarik 🙂

>>> fco = func.__code__
>>> dir(fco)
['__class__', '__cmp__', '__delattr__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_stacksize', 'co_varnames']
>>> fco = func.__code__
>>> fco
<code object func at 0x7f962ac37130, file "<stdin>", line 1>
>>> type(fco)
<type 'code'>
>>> fco.co_varnames      # Variable ?
('x', 'y', 'z')
>>> fco.co_consts        # Konstanta ?
(None, 2)
>>> fco.co_argcount      # Jumlah argumen ?
2
>>> fco.co_code          # Kode yang ada didalamnya ?
'd\x01\x00}\x02\x00|\x00\x00|\x01\x00\x17|\x02\x00\x18S'
>>> # Wait, WTF is this ?

Didalam code object ada attribute bernama co_code, co_code berisi code yang ada didalam fungi tersebut tapi dalam bentuk bytecode. Jadi apa yang kalian lihat diatas itu adalah bytecode dari fungsi “func”. Interpreter python lah yang mengerti bytecode tersebut. Tapi, Kita bisa Mendisassemblynya untuk menghasilkan intruksi-intruksi bytecode dengan menggunakan module dis.

>>> fco.co_code
'd\x01\x00}\x02\x00|\x00\x00|\x01\x00\x17|\x02\x00\x18S'
>>> import dis
>>> dis.dis(fco.co_code)
          0 LOAD_CONST          1 (1)
          3 STORE_FAST          2 (2)
          6 LOAD_FAST           0 (0)
          9 LOAD_FAST           1 (1)
         12 BINARY_ADD
         13 LOAD_FAST           2 (2)
         16 BINARY_SUBTRACT
         17 RETURN_VALUE

Python interpreter adalah virtual machine yang berbasis stack machine, artinya dia menggunakan stack untuk menyelesaikan operasi-operasinya.
Di kolom pertama, itu adalah offset pada bytecode dimana intruksi dimulai. Di kolom selanjutnya, ada intruksi dari bytecode tersebut, dan diikuti parameter untuk intruksi tersebut. Contohnya adalah LOAD_CONST membutuhkan parameter berupa index yang digunakan untuk mengambil nilai di co_const dan akan mempush ke stack (menaruhnya di stack paling atas).

>>> STACK = []
>>> # intruksi "LOAD_CONST 1" akan menaruh nilai yang berada di co_consts[1] dan menaruhnya di top of stack
... fco.co_consts[1]
2
>>> # Jadi, "LOAD_CONST 1" akan menaruh nilai 2 ke top of stack
>>> STACK.append(fco.co_consts[1])

Intruksi STORE_FAST digunakan untuk mengambil nilai yang ada distack paling atas (pop) dan menaruhnya di lokal variable. Tapi, intruksi ini tidak menyebutkan variable mana yang akan menjadi tempat nilai tersebut disimpan. Karena itu, intruksi ini membutuhkan satu parameter sebagai index dari lokal variable yang diambil lewat co_varnames didalam code object (co_varnames adalah tuple yang berisi variable-variable lokal)

>>> # Intruksi "STORE_FAST 2" akan beroperasi seperti ini
>>> fco.co_varnames[2]
'z'
>>> z = STACK.pop()
>>> z
2

Jadi, LOAD_CONST 1; STORE_FAST 2 artinya mengisi variable z dengan 2. Untuk penjelasan tentang intruksi-intruksi yang lain, kalian bisa membacanya di #1.

Selanjutnya ada dua intruksi LOAD_FAST dengan 2 argumen yang berbeda. intruksi LOAD_FAST digunakan untuk menaruh (push) nilai yang ada di local variable ke stack. Seperti STORE_FAST, parameter pada intruksi LOAD_FAST digunakan untuk indexing ke local variable sebagai penyebut bahwa nilai yang ada divariable itulah yang nilainya akan dipush ke stack. Dalam hal ini adalah variable yang berada di index 0 dan 1 akan dipush nilainya

>>> fco.co_varnames[0]   # index 0
'x'
>>> fco.co_varnames[1]   # index 1
'y'

Jadi, nilai yang ada berada di variable x dan y, akan dipush ke stack.
Selanjutnya ada intruksi BINARY_ADD. Dibaca dari namanya, tentu ini adalah intruksi untuk operasi pertambahan, dan dilihat dari hasil disassemblynya juga sepertinya intruksi ini tidak membutuhkan argumen. Intruksi ini beroperasi dengan cara menambahkan kedua bilangan yang ada di stack dan hasilnya akan disimpan distack.
Karena nilai yang ada di variable x dan y sudah berada di stack, Maka, 2 nilai itulah yang akan ditambahkan, Lalu hasil pertambahannya akan disimpan (push) di stack.

Dan sisa intruksinya coba kalian pahami sendiri yah, ehehehe

OPCODE

Kita tahu bytecode adalah array of byte (bisa juga disebut string) yang merepresentasikan intruksi-intruksi yang dimengerti oleh interpreter. Setiap intruksi akan mewakili suatu nilai (begitu pun sebaliknya) itu yang dinamakan opcode. setiap intruksi akan mempunyai opcode yang berbeda-beda, karna kalo sama akan ngebingungin interpreter wkwkwk

Kita lihat contoh sebelumnya.

>>> dis.dis(fco.co_code)
          0 LOAD_CONST          1 (1)
          3 STORE_FAST          2 (2)
          6 LOAD_FAST           0 (0)
          9 LOAD_FAST           1 (1)
         12 BINARY_ADD
         13 LOAD_FAST           2 (2)
         16 BINARY_SUBTRACT
         17 RETURN_VALUE
>>> fco.co_code
'd\x01\x00}\x02\x00|\x00\x00|\x01\x00\x17|\x02\x00\x18S'
>>> opcods = map(ord, fco.co_code)  # Ubah semua byte di fco.co_code ke desimal
>>> opcods
[100, 1, 0, 125, 2, 0, 124, 0, 0, 124, 1, 0, 23, 124, 2, 0, 24, 83]

fco.co_code bertype string, agar mudah melihatnya kita ubah menjadi desimal. 3 byte pertama [100, 1, 0] Sebenarnya itu adalah opcode dari intruksi pertama kita yaitu intruksi LOAD_CONST 1.

>>> import opcode
>>> opcode.opmap['LOAD_CONST']
100

sedikit mengintip lewat module opcode di python, dan memang benar intruksi LOAD_CONST itu mempunyai nilai 100. 2 byte selanjutnya [1, 0] merupakan argumen yang dibutuhkan untuk intruksi LOAD_CONST tersebut. Jika dilihat pada hasil disassembly, LOAD_CONST dipanggil dengan argumen bernilai 1, sedangkan di bytecode tertulis [1, 0]. Bisa kita pastikan [1, 0] merupakan bilangan short (unsigned short lebih tepatnya dan karena panjang short adalah 2 byte), dan kita bisa hitung dengan perhitungan byte\_pertama + byte\_kedua * 256 samadengan 1 + 0 * 256. atau bisa dengan cara unpack seperti ini

>>> import struct
>>> def array_ushort(arr):
...     assert len(arr) == 2
...     return struct.unpack("<H", bytearray(arr))[0]
...
>>> array_ushort([1, 0])
1

Oke, sekarang kita sudah tau arti dari [100, 1, 0] merupakan intruksi dari “LOAD_CONST 1” dan kita juga bisa menyimpulkan bahwa intruksi LOAD_CONST itu sebesar 3 byte (1 byte opcode + 2 byte argumen). Begitu juga dengan intruksi STORE_FAST dan LOAD_FAST yang mempunyai opcode tersendiri dan memiliki argumen sebesar 2 byte (3 byte total). Berbeda dengan intruksi BINARY_ADD yang hanya mempunyai panjang 1 byte, karena intruksi tersebut tidak membutuhkan argumen.
Kalian bisa lihat di module opcode tersebut terdapat opmap dan opname yang bisa kalian coba

>>> opcode.opmap['BINARY_ADD']
23
>>> opcode.opname[23]
'BINARY_ADD'

“Compiled” Python files

Berbeda dengan file python biasa, ini adalah file yang berisi script python yang sudah dicompile (menjadi bytecode). Biasanya file ini berformat .pyc dan bisa langsung dijalankan seperti kita menjalankan script python pada umumnya. Dengan pyc ini, script kita mungkin akan lebih cepat dieksekusi karena interpreter tidak perlu mengcompile script python terlebih dahulu dan juga dapat berguna juga untuk menyembunyikan script python yang kita buat, walaupun masih bisa didecompile untuk mendapatkan script python aslinya.

$ cat x.py                                             
#!/usr/bin/env python
# -*- coding: utf-8 -*-

for i in range(10):
    print i
$ python -m compileall x.py  # Compile x.py akan menghasilkan file x.pyc                        
Compiling x.py ...
$ file x.pyc                                           
x.pyc: python 2.7 byte-compiled
$ python x.pyc                                         
0
1
2
3
4
5
6
7
8
9
$ xxd x.pyc                                            
00000000: 03f3 0d0a 5f0f 0d5a 6300 0000 0000 0000  ....\_..Zc.......
00000010: 0002 0000 0040 0000 0073 2000 0000 7819  .....@...s ...x.
00000020: 0065 0000 6400 0083 0100 445d 0b00 5a01  .e..d.....D]..Z.
00000030: 0065 0100 4748 710d 0057 6401 0053 2802  .e..GHq..Wd..S(.
00000040: 0000 0069 0a00 0000 4e28 0200 0000 7405  ...i....N(....t.
00000050: 0000 0072 616e 6765 7401 0000 0069 2800  ...ranget....i(.
00000060: 0000 0028 0000 0000 2800 0000 0073 0400  ...(....(....s..
00000070: 0000 782e 7079 7408 0000 003c 6d6f 6475  ..x.pyt....<modu
00000080: 6c65 3e04 0000 0073 0200 0000 1301       le>....s......

file pyc sejatinya adalah code object yang diserialize dengan marshal, dengan tambahan 8 byte diawal file pyc adalah magic number dan timestamps. Tentu kita bisa langsung mengeksekusi dengan cara mendeserialize file tersebut dan mengeksekusinya.

Python 2.7.13 (default, Jan 19 2017, 14:48:08)
[GCC 6.3.0 20170118] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import marshal
>>> import dis
>>> fp = open("./x.pyc", "rb")
>>> magic, timstamp = fp.read(4), fp.read(4)
>>> co = marshal.load(fp) # deserialize
>>> co
<code object <module> at 0x7f4df05582b0, file "x.py", line 4>
>>> exec co
0
1
2
3
4
5
6
7
8
9
>>> dis.dis(co)
  4           0 SETUP_LOOP              25 (to 28)
              3 LOAD_NAME                0 (range)
              6 LOAD_CONST               0 (10)
              9 CALL_FUNCTION            1
             12 GET_ITER
        >>   13 FOR_ITER                11 (to 27)
             16 STORE_NAME               1 (i)

  5          19 LOAD_NAME                1 (i)
             22 PRINT_ITEM
             23 PRINT_NEWLINE
             24 JUMP_ABSOLUTE           13
        >>   27 POP_BLOCK
        >>   28 LOAD_CONST               1 (None)
             31 RETURN_VALUE

Modifying Python Bytecode

Setelah mempelajari apa yang ada diatas, sekarang kita saya akan memberikan trik trik yang bisa dilakukan terhadap python bytecode. Salah satu yang saya bahas adalah cara untuk memodifikasi python bytecode atau bisa dibilang mempatch di level bytecode. Oke disini saya mempunyai fungsi yang sederhana.

>>> def add(x, y):
...     return x + y
...
>>> add(10, 20)
30
>>>

Sudah jelas apa yang dilakukan fungsi diatas. Tujuan kita adalah mengubah operasi pertambahan yang ada didalam fungsi tersebut menjadi operasi pengurangan.

>>> dis.dis(add)
  2           0 LOAD_FAST                0 (x)
              3 LOAD_FAST                1 (y)
              6 BINARY_ADD
              7 RETURN_VALUE

Hasil dari disassembly bytecode diatas sangat sederhana, 2 operasi LOAD_FAST pertama akan menaruh variable x dan y ke dalam stack setelah itu ada intruksi BINARY_ADD yang menjumlahkan kedua bilangan yang berada diatas stack, hasil dari operasi BINARY_ADD akan disimpan diatas stack juga. Tujuan kita adalah mengubah intruksi BINARY_ADD menjadi BINARY_SUBTRACT. Terlebih dahulu kita lihat apa yang harus kita ubah, pertama kita lihat opcode yang sebenarnya

>>> add.func_code.co_code
'|\x00\x00|\x01\x00\x17S'

Untuk memudahkan pembacaan, kita ubah menjadi list desimal

>>> list(bytearray(add.func_code.co_code))
[124, 0, 0, 124, 1, 0, 23, 83]

Seperti yang tertera di dis, intruksi BINARY_ADD terdapat di index ke 6

>>> bc = list(bytearray(add.func_code.co_code))
>>> bc[6] # Ini adalah intruksi BINARY_ADD
23
>>> import opcode
>>> opcode.opmap['BINARY_ADD'] # Sama seperti yang terdapat di opmap
23

Untuk mengubah operasi add menjadi substract kita harus mengubah opcode BINARY_ADD (23) menjadi opcode BINARY_SUBTRACT. Dengan opmap kita bisa mengetahui nilai dari opcode BINARY_SUBTRACT.

>>> opcode.opmap['BINARY_SUBTRACT']
24

Sekarang, kita ganti opcode BINARY_ADD dengan BINARY_SUBTRACT.

>>> func_code = list(bytearray(add.func_code.co_code))
>>> func_code[6] = opcode.opmap['BINARY_SUBTRACT']
>>> func_code
[124, 0, 0, 124, 1, 0, 24, 83]
>>> func_code = str(bytearray(func_code))
>>> func_code
'|\x00\x00|\x01\x00\x18S'

Selanjutnya kita ganti code object yang ada di fungsi add, dengan code object buatan kita yang kita bytecodenya kita isi dengan bytecode yang telah kita rubah

>>> fco = add.func_code
>>> add.func_code = type(fco)(
...     fco.co_argcount,
...     fco.co_nlocals,
...     fco.co_stacksize,
...     fco.co_flags,
...     func_code,
...     fco.co_consts,
...     fco.co_names,
...     fco.co_varnames,
...     fco.co_filename,
...     fco.co_name,
...     fco.co_firstlineno,
...     fco.co_lnotab,
...     fco.co_freevars,
...     fco.co_cellvars
... )
>>> add(3, 4)
-1

Yap, kita sudah berhasil, operasi pertambahan di fungsi add diubah menjadi operasi pengurangan dengan mempatch fungsi tersebut di level bytecode, jika kreatif kalian bisa mengembangakan teknik-teknik yang sudah dibahas diatas, seperti mempatch langsung ke file pycnya.

$ uncompyle6 anti_decompile.pyc
# uncompyle6 version 2.13.2
# Python bytecode 2.7 (62211)
# Decompiled from: Python 2.7.13 (default, Jan 19 2017, 14:48:08)
# [GCC 6.3.0 20170118]
# Embedded file name: anti_decompile.py
# Compiled at: 2017-12-14 15:53:39
Traceback (most recent call last):
  File "/usr/local/bin/uncompyle6", line 11, in <module>
    sys.exit(main_bin())
  File "/usr/local/lib/python2.7/dist-packages/uncompyle6/bin/uncompile.py", line 163, in main_bin
    **options)
--- **_snip_**
  File "/usr/local/lib/python2.7/dist-packages/uncompyle6/scanner.py", line 48, in __init__
    self._tokens, self._customize = scanner.ingest(co, classname)
  File "/usr/local/lib/python2.7/dist-packages/uncompyle6/scanners/scanner2.py", line 234, in ingest
    pattr = self.opc.cmp_op[oparg]
IndexError: tuple index out of range
$ python anti_decompile.pyc
Hello world

Lihat diatas, file anti_decompile.pyc adalah file pyc yang sudah saya modifikasi bytecodenya agar tidak bisa didecompile.
Selamat bereksperimen 😀

Referensi

[1] https://docs.python.org/2/library/dis.html#opcode-STORE_FAST
[2] https://www.synopsys.com/blogs/software-security/understanding-python-bytecode/
[3] https://nedbatchelder.com/blog/200804/the_structure_of_pyc_files.html
[4] https://vgel.me/posts/patching_function_bytecode_with_python/
[5] http://www.aosabook.org/en/500L/a-python-interpreter-written-in-python.html
[6] http://akaptur.com/blog/2013/11/17/introduction-to-the-python-interpreter-3/

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s