天天看點

Python代碼保護 | pyc 混淆從入門到工具實作

之前接觸到 Python 逆向相關的一些 CTF 題目(最近一次是某符的 game),有的給出 Python 的僞指令,還有的直接給了一個被替換過指令的 pyc 檔案,于是學習了一下Python 的位元組碼。學習過程中發現替換位元組碼指令這個操作其實是 Python 源碼保護的一種方式,于是想到有沒有不去修改 Python 解釋器的方法去保護源碼(增加對抗的成本)。

查閱資料發現 Python 源碼有幾種保護的方式:

1.生成 pyc 檔案:這感覺完全不能算保護,uncompyle6 一鍵反編譯,支援 Python 1.0 到 3.8 全部版本(恐怖)

2.py 源碼混淆:一般針對 py 源碼混淆就是往代碼裡插入一些沒有意義跳轉分支,修改變量名和函數名等這些操作,但是這種雖然閱讀起來很難了解,但是混淆效果并不好。

3.打包成可執行的二進制檔案

4.自定義 opcode 的 Python 解釋器

學習了 Python 位元組碼之後,就想從 pyc 檔案入手,去做一些混淆,因為雖然 uncompyle6 使得 pyc 檔案反編譯變得很簡單,但是簡單的無效指令就可能使這類工具失效。

查閱資料過程中發現其實針對 Python2 的位元組碼有較多的分析,但是針對 Python3 位元組碼分析就幾乎沒有了;雖說原理的都是一樣的,但是指令和格式上 Python3 都有了一定的變化,并且其實Python3 不同版本之間的變化也是較大的,是以下面先對 pyc 格式進行簡單的版本對比分析,然後再談談混淆的思路。

生成 pyc 檔案

pyc 檔案其實包含的是 Python 虛拟機可執行的的 byte-code。

Python 自帶的 py_compile 子產品可以直接把源碼編譯成 pyc 檔案,我們平時的的子產品導入(import)也是會将導入的子產品對應的檔案編譯成 pyc 的。

pyc 檔案的格式

magic number + 源代碼檔案資訊 + PyCodeObject

  • 4個位元組的 magic number
  • 12個位元組的源代碼檔案資訊(不同版本的 Python 包含的長度和資訊都不一樣,後面說)
  • 序列化之後的 PyCodeObject

magic number

像大多數的檔案格式一樣,pyc 檔案開頭也有一個 magic number,不過不一樣的是 pyc 檔案的 magic number 并不固定,而是不同版本的 Python 生成的 pyc 檔案的 magic number 都不相同。這裡可以看到看不同版本的 Python 的 magic number 是多少。前兩個位元組以小端的形式寫入,然後加上 \r\n 形成了四個位元組的 pyc 檔案的magic number

如 Python2.7 的 magic number 為 MAGIC_NUMBER = (62211).to_bytes(2, ‘little’) + b’\r\n’

我們可以看到的前四個位元組的16進制形式為 03f3 0d0a

Python代碼保護 | pyc 混淆從入門到工具實作

python 2.7生成的 pyc 檔案前32個位元組

源代碼檔案資訊

源代碼檔案資訊在 Python 不同的版本之後差别較大

  • 在Python2的時候,這部分隻有4個位元組,為源代碼檔案的修改時間的 Unix timestamp(精确到秒)以小端法寫入,如上圖 (1586087865).to_bytes(4, ‘little’).hex() -> b9c7 895e。
  • 在 Python 3.5 之前的版本已經找不到了(後面就都從 Python 3.5 開始讨論了)
  • Python 3.5 和 3.6 相對于 Python 2,源代碼檔案資訊這部分,在時間後面增加了4個位元組的源代碼檔案的大小,機關位元組,以小端法寫入。如源碼檔案大小為87個位元組,那麼檔案資訊部分就寫入 5700 0000。加上前面的修改時間,就是 b9c7 895e 5700 0000
    Python代碼保護 | pyc 混淆從入門到工具實作
    python 3.6生成的 pyc 檔案前32個位元組
  • 從 Python3.7 開始支援 hash-based pyc 檔案
Changed in version 3.7: Added hash-based .pyc files. Previously, Python only supported timestamp-based invalidation of bytecode caches.

