作者: evilpan
原文鏈接: https://evilpan.com/2020/10/11/protected-python/

前言

某天在群里看到一個大佬看到另一個大佬的帖子而發的帖子的截圖,如下所示:

pediy

不過當我看到的時候已經過去了大概720小時?? 在查看該以太幣交易記錄的時候,發現在充值之后十幾小時就被提走了,可能是其他大佬也可能是作者自己。雖然沒錢可偷,但幸運的是 pyc 的下載地址依然有效,所以我就下載下來研究了一下。

初步分析

首先在專用的實驗虛擬機里運行一下,程序執行沒有問題:

$ python2 ether_v2.pyc
Input UR answer: whatever
You are too vegetable please try again!

然后看看文件里是否有對應的字符串信息:

$ grep vegetable ether_v2.pyc

很好,屁都沒有,看來字符串也混淆了。

目前市面上有一些開源的 pyc 還原工具,比如:

但是看作者的自信,應該是有信心可以抗住的,事實證明也確實可以。

Python 反匯編

既然沒有現成工具能用,那么我們就需要通過自己的方法來對代碼邏輯進行還原。要分析代碼邏輯第一步至少要把字節碼還原出來,使用 dis 模塊可以實現:

import dis
import marshal
with open('ether_v2.pyc', 'rb') as f:
    magic = f.read(4)
    timestamp = f.read(4)
    code = marshal.load(f)
    dis.disassemble(code)

.pyc文件本身是字節碼的marshal序列化格式,在 Python2.7 中加上 8 字節的 pyc 頭信息。一般通過上面的代碼即可打印出文件中的字節碼信息。當然,這個事情并不一般:

$ python2 try1.py
Traceback (most recent call last):
  File "try1.py", line 9, in <module>
    dis.disassemble(code)
  File "/usr/lib/python2.7/dis.py", line 64, in disassemble
    labels = findlabels(code)
  File "/usr/lib/python2.7/dis.py", line 166, in findlabels
    oparg = ord(code[i]) + ord(code[i+1])*256
IndexError: string index out of range

在 dis 模塊中直接異常退出了,有點意思。查看 dis 的源碼,查看出錯的部分,發現在 co.co_codeco.co_namesco.co_consts等多個地方都出現了下標溢出的IndexError。不管是什么原因,我們先把這些地方 patch 掉:

patch

這回就能看到輸出的 Python 字節碼了,如下:

$ ./dec.py --pyc ether_v2.pyc
  3           0 JUMP_ABSOLUTE         2764
              3 LOAD_CONST           65535 (consts[65535])
              6 <218>                50673
              9 SET_ADD              18016
             12 IMPORT_NAME           8316 (names[8316])
             15 STOP_CODE           
             16 LOAD_CONST              33 (8)
             19 COMPARE_OP               2 ('==')
             22 POP_JUMP_IF_FALSE       99
             25 LOAD_FAST               28 ('/ * && ')
             28 LOAD_ATTR               45 ('append')
             31 LOAD_FAST                9 ('with ^ raise ')
             34 LOAD_FAST               44 ('with as  - 6 lambda ')
             37 COMPARE_OP               8 ('is')
             40 CALL_FUNCTION            1
             43 POP_TOP             
             44 JUMP_FORWARD          8559 (to 8606)
...

不過這些字節碼的邏輯看起來很奇怪,看不出哪里奇怪不要緊,我們先來看看正常的 Python 字節碼。

Python ByteCode 101

Python 是一種解釋型語言,而 Python 字節碼是一種平臺無關的中間代碼,由 Python 虛擬機動態(PVM)解釋執行,這也是 Python 程序可以跨平臺的原因。

示例

看一個簡單的例子test.py:

#!/usr/bin/env python2

def add(a, b):
    return a - b + 42

def main():
    b = add(3, 4)
    c = add(b, 5)
    result = 'evilpan: ' + str(c)
    print result

if __name__ == '__main__':
    main()

使用上面的反匯編程序打印出字節碼如下:

$ ./dec.py --pyc test.pyc
  3           0 LOAD_CONST               0 (<code object add at 0x7f02ee26f5b0, file "test.py", line 3>)
              3 MAKE_FUNCTION            0
              6 STORE_NAME               0 ('add')

  6           9 LOAD_CONST               1 (<code object main at 0x7f02ee26ff30, file "test.py", line 6>)
             12 MAKE_FUNCTION            0
             15 STORE_NAME               1 ('main')

 12          18 LOAD_NAME                2 ('__name__')
             21 LOAD_CONST               2 ('__main__')
             24 COMPARE_OP               2 ('==')
             27 POP_JUMP_IF_FALSE       40

 13          30 LOAD_NAME                1 ('main')
             33 CALL_FUNCTION            0
             36 POP_TOP
             37 JUMP_FORWARD             0 (to 40)
        >>   40 LOAD_CONST               3 (None)
             43 RETURN_VALUE

能看懂英文的話,理解上面的代碼應該也沒有太大問題,不過值得注意的是有兩個 LOAD_CONST 指令的參數本身也是代碼,即dis.disassemble函數的參數,所以我們可以對其也進行反匯編:

dis.disassemble(code)
# ...
print("=== 0 ===")
dis.disassemble(code.co_consts[0])
print("=== 1 ===")
dis.disassemble(code.co_consts[1])

結果如下:

=== 0 ===
  4           0 LOAD_FAST                0 ('a')
              3 LOAD_FAST                1 ('b')
              6 BINARY_SUBTRACT
              7 LOAD_CONST               1 (42)
             10 BINARY_ADD
             11 RETURN_VALUE
