python逆向之实战分析
Python 代码先被编译为字节码后,再由Python虚拟机来执行字节码, Python的字节码是一种类似汇编指令的中间语言, 一个Python语句会对应若干字节码指令,虚拟机一条一条执行字节码指令, 从而完成程序执行。而dis模块可以帮助我们查看Python代码的字节码,它是python中内置的一个模块。
关于python的逆向主要是针对Python字节码和Python编译后的.pyc
文件进行的。在一般情况下拿到一个pyc文件可以直接使用工具uncompyle6将pyc文件直接转换成py文件。
关于uncompyle6的下载可以直接使用pip进行安装:
pip install uncompyle6
使用方法也很简单:
uncompyle6 -o OUTPUT_DIR INPUT_FILE.pyc
下面通过一个实例对基本的pyc反编译的操作进行介绍:
实例一:基本的反汇编操作
tips:不知道csdn怎么上传附件。需要附件的话可以去我自己博客的每个实例标题下的toggle中下载:
uncompyle6 -o findkey.py findkey.pyc
如上图键入命令后,ubcompyle6就反编译出了一个py文件,打开这个文件查看一下反编译后的源代码:
可以看到反编译的效果非常好,顺便解出这个题的flag,难度并不大。
lookup = [196, 153, 149, 206, 17, 221, 10, 217, 167, 18, 36, 135, 103, 61, 111, 31, 92, 152, 21, 228, 105, 191, 173, 41, 2, 245, 23, 144, 1, 246, 89, 178, 182, 119, 38, 85, 48, 226, 165, 241, 166, 214, 71, 90, 151, 3, 109, 169, 150, 224, 69, 156, 158, 57, 181, 29, 200, 37, 51, 252, 227, 93, 65, 82, 66, 80, 170, 77, 49, 177, 81, 94, 202, 107, 25, 73, 148, 98, 129, 231, 212, 14, 84, 121, 174, 171, 64, 180, 233, 74, 140, 242, 75, 104, 253, 44, 39, 87, 86, 27, 68, 22, 55, 76, 35, 248, 96, 5, 56, 20, 161, 213, 238, 220, 72, 100, 247, 8, 63, 249, 145, 243, 155, 222, 122, 32, 43, 186, 0, 102, 216, 126, 15, 42, 115, 138, 240, 147, 229, 204, 117, 223, 141, 159, 131, 232, 124, 254, 60, 116, 46, 113, 79, 16, 128, 6, 251, 40, 205, 137, 199, 83, 54, 188, 19, 184, 201, 110, 255, 26, 91, 211, 132, 160, 168, 154, 185, 183, 244, 78, 33, 123, 28, 59, 12, 210, 218, 47, 163, 215, 209, 108, 235, 237, 118, 101, 24, 234, 106, 143, 88, 9, 136, 95, 30, 193, 176, 225, 198, 197, 194, 239, 134, 162, 192, 11, 70, 58, 187, 50, 67, 236, 230, 13, 99, 190, 208, 207, 7, 53, 219, 203, 62, 114, 127, 125, 164, 179, 175, 112, 172, 250, 133, 130, 52, 189, 97, 146, 34, 157, 120, 195, 45, 4, 142, 139]
pwda = [188, 155, 11, 58, 251, 208, 204, 202, 150, 120, 206, 237, 114, 92, 126, 6, 42]
pwdb = [53, 222, 230, 35, 67, 248, 226, 216, 17, 209, 32, 2, 181, 200, 171, 60, 108]
flag=''
for i in range(0,17):
flag+=chr(lookup[i + pwdb[i]]-pwda[i]&255)
print(flag[::-1])
flag为:PCTF{PyC_Cr4ck3r}
看到这里是不是感觉python逆向就这?别急,下面慢慢上难度。
实例二:被打包成可执行程序的py逆向
为了在没有安装 Python 解释器的计算机上运行 Python 程序,诞生了一些工具将python文件打包成可执行程序。经过打包后的可执行程序如果直接使用ida进行分析的话是非常困难的Python本身是一种高级语言,其生成的二进制代码通常比C或C++更复杂,而且打包的程序包含了 Python 解释器和所有必要的库,这使得反编译的结果更加复杂,因为不仅需要理解python脚本中的原代码,还需要理解 Python 解释器的工作原理。所以碰到打包成exe的python程序首先是考虑提取还原出pyc文件再反汇编成py文件。
关于将Python程序打包成可执行文件的最常用工具通常是 PyInstaller、cx_Freeze、Py2exe。其中最常见的打包工具为PyInstaller。下面简单介绍一下PyInstaller的使用。
1.PyInstaller使用:
1.下载Pyinstaller:
pip install pyinstaller
2.使用pyinstaller打包程序:
基本命令如下:
pyinstaller 选项 Python 源文件
-h,--help | 查看该模块的帮助信息 |
---|---|
-F,-onefile | 产生单个的可执行文件 |
-D,--onedir | 产生一个目录(包含多个文件)作为可执行程序 |
-a,--ascii | 不包含 Unicode 字符集支持 |
-d,--debug | 产生 debug 版本的可执行文件 |
-w,--windowed,--noconsolc | 指定程序运行时不显示命令行窗口(仅对 Windows 有效) |
-c,--nowindowed,--console | 指定使用命令行窗口运行程序(仅对 Windows 有效) |
-o DIR,--out=DIR | 指定 spec 文件的生成目录。如果没有指定,则默认使用当前目录来生成 spec 文件 |
-p DIR,--path=DIR | 设置 Python 导入模块的路径(和设置 PYTHONPATH 环境变量的作用相似)。也可使用路径分隔符(Windows 使用分号,Linux 使用冒号)来分隔多个路径 |
-n NAME,--name=NAME | 指定项目(产生的 spec)名字。如果省略该选项,那么第一个脚本的主文件名将作为 spec 的名字 |
-i ICON.ico, -icon=ICON.ico | 指定生成后程序的图标 |
介绍完了工具接下来就产生了一个问题:如何判断分析的可执行程序是不是经过打包的python程序?
2.判断可执行程序是否是经过打包的py程序
首先前面有说过经过打包的程序包含了 Python 解释器和所有必要的库,程序的体积必然不小基本都是几兆大小。其二是直接看图标,在打包时如果没有指定打包后exe的图标的话,默认打包后的exe图标长下面这个样子:
如果看到这个图标基本就可以确定这其实是打包后的python程序,当然如果指定了生成后的程序图标这个办法就白瞎了。
最后还可以将exe丢到ida中然后shift+f12查看程序的字符串,到包的程序会有很多python相关的字符串。
根据这三点基本就可以判断出是否是打包的python程序了。
下面还是通过实例来讲解打包后的exe如何逆向。
3.相关逆向办法:
看到实例的图标就可以确定这是由PyInstaller打包的python程序了,为了验证这个说法可以拖进ida查看其字符串可以看到确实有很多python相关的字符串:
下面介绍一下如何从exe中提取出pyc文件,这里需要用到的工具为pyinstxtractor,github链接为:
将其中的pyinstxtractor.py下载下来,键入命令:
python pyinstxtractor.py login.exe
运行后显示提取成功,cmd内容如下:
D:\\Desktop\\keshan\\tmp>python pyinstxtractor.py login.exe
[+] Processing login.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 2.7
[+] Length of package: 3701450 bytes
[+] Found 18 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: login.pyc
[!] Warning: This script is running in a different Python version than the one used to build the executable.
[!] Please run this script in Python 2.7 to prevent extraction errors during unmarshalling
[!] Skipping pyz extraction
[+] Successfully extracted pyinstaller archive: login.exe
You can now use a python decompiler on the pyc files within the extracted directory
上面的回显显示提取出来的东西放在了文件名夹加exetracted的目录下
进入login.exe_extracted文件夹
在这个文件夹中可以看到其中一个pyc文件就是提取出来的login.pyc,得到pyc文件后继续使用uncompyle6反编译成py文件:
D:\\Desktop\\keshan\\tmp\\login.exe_extracted>uncompyle6 -o login.py ./login.pyc
.\\login.pyc --
# Successfully decompiled file
打开反汇编后的代码:
可以看到到这里代码就成功反编译出来了,这里顺便贴一下exp:
import base64
rflag=base64.b64decode('YWtmYHxgaGhjWHRzcmN+eg==')
flag=''
for i in range(len(rflag)):
flag+=chr(rflag[i]^7)
print(flag)
OK,又搞定一个下面继续加大难度。
实例三:
在前面的两个实例中我们都是使用工具将pyc直接反汇编成py,但设想一下有没有什么办法可以让uncompyle6在pyc→pc的过程中反编译失败呢?
在这个实例中就会出现这个问题,使用uncompyle6先尝试对其进行反编译:
D:\\Desktop\\keshan\\tmp>uncompyle6 -o BabyMaze.py BabyMaze.pyc
-- Stacks of completed symbols:
START ::= |- stmts .
_come_froms ::= \\e__come_froms . COME_FROM
_come_froms ::= \\e__come_froms . COME_FROM_LOOP
while1stmt ::= \\e__come_froms . l_stmts COME_FROM JUMP_BACK COME_FROM_LOOP
.
. //省略
.
whilestmt38 ::= \\e__come_froms . testexpr returns POP_BLOCK
# file BabyMaze.pyc
# --- This code section failed: ---
L. 1 0 JUMP_ABSOLUTE 4 'to 4'
2 JUMP_ABSOLUTE 6 'to 6'
4 JUMP_BACK 2 'to 2'
6_0 COLLECTION_START 0 'CONST_LIST'
6 ADD_VALUE 1 1
8 ADD_VALUE 1 1
10 ADD_VALUE 1 1
12 ADD_VALUE 1 1
.
. //省略
.
L. 33 2014 COMPARE_OP ==
2016_2018 POP_JUMP_IF_FALSE 2020 'to 2020'
2020_0 COME_FROM 2016 '2016'
2020 LOAD_NAME main
2022 CALL_FUNCTION_0 0 ''
2024 POP_TOP
Parse error at or near `None' instruction at offset -1
BabyMaze.pyc --
# decompile failed
看到命令行的回显最后一行显示反编译失败,然后上面报出了一大堆数据。根据现有的手段到了这里基本就是束手无策了。所以我们需要get一些新知识。
1.dis模块介绍:
在原理篇中有介绍到:Python 代码先被编译为字节码后,再由Python虚拟机来执行字节码, Python的字节码是一种类似汇编指令的中间语言, 一个Python语句会对应若干字节码指令,虚拟机一条一条执行字节码指令, 从而完成程序执行。
而dis模块可以帮助我们查看Python代码的字节码,它是python中内置的一个模块。下面看一下关于这个模块的简单示例:
import dis
def add(a,b):
return a+b
dis.dis(add)
上面这段代码首先是导入了dis模块,然后随便定义了一个函数,最后的输出dis.dis(函数名)表示输出对应函数的字节码,然后看一下输出:
这里我们可以手动将输出分成三列来看:
- 第一列表示当前字节码在源代码中的行号为第四行
- 第二列是字节码的偏移量和对应的字节码,0表示当前字节码,LOAD_FAST是 Python 虚拟机要执行的操作,(LOAD_FAST表示将一个局部变量加载到栈顶)
- 第三列是字节码指令的参数。这是字节码指令的操作数。第一个
LOAD_FAST
指令的参数是0
,表示它将第 0 个局部变量(即a
)加载到栈顶。
接着我们换个视角加深对字节码的理解,在原来的代码上加一行代码:
import dis
def add(a,b):
return a+b
dis.dis(add)
print(list(add.__code__.co_code))
最后这段代码将打印出 add
函数的字节码指令的二进制表示形式的整数列表。每个整数对应一个字节。查看一下输出情况:
下面这个列表的[124,0]就是上面第一行的0 LOAD_FAST 0 (a)
,所以124也就是字节码指令(124对应的字节码为LOAD_FAST
),该字节码指令所在的列表偏移为0。第二个元素0就是字节码指令的参数。上一篇写的原理分析有讲过PyCodeObject
对象中的co_varnames
保存了在当前作用域的变量,以字符串的形式保存了变量名,而现在看到的这个0其实就表示co_varnames
下标0处的变量。这样就相对好理解字节码相关的知识点了。最后还有一个问题就是如何得知字节码指令对应的数字是多少?
python源码中的opcode.h
定义了 Python 的字节码指令集,可以去官网直接查看定义:
-
tips:版本之间关于字节码的定义会有不同,我这里给的是v3.8.10版本的定义
#define POP_TOP 1 #define ROT_TWO 2 #define ROT_THREE 3 #define DUP_TOP 4 #define DUP_TOP_TWO 5 #define ROT_FOUR 6 #define NOP 9 #define UNARY_POSITIVE 10 #define UNARY_NEGATIVE 11 #define UNARY_NOT 12 #define UNARY_INVERT 15 #define BINARY_MATRIX_MULTIPLY 16 #define INPLACE_MATRIX_MULTIPLY 17 #define BINARY_POWER 19 #define BINARY_MULTIPLY 20 #define BINARY_MODULO 22 #define BINARY_ADD 23 #define BINARY_SUBTRACT 24 #define BINARY_SUBSCR 25 #define BINARY_FLOOR_DIVIDE 26 #define BINARY_TRUE_DIVIDE 27 #define INPLACE_FLOOR_DIVIDE 28 #define INPLACE_TRUE_DIVIDE 29 #define GET_AITER 50 #define GET_ANEXT 51 #define BEFORE_ASYNC_WITH 52 #define BEGIN_FINALLY 53 #define END_ASYNC_FOR 54 #define INPLACE_ADD 55 #define INPLACE_SUBTRACT 56 #define INPLACE_MULTIPLY 57 #define INPLACE_MODULO 59 #define STORE_SUBSCR 60 #define DELETE_SUBSCR 61 #define BINARY_LSHIFT 62 #define BINARY_RSHIFT 63 #define BINARY_AND 64 #define BINARY_XOR 65 #define BINARY_OR 66 #define INPLACE_POWER 67 #define GET_ITER 68 #define GET_YIELD_FROM_ITER 69 #define PRINT_EXPR 70 #define LOAD_BUILD_CLASS 71 #define YIELD_FROM 72 #define GET_AWAITABLE 73 #define INPLACE_LSHIFT 75 #define INPLACE_RSHIFT 76 #define INPLACE_AND 77 #define INPLACE_XOR 78 #define INPLACE_OR 79 #define WITH_CLEANUP_START 81 #define WITH_CLEANUP_FINISH 82 #define RETURN_VALUE 83 #define IMPORT_STAR 84 #define SETUP_ANNOTATIONS 85 #define YIELD_VALUE 86 #define POP_BLOCK 87 #define END_FINALLY 88 #define POP_EXCEPT 89 #define HAVE_ARGUMENT 90 #define STORE_NAME 90 #define DELETE_NAME 91 #define UNPACK_SEQUENCE 92 #define FOR_ITER 93 #define UNPACK_EX 94 #define STORE_ATTR 95 #define DELETE_ATTR 96 #define STORE_GLOBAL 97 #define DELETE_GLOBAL 98 #define LOAD_CONST 100 #define LOAD_NAME 101 #define BUILD_TUPLE 102 #define BUILD_LIST 103 #define BUILD_SET 104 #define BUILD_MAP 105 #define LOAD_ATTR 106 #define COMPARE_OP 107 #define IMPORT_NAME 108 #define IMPORT_FROM 109 #define JUMP_FORWARD 110 #define JUMP_IF_FALSE_OR_POP 111 #define JUMP_IF_TRUE_OR_POP 112 #define JUMP_ABSOLUTE 113 #define POP_JUMP_IF_FALSE 114 #define POP_JUMP_IF_TRUE 115 #define LOAD_GLOBAL 116 #define SETUP_FINALLY 122 #define LOAD_FAST 124 #define STORE_FAST 125 #define DELETE_FAST 126 #define RAISE_VARARGS 130 #define CALL_FUNCTION 131 #define MAKE_FUNCTION 132 #define BUILD_SLICE 133 #define LOAD_CLOSURE 135 #define LOAD_DEREF 136 #define STORE_DEREF 137 #define DELETE_DEREF 138 #define CALL_FUNCTION_KW 141 #define CALL_FUNCTION_EX 142 #define SETUP_WITH 143 #define EXTENDED_ARG 144 #define LIST_APPEND 145 #define SET_ADD 146 #define MAP_ADD 147 #define LOAD_CLASSDEREF 148 #define BUILD_LIST_UNPACK 149 #define BUILD_MAP_UNPACK 150 #define BUILD_MAP_UNPACK_WITH_CALL 151 #define BUILD_TUPLE_UNPACK 152 #define BUILD_SET_UNPACK 153 #define SETUP_ASYNC_WITH 154 #define FORMAT_VALUE 155 #define BUILD_CONST_KEY_MAP 156 #define BUILD_STRING 157 #define BUILD_TUPLE_UNPACK_WITH_CALL 158 #define LOAD_METHOD 160 #define CALL_METHOD 161 #define CALL_FINALLY 162 #define POP_FINALLY 163
了解完dis模块,我们就可以发现前面在反编译实例时出现的报错好像有点眼熟,这些返回的信息不就是字节码嘛:
到这里起码不是完全云里雾里了,接下来介绍一下python的花指令。
2.python的花指令:
花指令指的是插入到Python字节码中的额外、无意义或误导性的指令,用于干扰或误导反编译工具和分析者。还是用前面第例子:
def add(x, y):
return x + y
前面有说到使用dis模块查看这个函数的字节码为:
4 0 LOAD_FAST 0 (x)
2 LOAD_FAST 1 (y)
4 BINARY_ADD
6 RETURN_VALUE
根据上面这些字节码我们尝试插入一些花指令,这里可以考虑插入一些无效的跳转和无意义的操作。在Python字节码中,一个常见的花指令是使用**POP_TOP
来移除栈顶项(这不会影响函数的实际行为)和JUMP_FORWARD
**来进行无效的跳转。
4 0 LOAD_FAST 0 (x)
2 LOAD_FAST 1 (y)
4 JUMP_FORWARD 2 (to 8)
6 POP_TOP
>> 8 BINARY_ADD
10 RETURN_VALUE
在这里,**JUMP_FORWARD
指令实际上跳过了POP_TOP
**指令,这使得实际上没有执行。但是,当尝试反编译这段字节码时,这些额外的指令可能会导致反编译工具产生更为复杂或难以理解的代码。
补充知识:
POP_TOP:
- 功能:从堆栈顶部移除一个项并丢弃
举个例子,考虑以下Python代码:
del x
对应的字节码大致如下:
0 LOAD_NAME 0 (x) 2 POP_TOP
JUMP_FORWARD:
- 功能:向前跳过指定数量的字节码。
举个例子,考虑以下Python代码:
if x == 0: pass print("Hello")
对应的字节码大致如下:
scssCopy code 0 LOAD_NAME 0 (x) 2 LOAD_CONST 0 (0) 4 COMPARE_OP 2 (==) 6 POP_JUMP_IF_FALSE 12 8 JUMP_FORWARD 2 (to 12) 10 LOAD_CONST 1 ('Hello') 12 PRINT_ITEM 14 PRINT_NEWLINE
在上面的例子中,
POP_JUMP_IF_FALSE
是根据x == 0
的结果进行跳转的另一个指令。如果条件为False
,它会跳到标签12。JUMP_FORWARD
指令确保,如果条件为True
,解释器会跳过接下来的指令并直接转到标签12。
接着再看下一个花指令的例子:
def example(x):
if x > 10:
return True
return False
正常的字节码如下:
2 0 LOAD_FAST 0 (x)
2 LOAD_CONST 1 (10)
4 COMPARE_OP 4 (>)
6 POP_JUMP_IF_FALSE 12
3 8 LOAD_CONST 2 (True)
10 RETURN_VALUE
4 >> 12 LOAD_CONST 3 (False)
14 RETURN_VALUE
现在,我们插入一些花指令来干扰反编译:
假设我们在**COMPARE_OP
后面插入一些额外的指令,包括一个无效的JUMP_FORWARD
和一个不会被执行的LOAD_CONST
**,如下:
2 0 LOAD_FAST 0 (x)
2 LOAD_CONST 1 (10)
4 COMPARE_OP 4 (>)
6 JUMP_FORWARD 4 (to 10)
8 LOAD_CONST 4 (42) # 花指令,不会被执行
10 POP_JUMP_IF_FALSE 16
3 12 LOAD_CONST 2 (True)
14 RETURN_VALUE
4 >> 16 LOAD_CONST 3 (False)
18 RETURN_VALUE
在这里,我们插入了一个**JUMP_FORWARD
指令来跳过一个LOAD_CONST
指令,该指令尝试加载一个常数值42
**(这只是一个随便给的一个数)。
因为这个**LOAD_CONST
指令实际上被JUMP_FORWARD
跳过了,所以它从未被执行。然而,对于某些反编译工具来说,这可能会造成困惑,因为它们可能会期望每个LOAD_CONST
后面都有一个相关的操作(例如,一个STORE_FAST
或BINARY_ADD
)。当工具看到这个“悬挂”的LOAD_CONST
**时,它可能不知道如何正确地处理,于是就会返回报错。
3.实例三反编译报错原因解决:
了解了这些知识点之后再头看实例三的报错:
第一条到第三条很明显就是花指令:
0 JUMP_ABSOLUTE 4 'to 4'
- 这条指令跳转到标号 4 的位置。2 JUMP_ABSOLUTE 6 'to 6'
- 如果上述跳转不执行,这条指令将跳转到标号 6 的位置。4 JUMP_BACK 2 'to 2'
- 这条指令跳回标号 2 的位置。
结合以上三条指令,代码会在 2 和 4 之间无限循环后面的代码就永远都不会被执行到。接下里就是将这三条指令删除掉,具体步骤如下:
上面的指令码中有贴到#define JUMP_ABSOLUTE 113
113转换为十六进制为0x71,也就是说这三条指令转换为二进制就是71 04 71 06 71 02
,将实例文件拖到010editor中。直接搜索二进制数据:
然后将这段数据直接删掉。删完之后还没完,co_code中有一个**ob_size
** 成员里面保存了co_code的长度,如果co_code的实际长度与ob_size里记录的长度不匹配的话反编译时依然会报错。接下来就是找到ob_size所在的位置将其进行修改,在python3.8版本里ob_size会以**s
** 或 t
的类型标志开始接下来的几个字节会是一个整数,代表co_code的长度。
如上图在这个实例中ob_size的标志为s,后面的EE 07就是代码长度,还有不要忘记这是小端存储,所以最终的代码长度为7EE。除了这个办法还可以利用marshal模块输出co_code的长度。
marshal
模块提供了读写 Python 的内部值到字节流的能力。该模块主要用于支持.pyc
文件的读写。marshal常用的方法为:
marshal.dumps(value):
将值序列化为一个字节字符串。
data = marshal.dumps([1, 2, 3, 4])
marshal.load(file):
从一个已打开的文件对象中读取一个值。
with open('data.mar', 'rb') as f: mylist = marshal.load(f) print(mylist) # Output: [1, 2, 3, 4]
我们可以利用marshal模块编写一个简单的脚本来输出实例pyc的co_code的长度:
import marshal
f = open('BabyMaze.pyc','rb')
f.read(16)
code = marshal.load(f)
print(len(code.co_code))
简单解析一下这个脚本首先是导入marshal模块,然后加载目标实例pyc到f中,read函数实际作用是跳过前16个字节因为marshal读取的是字节码,所以要跳过前面的魔数等。之后使用marshal的load函数读取实例pyc的co_code,再对其长度以十六进制的形式输出。
tips:为什么跳过16字节:
上一篇原理部分有讲过从python3.7版本后魔数部分增长到了16个字节,我们又是如何知道这个pyc文件的版本的呢,其实很简单可以利用010editor查看pyc的版本号:
版本号为3413,还是去看上一篇里面有每个版本与数字的对应关系就可以了,3413对应的版本为*Python 3.8b4。*因此知道了我们需要跳过16个字节的魔数定义。
运行脚本后输出的长度为:7EE
知道了长度注意改成小端存储形式,还是再010editor直接搜:
这样也能找到co_code的长度,删完了三条花指令(6个字节),将原长度修改为7ee-6=7e8,将修改保存后再次尝试使用uncompyle6反编译,就可以看到反汇编已经成功了。
D:\\Desktop\\keshan\\tmp>uncompyle6 -o BabyMaze.py BabyMaze.pyc
BabyMaze.pyc --
# Successfully decompiled file
这个题目是一个谜宫题,关于迷宫的题目还要介绍BFS和DFS,需要介绍的东西不少,所以包括这个题的解法以及我遇到的迷宫题从易到难将会单独开一篇文章来讲。以上就是这篇文章的全部内容
更多推荐
所有评论(0)