也是就說,Python 不僅支援校驗 timestrap 來判斷檔案是否修改過了,也支援校驗 hash 值。Python 為了支援 hash 校驗又使源代碼檔案資訊這部分增加了4個位元組,變為一共12個位元組。

Python代碼保護 | pyc 混淆從入門到工具實作

python 3.7生成的 pyc 檔案前32個位元組

但是這個 hash 校驗預設是不啟用的(可以通過調用 py_compile 子產品的 compile 函數時傳入參數invalidation_mode=PycInvalidationMode.CHECKED_HASH 啟用)。不啟用時前4個位元組為0000 0000,後8個位元組為3.6和3.7版本一樣的源碼檔案的修改時間和大小;當啟用時前4個位元組變為0100 0000或者0300 0000,後8個位元組為源碼檔案的 hash 值。

PyCodeObject

其實這是一個定義在 Python 源碼 Include/code.h 中的結構體,結構體中的資料通過 Python 的 marshal 子產品序列化之後存到了 pyc檔案當中。(不同版本之間 PyCodeObject 的内容是不一樣的,但是這就導緻了不同版本之間的 Python 産生的 pyc 檔案其實并不完全通用,以下舉例均使用 python 3.7)

marshal 子產品中實作了一些基本的 Python 對象(也就是 PyObject )的序列化,一個 PyObject 序列化時首先會寫入一個位元組表示這是一個什麼類型的 PyObject,不同類型的 PyObject 對應的類型如下,PyCodeObject 對應的就是 TYPE_CODE,寫入第一個位元組就是63。

// Python/marshal.c
// ......
#define TYPE_NULL               '0'
#define TYPE_NONE               'N'
#define TYPE_FALSE              'F'
#define TYPE_TRUE               'T'
#define TYPE_STOPITER           'S'
#define TYPE_ELLIPSIS           '.'
#define TYPE_INT                'i'
/* TYPE_INT64 is not generated anymore.
   Supported for backward compatibility only. */
#define TYPE_INT64              'I'
#define TYPE_FLOAT              'f'
#define TYPE_BINARY_FLOAT       'g'
#define TYPE_COMPLEX            'x'
#define TYPE_BINARY_COMPLEX     'y'
#define TYPE_LONG               'l'
#define TYPE_STRING             's'
#define TYPE_INTERNED           't'
#define TYPE_REF                'r'
#define TYPE_TUPLE              '('
#define TYPE_LIST               '['
#define TYPE_DICT               '{'
#define TYPE_CODE               'c'
#define TYPE_UNICODE            'u'
#define TYPE_UNKNOWN            '?'
#define TYPE_SET                '<'
#define TYPE_FROZENSET          '>'
#define FLAG_REF                '\x80' /* with a type, add obj to index */

// 以下都是Python3.5之後支援的
#define TYPE_ASCII              'a'
#define TYPE_ASCII_INTERNED     'A'
#define TYPE_SMALL_TUPLE        ')'
#define TYPE_SHORT_ASCII        'z'
#define TYPE_SHORT_ASCII_INTERNED 'Z'
// ......
           
Python代碼保護 | pyc 混淆從入門到工具實作

python 3.7生成的 pyc 檔案前32個位元組