=== 1 ===
  7           0 LOAD_GLOBAL              0 ('add')
              3 LOAD_CONST               1 (3)
              6 LOAD_CONST               2 (4)
              9 CALL_FUNCTION            2
             12 STORE_FAST               0 ('b')

  8          15 LOAD_GLOBAL              0 ('add')
             18 LOAD_FAST                0 ('b')
             21 LOAD_CONST               3 (5)
             24 CALL_FUNCTION            2
             27 STORE_FAST               1 ('c')

  9          30 LOAD_CONST               4 ('evilpan: ')
             33 LOAD_GLOBAL              1 ('str')
             36 LOAD_FAST                1 ('c')
             39 CALL_FUNCTION            1
             42 BINARY_ADD
             43 STORE_FAST               2 ('result')

 10          46 LOAD_FAST                2 ('result')
             49 PRINT_ITEM
             50 PRINT_NEWLINE
             51 LOAD_CONST               0 (None)
             54 RETURN_VALUE

基本概念

上述打印的是 Python 字節碼的偽代碼,存儲時還是二進制格式,這個在下一節說。上面的偽代碼雖然大致能猜出意思, 但這并不是嚴謹的方法。實際上 Python 字節碼在官方文檔有比較詳細的介紹,包括每個指令的含義以及參數。

注意: 字節碼的實現和具體Python版本有關

對于常年進行二進制逆向的人而言,可以把 Python 字節碼看做是一種特殊的指令集。對于一種指令集,我們實際上需要關心的是指令結構和調用約定。Python 虛擬機 PVM 是一種基于棧的虛擬機,參數也主要通過棧來進行傳遞,不過與傳統 x86 的參數傳遞順序相反,是從左到右進行傳遞的。

每條字節碼由兩部分組成:

opcode + oparg

其中opcde占1字節,即PVM支持最多256個類型的指令;

oparg占的空間和opcode有關,如果opcode帶參數,即opcode > dis.HAVE_ARGUMENT,則oparg2個字節;通常oparg表示在對應屬性中的索引,比如LOAD_CONST指令的oparg就表示參數在co_consts數組中的索引。

在Python3中oparg占1個字節,所以再次提醒: 字節碼的解析和具體Python版本有關

數組元素的數量是可變的,2字節最多只能表示65536個元素,要是超過這個值怎么辦?答案就是 EXTENDED_ARG。這是個特殊的opcode,值為dis.EXTENDED_ARG,遇到這個 opcode 則表示下一條指令的參數值 next_oparg 值需要進行拓展:

extented_arg = oparg * 65536
next_oparg = next_oparg + extended_arg

當然EXTENDED_ARG是可以級聯的,從而支持任意大小的參數值。

CodeType

要查看某個 Python 函數的字節碼,比如:

def func(a):
  return a + 42

可以通過func.__code__獲取。或者直接編譯:

c = "a = 3; b = 4; c = a + b"
co = compile(c, "", "exec")

func.__code__co都是下面的 CodeType 類型:

class CodeType:
    co_argcount: int
    co_cellvars: Tuple[str, ...]
    co_code: str
    co_consts: Tuple[Any, ...]
    co_filename: str
    co_firstlineno: int
    co_flags: int
    co_freevars: Tuple[str, ...]
    co_lnotab: str
    co_name: str
    co_names: Tuple[str, ...]
    co_nlocals: int
    co_stacksize: int
    co_varnames: Tuple[str, ...]

前面介紹的字節碼,就是co_code中的內容。而字節碼中的參數oparg則是在對應數組(Tuple)中的位置。了解 PVM 翻譯字節碼過程最好的方法就是參考 dis 模塊中的反匯編函數:

def disassemble(co, lasti=-1):
    """Disassemble a code object."""
    code = co.co_code
    labels = findlabels(code)
    linestarts = dict(findlinestarts(co))
    n = len(code)
    i = 0
    extended_arg = 0
    free = None
    while i < n:
        c = code[i]
        op = ord(c)
        if i in linestarts:
            if i > 0:
                print
            print "%3d" % linestarts[i],
        else:
            print '   ',

        if i == lasti: print '-->',
        else: print '   ',
        if i in labels: print '>>',
        else: print '  ',
        print repr(i).rjust(4),
        print opname[op].ljust(20),
        i = i+1
        if op >= HAVE_ARGUMENT:
            oparg = ord(code[i]) + ord(code[i+1])*256 + extended_arg
            extended_arg = 0
            i = i+2
            if op == EXTENDED_ARG:
                extended_arg = oparg*65536L
            print repr(oparg).rjust(5),
            if op in hasconst:
                print '(' + repr(co.co_consts[oparg]) + ')',
            elif op in hasname:
                print '(' + co.co_names[oparg] + ')',
            elif op in hasjrel:
                print '(to ' + repr(i + oparg) + ')',
            elif op in haslocal:
                print '(' + co.co_varnames[oparg] + ')',
            elif op in hascompare:
                print '(' + cmp_op[oparg] + ')',
            elif op in hasfree:
                if free is None:
                    free = co.co_cellvars + co.co_freevars
                print '(' + free[oparg] + ')',
        print

其中hasconsthashname都是定義在opcode模塊中的數組,包含對應字節碼指令的參數類型,比如LOAD_CONST指令就包含在hasconst數組中,這只是一種方便的寫法。

加固與脫殼

通過字節碼基本上能還原出原始代碼的邏輯,即還原出可閱讀的反匯編代碼;如果要更進一步,反編譯出原始的 Python 代碼也是可以的,因為 CodeType 對象中已經有了足夠多的信息。

