作者: evilpan
原文鏈接: https://evilpan.com/2020/10/11/protected-python/
前言
某天在群里看到一個大佬看到另一個大佬的帖子而發的帖子的截圖,如下所示:

不過當我看到的時候已經過去了大概720小時?? 在查看該以太幣交易記錄的時候,發現在充值之后十幾小時就被提走了,可能是其他大佬也可能是作者自己。雖然沒錢可偷,但幸運的是 pyc 的下載地址依然有效,所以我就下載下來研究了一下。
初步分析
首先在專用的實驗虛擬機里運行一下,程序執行沒有問題:
$ python2 ether_v2.pyc
Input UR answer: whatever
You are too vegetable please try again!
然后看看文件里是否有對應的字符串信息:
$ grep vegetable ether_v2.pyc
很好,屁都沒有,看來字符串也混淆了。
目前市面上有一些開源的 pyc 還原工具,比如:
- uncompyle6
- pycdc
- ...
但是看作者的自信,應該是有信心可以抗住的,事實證明也確實可以。
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_code、 co.co_names、 co.co_consts等多個地方都出現了下標溢出的IndexError。不管是什么原因,我們先把這些地方 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,則oparg占2個字節;通常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
其中hasconst、hashname都是定義在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 的追蹤還需要解決幾個問題:
- LLTRACE 的啟用需要在當前棧幀上定義全局變量
__lltrace__; - LLTRACE 輸出的字節碼過于簡略,缺乏可讀性;
- 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 加密的網頁:

介紹上基本和前面的分析吻合,這里先把這個網站放一邊,繼續往下看代碼。由于運行時用戶輸入,然后返回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導致跳轉到了錯誤提示打印的分支,反向分析該字符串的來源,如下所示:

該加密流程將字符串本身也在內存中解密,因此我們靜態搜索無法搜到相關的字節碼邏輯,解密后內存中的字符串表如下所示:
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)
# ...
關鍵邏輯就是以下幾步:
- 首先判斷輸入是否為64字節;
- 將輸入與一些魔術字進行異或處理;
- 將處理后的輸入分為8組,每組8字節,并對每組求md5(其中16:20的組還經過了翻轉,很調皮);
- 將分別求出的MD5再次進行組合;
- 組合后的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
- https://gist.github.com/stecman/3751ac494795164efa82a683130cabe5
- https://0xec.blogspot.com/2017/03/hacking-cpython-virtual-machine-to.html
- https://rushter.com/blog/python-bytecode-patch/
- https://towardsdatascience.com/understanding-python-bytecode-e7edaae8734d
- https://opensource.com/article/18/4/introduction-python-bytecode
- https://bits.theorem.co/protecting-a-python-codebase-part-3/
- https://etherscan.io/address/0xd0fe5288c5320bb898498fa45fa4f7c324e1e074
- https://www.reddit.com/r/ethereum/comments/3gbhui/how_do_i_generate_an_eth_private_key/
- https://walletgenerator.net/?culture=zh¤cy=bitcoin#
- 橢圓曲線加密與NSA后門考古
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1356/
暫無評論