但是我們發現我們第17個位元組也就是 PyCodeObject 的第一個位元組卻是 0xe3,這是因為 PyObject 對象第一個位元組還可以有一個 flag(# define FLAG_REF ‘\x80’),即第一個位元組為0x63 | 0x80 -> 0xe3。( FLAG_REF 表示将這個對象加入引用清單,當下次再出現這個對象的實作就可以不用再序列化一遍這個對象,直接使用 TYPE_REF 取這個對象就可以了;算是 Python 序列化的一種優化吧。Python2 實作不同。)

/* Bytecode object */
typedef struct {
  PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */
    int co_posonlyargcount;     /* #positional only arguments */
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_flags;               /* CO_..., see below */
    int co_firstlineno;         /* first source line number */
  PyObject *co_code;          /* instruction opcodes */
  PyObject *co_consts;        /* list (constants used) */
  PyObject *co_names;         /* list of strings (names used) */
  PyObject *co_varnames;      /* tuple of strings (local variable names) */
  PyObject *co_freevars;      /* tuple of strings (free variable names) */
  PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
    /* The rest aren't used in either hash or comparisons, except for co_name,
       used in both. This is done to preserve the name and line number
  for tracebacks and debuggers; otherwise, constant de-duplication
       would collapse identical functions/lambdas defined on different lines.
    */
  Py_ssize_t *co_cell2arg;    /* Maps cell vars which are arguments. */
  PyObject *co_filename;      /* unicode (where it was loaded from) */
  PyObject *co_name;          /* unicode (name, for reference) */
  PyObject *co_lnotab;        /* string (encoding addr<->lineno mapping) See
  Objects/lnotab_notes.txt for details. */
//  ......
}PyCodeObject;
           

上面結構體中的内容也不是全部都要寫入 pyc,我們可以看看 marshal 序列化 PyCodeObject 的實作部分

// ......
  else if (PyCode_Check(v)) {
        PyCodeObject *co = (PyCodeObject *)v;
        W_TYPE(TYPE_CODE, p);
  w_long(co->co_argcount, p);
  w_long(co->co_kwonlyargcount, p);
  w_long(co->co_nlocals, p);
  w_long(co->co_stacksize, p);
  w_long(co->co_flags, p);
  w_object(co->co_code, p);
  w_object(co->co_consts, p);
  w_object(co->co_names, p);
  w_object(co->co_varnames, p);
  w_object(co->co_freevars, p);
  w_object(co->co_cellvars, p);
  w_object(co->co_filename, p);
  w_object(co->co_name, p);
  w_long(co->co_firstlineno, p);
  w_object(co->co_lnotab, p);
    }
// ......
           

上面代碼我們可以發現 PyCodeObject 裡面序列化了哪些字段和序列化的順序。

第一個位元組是 PyObject 的類型,然後是 5x4=20個位元組的我們目前不大關心的資訊。然後 PyCodeObject 的第22個位元組開始就是 Python 的 opcode 序列了,這部分是決定了程式的執行流程,也就是我們最關心的部分了。由于一個 PyObject 的長度是不一定的,是以需要讀取完一個對象才能繼續讀取下個對象。

簡單說一下 PyCodeObject 中的幾個 PyObject 序列化時采用的類型。

Python代碼保護 | pyc 混淆從入門到工具實作

TYPE_STRING和TYPE_TUPLE後面的四個位元組表示該對象的長度(小端表示),TYPE_STRING表示字元串的長度,TYPE_TUPLE表示tuple中對象的個數。

PyCodeObject 中的 co_code

Python 的 opcode 序列決定了程式的執行流程,它被作為 TYPE_STRING 類型的 PyObject 存到了 PyCodeObject 的 co_code 當中。

Python代碼保護 | pyc 混淆從入門到工具實作

python 3.7 的 opcode 序列

上圖紅框中的内容就是序列化之後的 opcode 序列了( offset 0x2a-0x47),第25個位元組73表示 TYPE_STRING,第26-29個位元組表示對象的長度,1e00 0000就是小端表示的30。

opcode

Python 的源碼 Include/opcode.h 中定義了一系列的 opcode。其中,以 HAVE_ARGUMENT 為界限,凡是大于 HAVE_ARGUMENT 的 opcode 都是有參數的,凡是小于 HAVE_ARGUMENT 的 opcode 都是沒有參數的(有參數的也隻有一個參數)。

CPython implementation detail: Bytecode is an implementation detail of the CPython interpreter. No guarantees are made that bytecode will not be added, removed, or changed between versions of Python. Use of this module should not be considered to work across Python VMs or Python releases.

Python并不保證不同的Python版本之間的opcode的相容性,這也是Python各個版本之間的pyc不相容的一個原因。

Changed in version 3.6: Use 2 bytes for each instruction. Previously the number of bytes varied by instruction.。

從 Python 3.6開始,有一個較大的改變,就是不管 opcode 有沒有參數,每一條指令的長度都兩個位元組,opcode 占一個位元組,如果這個 opcode 是有參數的,那麼另外一個位元組就表示參數;如果這個 opcode 沒有參數,那麼另外一個位元組就會被忽略掉,一般都為00(猜測這樣對友善對指令執行進行一些優化)。其實 opcode 的參數隻是一個 offset。

但是在Python3.6之前,對于有參數的opcode,指令長度為3個位元組,|opcode|argv_low|argv_high|,opcode 一個位元組,參數兩個位元組也采用小端,如 Python 2.7中指令6401 00,表示 opcode 為 LOAD_CONST,參數為1

LOAD_CONST(consti)

Pushes co_consts[consti] onto the stack.

即從 co_consts 這個 tuple 對象中取出第1個對象(從0開始計算的,是以第一個就是co_consts[1])壓到棧頂。

我們可以用 Python 自帶的 dis 和 marshal 庫幫助我們看一下 opcode 序列是怎麼樣的。

針對源碼

print('Hello, world')
def fff(a, b):
    c = a + b
  return c & 0xffff
fff(34, 67)
           

用不同版本的 Python 産生 pyc 檔案看一下 opcode。

Python2.7

>>> import dis, marshal
>>> f=open('t.pyc', 'rb').read()
>>> co=marshal.loads(f[8:]) # Python2.7中,PyCodeObject在pyc檔案中的偏移為8
>>> dis.dis(co)
  1           0 LOAD_CONST           0 ('Hello, world')
              3 PRINT_ITEM
              4 PRINT_NEWLINE

  3           5 LOAD_CONST               1 (<code object fff at 0x10a1c9630, file "t.py", line 3>)
              8 MAKE_FUNCTION            0
             11 STORE_NAME               0 (fff)

  7          14 LOAD_NAME                0 (fff)
             17 LOAD_CONST               2 (34)
             20 LOAD_CONST               3 (67)
             23 CALL_FUNCTION            2
             26 POP_TOP
             27 LOAD_CONST               4 (None)
             30 RETURN_VALUE
>>> co.co_names
('fff',)
>>> co.co_consts
('Hello, world', <code object fff at ..., file ".../t.py", line 3>, 34, 67, None)
           
Python代碼保護 | pyc 混淆從入門到工具實作

Python3.7

>>> import dis, marshal
>>> f=open('t.pyc', 'rb').read()
>>> co=marshal.loads(f[16:]) # Python3.7中,PyCodeObject在pyc檔案中的偏移為16
>>> dis.dis(co)
  1           0 LOAD_NAME                0 (print)
              2 LOAD_CONST               0 ('Hello, world')
              4 CALL_FUNCTION            1
              6 POP_TOP

  3           8 LOAD_CONST               1 (<code object fff at ..., line 3>)
             10 LOAD_CONST               2 ('fff')
             12 MAKE_FUNCTION            0
             14 STORE_NAME               1 (fff)

  7          16 LOAD_NAME                1 (fff)
             18 LOAD_CONST               3 (34)
             20 LOAD_CONST               4 (67)
             22 CALL_FUNCTION            2
             24 POP_TOP
             26 LOAD_CONST               5 (None)
             28 RETURN_VALUE
>>> co.co_names
('print', 'fff')
>>> co.co_name
'<module>'
>>> co.co_consts
('Hello, world', <code object fff at ..., file".../t.py", line 3>,'fff', 34, 67,None)
           
Python代碼保護 | pyc 混淆從入門到工具實作

pyc 混淆

思路

由于 pyc 檔案有現成的工具( uncompyle 6 )可以還原成 Python 代碼,是以說我們不了解 pyc 格式也沒有關系。這樣我們混淆 pyc 的思路就可以是欺騙像 uncompyle 6 這類反編譯的工具,讓它誤以為指令的序列不合法,但是又不影響真正的Python 虛拟機執行。

Python 的虛拟機是根據 PyCodeObject 中的 co_code 這個字段中存儲的 opcode 序列來決定程式的執行流程的。是以說一個混淆的手段就是修改 co_code 字段中的 opcode 序列,可以添加一些加載超出範圍的變量的指令,再用一些指令去跳過這些會出錯的指令,這樣執行的時候就不會出錯了,但是反編譯工具就不能正常工作了。

舉個簡單的例子

0 LOAD_NAME                0 (print)
2 LOAD_CONST               0 ('Hello, world')
4 CALL_FUNCTION            1
6 POP_TOP
           

這是我們上面 Python 3.7生成的 pyc 的一段 opcode 序列,考慮在它的前面加兩條指令。

0 JUMP_ABSOLUTE            4
2 LOAD_CONST               255
4 LOAD_NAME                0 (print)
6 LOAD_CONST               0 ('Hello, world')
8 CALL_FUNCTION            1
10 POP_TOP
           

其中 JUMP_ABSOLUTE 4 表示直接跳轉到 offset 為4的位置去執行指令,也就是插入的第二條指令 LOAD_CONST 255 并不會被執行,是以是以也并不會報錯。但是對于反編譯工具來說,這就是一個錯誤了,直接導緻了反編譯的失敗。

實作

根據上面的那個思路,我們可以插入許多這樣類似的指令,任意的不合法指令(其實随機資料都可以),然後用一些 JUMP 指令去跳過這樣的不合法指令,上面的 JUMP_ABSOLUTE 隻是一個簡單的例子。甚至我們可以跳轉到一些自行添加的虛假分支再跳轉到到真實的分支(參考 ROP 的思路)。

Python 的 opcode 中 JUMP 相關的有

'JUMP_FORWARD',
'JUMP_IF_FALSE_OR_POP',
'JUMP_IF_TRUE_OR_POP',
'JUMP_ABSOLUTE',
'POP_JUMP_IF_FALSE',
'POP_JUMP_IF_TRUE',
           

原則上這六個都可以使用,但是實際上為了友善的話,其實還是 JUMP_FORWARD 和 JUMP_ABSOLUTE 比較好用(字面了解,一個是相對跳轉,一個是絕對跳轉),因為其他的 JUMP 指令存在一些目前棧頂元素判斷的問題(要做也可以,隻不過實作同樣的功能可能需要寫更多的指令)。

還有一些在添加混淆指令的時候可能會遇到的問題:

  • 首先是 Python 版本的問題,前面說了,Python 3.6之前使用的是變長指令,3.6及之後都是用的是定長指令了,這樣對于不同的版本需要有不用的處理。對于變長指令,在查找資料是還發現了還可以使用重疊指令來混淆(參考最後 reference 中的部落格連結),親測也是一種有效的混淆方式。對于定長指令,上面說的重疊指令就沒有辦法了。
  • 由于添加了指令,一些原本存在的絕對跳轉指令就會失效,是以需要對原本存在的絕對跳轉指令計算偏移。
  • 對于相對跳轉,由于參數隻能非負,是以不能向前跳轉;而絕對跳轉隻要計算好偏移就可以任意跳轉了。
  • 不定長指令的參數長度是兩個位元組,而定長指令的參數隻有一個位元組,可能存在參數長度不夠用的時候,這個時候可以使用 EXTENDED_ARG 指令去擴充參數的長度,最多可以有四個位元組。
  • 跳轉的混淆最好還是不要從循環内到循環外或者循環外到循環内。其實最好是根據 co_lnotab 字段中的指令偏移和行号來插入混淆指令,不在屬于同一行的指令中間插入,這樣可以避免一些可能存在的問題。

添加的混淆指令越多,檔案的體積也就越大了,但是混淆的效果可能也會更好一點。

End

其實學習了 pyc 不同版本之間的差異之後,發現混淆 pyc 這種方式其實還是存在版本局限性的,因為 Python 自己都不能保證不同小版本之間生成的 pyc 檔案是可以互相執行的,是以還是隻針對某一單一的版本進行混淆更容易一些。通過對 opcode 的進行混淆來達到反編譯器失效但是 pyc 仍可以執行這樣的方式,其實也隻能欺騙一下反編譯器,不能直接把 pyc檔案反編譯成 py 檔案,這樣的混淆如果搭配上源碼的混淆感覺效果會更好一些。想要完全避免 Python 代碼被分析幾乎是不可能的,但是通過簡單的混淆能大大增加分析者的工作量,我們的目的就達到了。

我建了一個python學習交流群654234959,期待大家的加入,一起學習共同進步

準備了一些學習資料,讀者福利時間

Python代碼保護 | pyc 混淆從入門到工具實作

連結:百度網盤

提取碼:frl9

連結容易被舉報過期,如果失效了也可以加群654234959找管理者小姐姐免費領取

繼續閱讀