因此,出于保護的目的,就有了針對 python 代碼的安全加固的需求,一般而言 python 代碼加固有以下幾種:

  • 源碼混淆,比如替換混淆變量名,例如 JavaScript 的 uglify 和 Java 的 Proguard,目的是讓代碼變得不可讀;
  • 字節碼混淆,在不提供源代碼的前提下,針對特定版本的 Python 對字節碼做了額外的執行流混淆和代碼數據加密,并在運行時解密,不影響最終程序在標準 Python 解釋器中的運行結果;
  • 魔改解釋器,使用了定制的 Python 解釋器,對 opcode 等字節碼的屬性進行了替換和修改,與混淆后的字節碼文件一并提供,并且無法在標準解釋器中運行;
  • 其他的組合技……

對于我們的目標而言,顯然是第二種加固方法,因為輸出的 pyc 文件可以在標準的 Python2.7 解釋器中運行。查看直接反匯編的字節碼,可以明顯看出對抗的痕跡:

  3           0 JUMP_ABSOLUTE         2764
              3 LOAD_CONST           65535 (consts[65535])
              6 <218>                50673
              9 SET_ADD              18016

內部使用了許多跳轉指令,并在期間插入各種無效指令,這也是標準的反編譯模塊會崩潰退出的原因之一。既然無法使用靜態分析,那么動態調試就是一個直觀的方案,因為 Python 作為一個解釋執行的語言,所有字節碼最終都是需要通過 PVM 虛擬機去解釋的。

CPython

為了分析 Python 如何解釋執行字節碼,我下載了默認的解釋器 CPython 源碼進行分析。首先從 PyEval_EvalCode 函數為入口找起:

PyObject *
PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals)
{
    return PyEval_EvalCodeEx(co,
                      globals, locals,
                      (PyObject **)NULL, 0,
                      (PyObject **)NULL, 0,
                      (PyObject **)NULL, 0,
                      NULL, NULL);
}

經過漫長的調用鏈:

  • PyEval_EvalCode
  • PyEval_EvalCodeEx
  • _PyEval_EvalCodeWithName
  • _PyEval_EvalCode
  • _PyEval_EvalFrame
  • tstate->interp->eval_frame
  • _PyEval_EvalFrameDefault

最終來到執行的函數_PyEval_EvalFrameDefault,該函數大約有 3000 行 C 代碼,并且其中大量使用了宏來加速運算。前面說過 Python 字節碼是基于棧的,這里的 Frame 就是指代某個棧幀,也就是當前執行流的上下文。棧幀中包括字節碼、全局變量、本地變量等信息,如下所示:

struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* previous frame, or NULL */
    PyCodeObject *f_code;       /* code segment */
    PyObject *f_builtins;       /* builtin symbol table (PyDictObject) */
    PyObject *f_globals;        /* global symbol table (PyDictObject) */
    PyObject *f_locals;         /* local symbol table (any mapping) */
    PyObject **f_valuestack;    /* points after the last local */
    PyObject *f_trace;          /* Trace function */
    int f_stackdepth;           /* Depth of value stack */
    char f_trace_lines;         /* Emit per-line trace events? */
    char f_trace_opcodes;       /* Emit per-opcode trace events? */

    /* Borrowed reference to a generator, or NULL */
    PyObject *f_gen;

    int f_lasti;                /* Last instruction if called */
    /* Call PyFrame_GetLineNumber() instead of reading this field
       directly.  As of 2.3 f_lineno is only valid when tracing is
       active (i.e. when f_trace is set).  At other times we use
       PyCode_Addr2Line to calculate the line from the current
       bytecode index. */
    int f_lineno;               /* Current line number */
    int f_iblock;               /* index in f_blockstack */
    PyFrameState f_state;       /* What state the frame is in */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
};

這里的PyCodeObject就是字節碼對象,和 dis 模塊中的對象類似:

/* Bytecode object */
struct PyCodeObject {
    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. */
  // ...
}

回到(默認的)eval_frame函數,抽取一些關鍵部分如下:

#define JUMPTO(x)       (next_instr = first_instr + (x) / sizeof(_Py_CODEUNIT))
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
  //...
    if (tstate->use_tracing) {
        if (tstate->c_tracefunc != NULL) {
            if (call_trace_protected(tstate->c_tracefunc,
                               tstate->c_traceobj,
                               tstate, f, PyTrace_CALL, Py_None)) {
                /* Trace function raised an error */
                goto exit_eval_frame;
            }
        }
      }
    // ...
    first_instr = (_Py_CODEUNIT *) PyBytes_AS_STRING(co->co_code);
  next_instr = first_instr;
  // ...
main_loop:
    for (;;) {
        assert(stack_pointer >= f->f_valuestack); /* else underflow */
        assert(STACK_LEVEL() <= co->co_stacksize);  /* else overflow */
        assert(!_PyErr_Occurred(tstate));
    fast_next_opcode:
                if (PyDTrace_LINE_ENABLED())
            maybe_dtrace_line(f, &instr_lb, &instr_ub, &instr_prev);

        /* line-by-line tracing support */
        if (trace...) {
          err = maybe_call_line_trace(tstate->c_tracefunc,
                                        tstate->c_traceobj,
                                        tstate, f,
                                        &instr_lb, &instr_ub, &instr_prev);
        }
    dispatch_opcode:
      // ...
        switch (opcode) {
            case TARGET(NOP): {
            FAST_DISPATCH();
          }
          case TARGET(LOAD_FAST): {/*...*/}
          case TARGET(LOAD_CONST): {
            PREDICTED(LOAD_CONST);
            PyObject *value = GETITEM(consts, oparg);
            Py_INCREF(value);
            PUSH(value);
            FAST_DISPATCH();
            }
          case TARGET(STORE_FAST): {/*...*/}
          case TARGET(POP_TOP): {/*...*/}
          // ...
          case TARGET(BINARY_MULTIPLY): {
            PyObject *right = POP();
            PyObject *left = TOP();
            PyObject *res = PyNumber_Multiply(left, right);
            Py_DECREF(left);
            Py_DECREF(right);
            SET_TOP(res);
            if (res == NULL)
                goto error;
            DISPATCH();
            }
          // ...
          case TARGET(JUMP_ABSOLUTE): {
            PREDICTED(JUMP_ABSOLUTE);
            JUMPTO(oparg);
#if FAST_LOOPS
            /* Enabling this path speeds-up all while and for-loops by bypassing
               the per-loop checks for signals.  By default, this should be turned-off
               because it prevents detection of a control-break in tight loops like
               "while 1: pass".  Compile with this option turned-on when you need
               the speed-up and do not need break checking inside tight loops (ones
               that contain only instructions ending with FAST_DISPATCH).
            */
            FAST_DISPATCH();
#else
            DISPATCH();
#endif
            }
                    // ...
          case TARGET(EXTENDED_ARG): {
            int oldoparg = oparg;
            NEXTOPARG();
            oparg |= oldoparg << 8;
            goto dispatch_opcode;
            }
          // ...
          // switch end
        }
                /* This should never be reached. Every opcode should end with DISPATCH()
        or goto error. */
        Py_UNREACHABLE();
error:
      // ...
exception_unwind:
      // ...
exiting:
      // ...
        }
    }
/* pop frame */
exit_eval_frame:
    // ...
    return _Py_CheckFunctionResult(tstate, NULL, retval, __func__);
}

大部分的代碼是對字節碼中的 opcode 進行 switch/case 處理,上面截取了幾個提到的字節碼,比如 LOAD_CONST、JUMP_ABSOLUTE、BINARY_MULTIPLY、EXTENDED_ARG 等,根據代碼的執行流程大概知道了 Python 解釋器如何對這些字節碼進行理解。

c_tracefunc

在 switch 語句之前有部分代碼值得注意,即關于c_tracefunc的處理。從代碼中看出,Python實際上內置了追蹤字節碼的功能。我們可以使用 sys.settrace 來設置跟蹤函數,下面是一個簡單的例子:

#!/usr/bin/env python2
import sys
import dis

def func(a, b):
    c = a + b
    return c * 10

co = func.__code__
dis.disassemble(co)

def mytrace(frame, why, arg):
    print "Trace", frame, why, arg
    return mytrace

print "=== Trace Start ==="
sys.settrace(mytrace)

func(3, 4)

輸出如下:

$ ./demo.py
  6           0 LOAD_FAST                0 (a)
              3 LOAD_FAST                1 (b)
              6 BINARY_ADD
              7 STORE_FAST               2 (c)

  7          10 LOAD_FAST                2 (c)
             13 LOAD_CONST               1 (10)
             16 BINARY_MULTIPLY
             17 RETURN_VALUE
=== Trace Start ===
Trace <frame object at 0x10b8cb218> call None
Trace <frame object at 0x10b8cb218> line None
Trace <frame object at 0x10b8cb218> line None
Trace <frame object at 0x10b8cb218> return 70
Trace <frame object at 0x10b98c050> call None
Trace <frame object at 0x10b98c050> call None

Python 的標準庫中也提供了 trace 模塊 來支持字節碼跟蹤,查看該模塊的的源碼發現實際上也是用了 sys.settrace 或者 threading.settrace 來設置跟蹤回調。

不過,使用 sys.trace 并不是每條指令都跟蹤的,只針對特定事件進行跟蹤:

  • call: 函數調用
  • return: 函數返回
  • line: 一行新代碼
  • exception: 異常事件

而且該代碼中也做了對應的防護,使用 trace 啟動腳本直接報錯:

SystemError: A debugger has been found running in your system. Please, unload it from memory and restart.

Python 的 trace 功能可以用來實現行覆蓋率以及調試器等強大的功能,只是對于我們這次的目標并不適用。

類似的回調還有 c_profilefunc ,不過該函數不對 line 事件進行觸發。

LLTRACE

Python 有一個鮮為人知的特性是可以在 Debug 編譯時啟用底層跟蹤 LLTRACE (即 Low Level Trace),這也是在查看 ceval.c 時發現的:

    next_instr = first_instr + f->f_lasti + 1;
    stack_pointer = f->f_stacktop;
    assert(stack_pointer != NULL);
    f->f_stacktop = NULL;       /* remains NULL unless yield suspends frame */

#ifdef LLTRACE
    lltrace = PyDict_GetItemString(f->f_globals, "__lltrace__") != NULL;
#endif
#if defined(Py_DEBUG) || defined(LLTRACE)
    filename = PyString_AsString(co->co_filename);
#endif

    why = WHY_NOT;
    err = 0;
    x = Py_None;        /* Not a reference, just anything non-NULL */
    w = NULL;

    if (throwflag) { /* support for generator.throw() */
        why = WHY_EXCEPTION;
        goto on_error;
    }

    for (;;) {
      // 循環解釋執行 Python 字節碼
    }

Low Level Trace 一方面需要編譯時啟用,另一方面也需要在運行時當前棧幀定義了全局變量__lltrace__

還是實踐出真知,先寫個簡單的測試文件:

# test.py
__lltrace__ = 1

def add(a, b):
    return a + b - 42

a = 3
c = add(a, 4)

使用 Debug 編譯的 Python 運行結果如下:

$ /cpython_dbg/bin/python2.7 test.py
0: 124, 0
push 3
3: 124, 1
push 4
6: 23
pop 4
7: 100, 1
push 42
10: 24
pop 42
11: 83
pop -35
ext_pop 4
ext_pop 3
ext_pop <function add at 0x7f95944a0e28>
push -35
33: 90, 3
pop -35
36: 100, 4
push None
39: 83
pop None

打印的數字從下面的代碼而來:

        if (lltrace) {
            if (HAS_ARG(opcode)) {
                printf("%d: %d, %d\n",
                       f->f_lasti, opcode, oparg);
            }
            else {
                printf("%d: %d\n",
                       f->f_lasti, opcode);
            }
        }

其中 push/pop 相關的輸出來源是如下棧追蹤相關的函數:

#ifdef LLTRACE
static int
prtrace(PyObject *v, char *str)
{
    printf("%s ", str);
    if (PyObject_Print(v, stdout, 0) != 0)
        PyErr_Clear(); /* Don't know what else to do */
    printf("\n");
    return 1;
}
#define PUSH(v)         { (void)(BASIC_PUSH(v), \
                          lltrace && prtrace(TOP(), "push")); \
                          assert(STACK_LEVEL() <= co->co_stacksize); }
#define POP()           ((void)(lltrace && prtrace(TOP(), "pop")), \
                         BASIC_POP())
#define STACKADJ(n)     { (void)(BASIC_STACKADJ(n), \
                          lltrace && prtrace(TOP(), "stackadj")); \
                          assert(STACK_LEVEL() <= co->co_stacksize); }
#define EXT_POP(STACK_POINTER) ((void)(lltrace && \
                                prtrace((STACK_POINTER)[-1], "ext_pop")), \
                                *--(STACK_POINTER))
#else
#define PUSH(v)                BASIC_PUSH(v)
#define POP()                  BASIC_POP()
#define STACKADJ(n)            BASIC_STACKADJ(n)
#define EXT_POP(STACK_POINTER) (*--(STACK_POINTER))
#endif

上面的 lltrace 輸出可以記錄每條字節碼的執行,并且會打印堆棧的變化,因此在追蹤和調試字節碼上非常有用。

更多 LLTRACE 相關內容見: https://github.com/python/cpython/blob/master/Misc/SpecialBuilds.txt

Python VMP

現在有了 LLTRACE 的功能,但是要實現 ether_v2.py 的追蹤還需要解決幾個問題:

  1. LLTRACE 的啟用需要在當前棧幀上定義全局變量 __lltrace__
  2. LLTRACE 輸出的字節碼過于簡略,缺乏可讀性;
  3. LLTRACE 輸出的字節碼是運行的代碼,也就是循環展開后(flatten)的代碼,進一步影響逆向分析;

所以我使用了一個簡單粗暴的方法,即直接修改 CPython 源代碼。首先在判斷 lltrace 啟用的地方修改判斷從f->f_globals 改為遞歸搜索 f->f_back->f_globals,這樣只要在我們的調用棧幀定義變量即可;對于字節碼的輸出,最好是可以有類似 dis 模塊的顯示效果,至于平坦化的控制流,可以根據指令 index 再重新進行組合。

Dynamic Trace

在 LLTRACE 的基礎上,我們可以比較簡單地修改出一版具有可讀性的 Trace 代碼,以下面的源碼為例:

# test.py
__pztrace__ = 1

def validate(s):
    if len(s) != 4:
        return False
    cc = 0
    for i in s:
        cc ^= ord(i)
    if cc == 0:
        return True
    return False

s = raw_input('Your input: ')
if validate(s):
    print 'ok'
else:
    print 'failed'

其中__pztrace__是我新定義的全局跟蹤觸發標記,在沒有源碼的前提下,運行上述字節碼可實時打印字節碼如下:

$ /build/cpython/build/bin/python2.7 test.py
Your input: helloworld
=== pztrace test.py ===
   0 LOAD_GLOBAL 0; push <built-in function len>
   3 LOAD_FAST 0; push 'helloworld'
   6 CALL_FUNCTION 1
ext_pop 'helloworld'
ext_pop <built-in function len>
push 10
   9 LOAD_CONST 1; push 4
  12 COMPARE_OP 3 (!=) ; pop 4
  15 POP_JUMP_IF_FALSE 22; pop True
  18 LOAD_GLOBAL 1; push False
  21 RETURN_VALUE; pop False
ext_pop 'helloworld'
ext_pop <function validate at 0x7fe13a5f4ed0>
push False
  36 POP_JUMP_IF_FALSE 47; pop False
  47 LOAD_CONST 4; push 'failed'
  50 LOAD_BUILD_CLASS; pop 'failed'
failed  51 YIELD_FROM;
  52 LOAD_CONST 5; push None
  55 RETURN_VALUE; pop None

將每條字節碼后對應的棧操作以及實時數據輸出,更加有利于對代碼的理解。從上面的字節碼輸出中可以基本看出實際的操作,而且打印出來的是已經執行到的分支,通過調整輸入可以觸達不同的分支,如下為輸入abab的跟蹤流程:

$ /build/cpython/build/bin/python2.7 test.py
Your input: abab
=== pztrace test.py ===
   0 LOAD_GLOBAL 0; push <built-in function len>
   3 LOAD_FAST 0; push 'abab'
   6 CALL_FUNCTION 1
ext_pop 'abab'
ext_pop <built-in function len>
push 4
   9 LOAD_CONST 1; push 4
  12 COMPARE_OP 3 (!=) ; pop 4
  15 POP_JUMP_IF_FALSE 22; pop False
  22 LOAD_CONST 2; push 0
  25 STORE_FAST 1; pop 0
  28 SETUP_LOOP 30
  31 LOAD_FAST 0; push 'abab'
  34 GET_ITER
  35 FOR_ITER 22; push 'a'
  38 STORE_FAST 2; pop 'a'
  41 LOAD_FAST 1; push 0
  44 LOAD_GLOBAL 2; push <built-in function ord>
  47 LOAD_FAST 2; push 'a'
  50 CALL_FUNCTION 1
ext_pop 'a'
ext_pop <built-in function ord>
push 97
  53 INPLACE_XOR; pop 97
  54 STORE_FAST 1; pop 97
  57 JUMP_ABSOLUTE 35
  35 FOR_ITER 22; push 'b'
  38 STORE_FAST 2; pop 'b'
  41 LOAD_FAST 1; push 97
  44 LOAD_GLOBAL 2; push <built-in function ord>
  47 LOAD_FAST 2; push 'b'
  50 CALL_FUNCTION 1
ext_pop 'b'
ext_pop <built-in function ord>
push 98
  53 INPLACE_XOR; pop 98
  54 STORE_FAST 1; pop 3
  57 JUMP_ABSOLUTE 35
  35 FOR_ITER 22; push 'a'
  38 STORE_FAST 2; pop 'a'
  41 LOAD_FAST 1; push 3
  44 LOAD_GLOBAL 2; push <built-in function ord>
  47 LOAD_FAST 2; push 'a'
  50 CALL_FUNCTION 1
ext_pop 'a'
ext_pop <built-in function ord>
push 97
  53 INPLACE_XOR; pop 97
  54 STORE_FAST 1; pop 98
  57 JUMP_ABSOLUTE 35
  35 FOR_ITER 22; push 'b'
  38 STORE_FAST 2; pop 'b'
  41 LOAD_FAST 1; push 98
  44 LOAD_GLOBAL 2; push <built-in function ord>
  47 LOAD_FAST 2; push 'b'
  50 CALL_FUNCTION 1
ext_pop 'b'
ext_pop <built-in function ord>
push 98
  53 INPLACE_XOR; pop 98
  54 STORE_FAST 1; pop 0
  57 JUMP_ABSOLUTE 35
  35 FOR_ITER 22; pop <iterator object at 0x7f871d28ca00>
  60 POP_BLOCK
  61 LOAD_FAST 1; push 0
  64 LOAD_CONST 2; push 0
  67 COMPARE_OP 2 (==) ; pop 0
  70 POP_JUMP_IF_FALSE 77; pop True
  73 LOAD_GLOBAL 3; push True
  76 RETURN_VALUE; pop True
ext_pop 'abab'
ext_pop <function validate at 0x7f871d28ded0>
push True
  36 POP_JUMP_IF_FALSE 47; pop True
  39 LOAD_CONST 3; push 'ok'
  42 LOAD_BUILD_CLASS; pop 'ok'
ok
    43 YIELD_FROM;
  44 JUMP_FORWARD 5
  52 LOAD_CONST 5; push None
  55 RETURN_VALUE; pop None

由于是實時跟蹤,因此上面的字節碼是循環展開之后的。對于不熟悉的字節碼,比如FOR_ITER等,可以輔助參考Python dis 模塊的解釋加以理解。

Get The ETH!

回到我們最初的挑戰,使用修改后的 trace 功能去跟蹤ether_v2.pyc,結果如下:

--------------------------------------------------------------------------------
Python version: 2.7.16
Magic code: 03f30d0a
Timestamp: Fri Mar 10 21:08:20 2017
Size: None
=== pztrace pyprotect.angelic47.com ===
   0 JUMP_ABSOLUTE 2764
2764 LOAD_CONST 1; push -1
2767 LOAD_CONST 0; push None
2770 IMPORT_NAME 0; pop None
2773 STORE_FAST 2; pop <module 'marshal' (built-in)>
2776 LOAD_CONST 1; push -1
2779 LOAD_CONST 0; push None
2782 IMPORT_NAME 1; pop None
2785 STORE_FAST 3; pop <module 'sys' (built-in)>
2788 LOAD_CONST 1; push -1
2791 LOAD_CONST 0; push None
2794 IMPORT_NAME 2; pop None
...

前面一部分和之前直接使用修改過的 dis 模塊反編譯結果類似,只不過跳過了中間的垃圾代碼。其中co->co_filename的名稱是pyprotect.angelic47.com,訪問一下發現正是提供 Python 加密的網頁:

pyprotect

介紹上基本和前面的分析吻合,這里先把這個網站放一邊,繼續往下看代碼。由于運行時用戶輸入,然后返回You are too vegetable please try again!,因此直接搜索此字符串:

...
6114 LOAD_FAST 42; push 154
6117 LOAD_CONST 75; push 154
6120 COMPARE_OP 2 (==) ; pop 154
6123 POP_JUMP_IF_FALSE 6142; pop True
6126 LOAD_FAST 28; push ['You are too vegetable please try again!']
6129 LOAD_ATTR 44
6132 CALL_FUNCTION 0
ext_pop <built-in method pop of list object at 0x7f1871b1f8d0>
push 'You are too vegetable please try again!'
6135 LOAD_BUILD_CLASS; pop 'You are too vegetable please try again!'
You are too vegetable please try again!

這里在指令6123的判斷中判斷為True導致跳轉到了錯誤提示打印的分支,反向分析該字符串的來源,如下所示:

str

該加密流程將字符串本身也在內存中解密,因此我們靜態搜索無法搜到相關的字節碼邏輯,解密后內存中的字符串表如下所示:

s[0]: -1
s[1]: None
s[2]: ==--AVMPROTECTFUNCTION--==
s[3]: bce0af39a797
s[4]: 9d8e9bcfe8d3
s[5]: WARNING×WARNING×WARNING
s[6]: WARNING WARNING WARNING YOU
s[7]: Ba Ba Battle You Battle You Battle You
s[8]: (And watch out!)
s[9]: WARNING WARNING WARNING HELL
s[10]: Yeah you cannot die not at this time!
s[11]: WARNING!
s[12]: 你對我有何居心呢?
s[13]: 別隨意地進來啊
s[14]: 非常危險的氣息
s[15]: 絕對回避不能的彈幕
s[16]: 要是小看本娘的話
s[17]: 你鐵定會不停嘗到BAD END
s[18]: 你的心可是一定會
s[19]: WARNING WARNING
s[20]: 不得不警示警報的吧
s[21]: Input UR answer:
s[22]: 33c0691e3230d16fb434e5
s[23]: 8ce92dc3fe708e5b81a848
s[24]: k
s[25]: 171
s[26]: e
s[27]: 44
s[28]: y
s[29]: You are too vegetable please try again!
s[30]: Vegetable!!! Bad end!!!
s[31]: hex
s[32]: Very Very Vegetable!!! Bad end!!!
s[33]: base64
s[34]: Really Really Vegetable!!! Bad end!!!
s[35]:
s[36]: 37
s[37]: 要是下定決心就來吧
s[38]: 或許會感到興奮
s[39]: 或是激動也說不定
s[40]: 一邊感到無聊 一邊吹著口哨
s[41]: 真不錯呢 單純的旋律
s[42]: 本娘還會還會還會繼續上喔!
s[43]: 看好給本娘更加更加地躲開吧!
s[44]: 你有多少能耐呢?
s[45]: 對上本娘熱情如火的愛?
s[46]: 0
s[47]: 3
s[48]: 1
s[49]: 2
s[50]: 4
s[51]: 94
s[52]: 204
s[53]: Burning!
s[54]: 本娘好開心!
s[55]: 不得了?
s[56]: 但是, 果然很開心吧?
s[57]: *********************
s[58]: 再一次華麗的閃過吧!
s[59]: 看啊還有更多更多喔!
s[60]: 都給本娘確切地閃過!
s[61]: 255
s[62]: 本娘被打進了結局!?
s[63]: 本娘可不能輸!
s[64]: 雖然很不甘心
s[65]: 但是很開心 WARNING!!!
s[66]: 本娘警告你,這是你最后的機會
s[67]: 本娘超級地~危險、狂氣
s[68]: 而且你無法逃避我華麗的彈幕
s[69]: 28
s[70]: 32
s[71]: 12
s[72]: 16
s[73]: 8
s[74]: 24
s[75]: 20
s[76]: M
s[77]: 13
s[78]: m
s[79]: ps1q6r14s2sn8o8o1n5982rq31o33143p52337s9870snq1r0rrr9s04qr58q9n53pq187q467p0949o8803r10909p332413oo3oq914847qo0n29qo81n1s90pq0330os586rr929r34884rqo351s6660q2ss8113923n911555s62sq3p3os78039o7q024pp03r8os0083r856599095ror8pr7op04r6oq485q3s558o4n39qrpn1n43o2
s[80]: 本娘很開心!
s[81]: Good! But wrong answer, please try again!
s[82]: You are SUPER Vegetable!!! Bad end!!!
s[83]: Nice job! To get your ETH, please use your answer as private key!
s[84]: If ur interested with this Python-VirtualMachine Protect, please contact admin@angelic47.com for more technical information!
s[85]: 不得了?但是,果然很開心吧
s[86]: 沒錯,現在是狂氣時間
s[87]: 歡迎來到瘋狂的世界!
s[88]: -- END --

注意打印日志中只輸出了目前為止所運行到的代碼,也就是說對于未觸及的分支是不顯示在其中的。為了增加覆蓋率,觸達新的分支,就需要改變上面的上面執行分支:

7092 LOAD_FAST 22; push (字符串表...)
7095 LOAD_FAST 32; push 29
7098 BINARY_SUBSCR; pop 29
7099 CALL_FUNCTION 1
ext_pop 'You are too vegetable please try again!'

即需要執行到這里的時候字符串表的索引不是29,進而決定前面指令中STORE_FAST 32的結果不能是29,……根據對輸入字符串的處理,可以猜測輸入的總長度需要是64字節,驗證一下:

$ python2 ether_v2.pyc
Input UR answer: 1111111122222222333333334444444455555555666666667777777788888888
Good! But wrong answer, please try again!

確實產生了不同的輸出。繼續往前分析,可以大概梳理出判斷的邏輯,所幸關鍵代碼不是很復雜,手動還原偽代碼如下所示:

#!/usr/bin/env python2
// pwn.py
import base64
import hashlib

flag = 'bce0af39a7973d8efcb9e8d933c0691e3230d16fb434e5848a18b5e807ef3cd29ec8'
flag = flag.decode('hex')
flag = base64.b64encode(flag) + '\n'
# vOCvOaeXPY78uejZM8BpHjIw0W+0NOWEihi16AfvPNKeyA==\n

pz_list = []
for x in flag:
    pz_list.append(chr(ord(x) ^ 37))

flag = ''.join(pz_list)
# 'SjfSjD@}u|\x12\x1dP@O\x7fh\x1dgUmOlR\x15r\x0e\x15kjr`LML\x14\x13dCSukn@\\d\x18\x18/'

flag_1 = '1111111122222222333333334444444455555555666666667777777788888888'
if len(flag_1) + ord('e') < 44 + ord('y'):
    print 'You are too vegetable please try again!'
    sys.exit(1)
flag_1 = flag_1.decode('hex')


ll = []
for l1, llll in enumerate(flag_1):
    if l1 % 4 == 0:
        ll.append(ord(llll) ^ ord(flag[(l1 >> 4) + 3]) ^ 204)
    elif l1 % 4 == 1:
        ll.append(ord(llll) ^ ord(flag[(l1 >> 4) + 1]) ^ 94)
    elif l1 % 4 == 2:
        ll.append(ord(llll) ^ ord(flag[(l1 >> 4) + 0]) ^ 171)
    else:
        ll.append(ord(llll) ^ ord(flag[(l1 >> 4) + 2]) ^ 37)

print ll

ll = [ i ^ 255 for i in ll ]
print ll

def calc(ll, a, b, o=1):
    s = ll[a:b]
    if o == -1:
        s = s[::-1]
    ret = hashlib.md5(''.join([ chr(i) for i in s ]).encode('hex')).hexdigest()
    print s, ':', ret
    return ret

l1ll1lll = calc(ll, 28, 32)
lllllll1 = calc(ll, 12, 16)
ll1lllll = calc(ll, 4, 8)
ll1lll1l = calc(ll, 24, 28)
lllll1ll = calc(ll, 0, 4)
llll1lll = calc(ll, 16, 20, -1)
l1llllll = calc(ll, 8, 12)
llllll1l = calc(ll, 20, 24)

l1l11lll = l1ll1lll + lllllll1 + ll1lllll + ll1lll1l + lllll1ll + llll1lll + l1llllll + llllll1l 
print l1l11lll 

res = ''
for c in l1l11lll:
    k = c
    if c.islower():
        if c <= 'm':
            k = chr(ord(c) + 13)
        else:
            pass
    if c.isupper():
        pass
    res += k

print res

if res != 'ps1q6r14s2sn8o8o1n5982rq31o33143p52337s9870snq1r0rrr9s04qr58q9n53pq187q467p0949o8803r10909p332413oo3oq914847qo0n29qo81n1s90pq0330os586rr929r34884rqo351s6660q2ss8113923n911555s62sq3p3os78039o7q024pp03r8os0083r856599095ror8pr7op04r6oq485q3s558o4n39qrpn1n43o2':
    print 'Good! But wrong answer, please try again!'
    sys.exit(1)
# ...

關鍵邏輯就是以下幾步:

  1. 首先判斷輸入是否為64字節;
  2. 將輸入與一些魔術字進行異或處理;
  3. 將處理后的輸入分為8組,每組8字節,并對每組求md5(其中16:20的組還經過了翻轉,很調皮);
  4. 將分別求出的MD5再次進行組合;
  5. 組合后的MD5再次進行一些字符串處理,最后與魔術字ps1q6r14s2sn8o8o...進行比較。

由于每組求md5只需要8字節的求解空間,因此可以在很快的時間內進行爆破獲取到原始的正確輸入,最終的正確輸入即是題干所給的以太坊錢包私鑰。

以太坊的私鑰長度和比特幣一樣是256位的隨機數,其值需要小于 secp256k1 橢圓曲線的階 n (值為ffffffff ffffffff ffffffff fffffffe baaedce6 af48a03b bfd25e8c d0364141),可以使用 go-ethereum 或者 ethereumjs 等開源實現來生成和驗證合法的錢包公私鑰。

$ cat private.key
***********
$ geth account import private.key
INFO [10-11|20:14:07.359] Maximum peer count                       ETH=50 LES=0 total=50
INFO [10-11|20:14:07.360] Smartcard socket not found, disabling    err="stat /run/pcscd/pcscd.comm: no such file or directory"
INFO [10-11|20:14:07.438] Set global gas cap                       cap=25000000
Your new account is locked with a password. Please give a password. Do not forget this password.
Password:
Repeat password:
Address: {d0fe5288c5320bb898498fa45fa4f7c324e1e074}

d0fe5288c5320bb898498fa45fa4f7c324e1e074 正是題目所給的以太坊錢包地址,然后直接用私鑰轉賬即可。

小結

由于接觸 Python 虛擬機不多,因此在閱讀理解字節碼上頗為花費了一點時間。從加固的代碼模式來看,該加固工具應該是自己實現并維護了一個用戶態的虛擬機,名為AVMP,確實是可以比較有效地防止無腦逆向工程,提高逆向難度。只不過由于 Python 的解釋性特性使得代碼加固很難得到有效混淆,因此一般商業化的 Python 加固都是直接將深度定制的 Python 解釋器一起打包作為輸出,不兼容標準解釋器。值得一提的是,該 Python 虛擬機加固還實現了變量混淆、反調試等功能,完成度可以說相當高了;另外其作者自稱47娘 (angelic47),似乎還是個女生,真是巾幗不讓須眉啊。

后記

虛擬機加固(VMP)是當今很常見的一種代碼保護方案,不管是 X86 機器碼(匯編),安卓的 DEX 字節碼還是 Python 字節碼,其本質上是從處理器中搶活干,自身在用戶空間實現代碼執行的狀態機,有的還自己實現一套中間指令集。正如偉人所說 —— 世上本沒有 VMP,對抗得深了,自然就成了 VMP。

LINKS


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1356/