我们常常发现, 自己用PyInstaller
等库打包的exe被别人反编译。而源代码在exe文件中是以字节码形式存储的。掌握了字节码的加密技巧, 就可以防止源代码的反编译。
本文使用的字节码加壳、脱壳工具源代码:gitcode.net/qfcy_/pyc-zipper
1.字节码是什么
PyInstaller, py2exe等库会把编译生成的字节码打包进exe中。掌握字节码(bytecode)的知识, 对于PyInstaller打包exe的反编译是十分有用的。
在Python中, 字节码是一种独特的数据类型, 常存在于用python写成的函数中。先放示例:
>>> import dis
>>> def f(x):print('hello',x)
>>> type(f.__code__)
<class 'code'>
>>> f.__code__.co_code
b't\x00d\x01|\x00\x83\x02\x01\x00d\x00S\x00'
>>> dis.dis(f)
1 0 LOAD_GLOBAL 0 (print)
2 LOAD_CONST 1 ('hello')
4 LOAD_FAST 0 (x)
6 CALL_FUNCTION 2
8 POP_TOP
10 LOAD_CONST 0 (None)
12 RETURN_VALUE
>>>
上述示例中f.__code__
就是bytecode对象, f.__code__.co_code
就是字节码的二进制部分, 通过dis
模块可以反编译、分析这些字节码。
关于字节码的基础知识, 参见作者的前一篇文章: Python pyc文件 bytecode 字节码解析与插入、编辑
2.包装字节码
在python中, bytecode对象的属性是不可修改的。如:
>>> def f():pass
>>> f.__code__.co_code = b''
Traceback (most recent call last):
... ...
AttributeError: readonly attribute
为了使bytecode对象更易用, 作者编写了Code
类, 用于包装 (wrap)字节码对象, 使字节码对象变得更易操作。
# 读者可暂时跳过本代码, 进入下一节
import sys
try:
from importlib._bootstrap_external import MAGIC_NUMBER
except ImportError:
from importlib._bootstrap import MAGIC_NUMBER
from types import CodeType, FunctionType
from collections import OrderedDict
import marshal,io,builtins
import dis
import pickle
_default_code=compile('','','exec')
_is_py38=hasattr(_default_code, 'co_posonlyargcount') # 是否为3.8及以上版本
_is_py310=hasattr(_default_code, 'co_linetable') # 是否为3.10及以上版本
_is_py311=hasattr(_default_code, 'co_exceptiontable') # 是否为3.11及以上版本
class Code:
"""
# 用于doctest
>>> def f():print("Hello")
>>> c=Code.fromfunc(f)
>>> c.co_consts
(None, 'Hello')
>>> c.co_consts=(None, 'Hello World!')
>>> c.exec()
Hello World!
>>>
>>> import os,pickle
>>> temp=os.getenv('temp')
>>> with open(os.path.join(temp,"temp.pkl"),'wb') as f:
... pickle.dump(c,f)
...
>>>
>>> f=open(os.path.join(temp,"temp.pkl"),'rb')
>>> pickle.load(f).to_func()()
Hello World!
>>>
>>> c.to_pycfile(os.path.join(temp,"temppyc.pyc"))
>>> sys.path.append(temp)
>>> import temppyc
Hello World!
>>> Code.from_pycfile(os.path.join(temp,"temppyc.pyc")).exec()
Hello World!
"""
# 关于CodeType:
# 初始化参数:
# code(argcount, kwonlyargcount, nlocals, stacksize, flags, codestring,
# constants, names, varnames, filename, name, firstlineno,
# lnotab[, freevars[, cellvars]])
# 初始化参数 (Python 3.11+):
# code(argcount, posonlyargcount, kwonlyargcount, nlocals, stacksize, flags, codestring,
# constants, names, varnames, filename, name, qualname, firstlineno, linetable,
# exceptiontable, freevars=(), cellvars=(), /)
# Python 3.10中没有qualname和exceptiontable这两个参数
# Python 3.8增加了属性co_posonlyargcount,而Python 3.11的字节码有较大的改动
if _is_py310: # Python 3.10及以上版本
_default_args=OrderedDict(
[('co_argcount',0),
('co_posonlyargcount',0),
('co_kwonlyargcount',0),
('co_nlocals',0),
('co_stacksize',1),
# 如果是函数中, 则为OPTIMIZED, NEWLOCALS, NOFREE; Python 3.11及以上为OPTIMIZED, NEWLOCALS
('co_flags',0), # 无flag
('co_code',_default_code.co_code),# 具体因Python版本而异
('co_consts',(None,)),
('co_names',()),
('co_varnames',()),
('co_filename',''),
('co_name',''),
('co_qualname',''), # 3.11+
('co_firstlineno',1),
('co_linetable',b''),
('co_exceptiontable',b''), # 3.11+
('co_freevars',()),
('co_cellvars',())
])
if not _is_py311: # Python 3.10
_default_args["co_flags"]=64 # NOFREE
del _default_args['co_qualname']
del _default_args['co_exceptiontable']
else:
# 按顺序的字典
_default_args=OrderedDict(
[('co_argcount',0),
('co_kwonlyargcount',0),
('co_nlocals',0),
('co_stacksize',1),
# 如果是函数中, 则为OPTIMIZED, NEWLOCALS, NOFREE
('co_flags',64), # NOFREE
('co_code',b'd\x00S\x00'),# LOAD_CONST 0 (None)
# RETURN_VALUE
('co_consts',(None,)),
('co_names',()),
('co_varnames',()),
('co_filename',''),
('co_name',''),
('co_firstlineno',1),
('co_lnotab',b''),
('co_freevars',()),
('co_cellvars',())
])
# 3.8~3.9
if _is_py38:
_default_args['co_posonlyargcount']=0
_default_args.move_to_end('co_posonlyargcount', last=False)
_default_args.move_to_end('co_argcount', last=False)
_arg_types={key:type(value) for key,value in _default_args.items()}
def __init__(self,code=None):
super().__setattr__('_args',self._default_args.copy())
if code is not None:
if isinstance(code,Code):
self._args = code._args.copy()
self._update_code()
else:
self._code=code
for key in self._args.keys():
self._args[key]=getattr(code,key)
else:
self._update_code()
def _update_code(self):
self._code=CodeType(*self._args.values())
def exec(self,globals_=None,locals_=None):
default={"__builtins__":__builtins__,"__doc__":None,
"__loader__":__loader__,"__name__":"__main__"}
globals_ = globals_ or default
if not locals_:locals_ = default.copy()
return exec(self._code,globals_,locals_)
def eval(self,globals_=None,locals_=None):
return eval(self._code,globals_,locals_)
def __getattr__(self,name):
_args=object.__getattribute__(self,'_args')
if name in _args:
return _args[name]
else:
if hasattr(self._code,name) and not name.startswith("__"):
return getattr(self._code,name) # self._code的其他属性
else:
# 调用super()耗时较大, 因此改用object
return object.__getattribute__(self,name)
def __setattr__(self,name,value):
if name not in self._args:
return object.__setattr__(self,name,value)
if not isinstance(value,self._arg_types[name]):
raise TypeError("Illegal attribute %s" % name)
self._args[name]=value
self._update_code()
def __dir__(self):
extra=[attr for attr in dir(self._code) \
if attr not in self._args and not attr.startswith("__")] # self._code的其他属性
return object.__dir__(self) + list(self._args.keys()) + extra
# 用于pickle模块保存状态
def __getstate__(self):
return self._args
def __setstate__(self,state):
super().__setattr__('_args',self._default_args.copy())
for key in state: # 删除来自新版中不兼容的项
if key not in self._args:
del state[key]
self._args.update(state)
self._update_code()
@classmethod
def fromfunc(cls,function):
c=function.__code__
return cls(c)
@classmethod
def fromstring(cls,string,mode='exec',filename=''):
return cls(compile(string,filename,mode))
def to_code(self):
return self._code
def to_func(self,globals_=None,name=''):
if globals_ is None:
# 默认的全局命名空间包含内置函数
globals_={"__builtins__":builtins}
return FunctionType(self._code,globals_,name)
def to_pycfile(self,filename):
with open(filename,'wb') as f:
f.write(MAGIC_NUMBER)
if sys.version_info.minor>=7:
f.write(b'\x00'*12)
else:
f.write(b'\x00'*8)
marshal.dump(self._code,f)
@classmethod
def from_pycfile(cls,filename):
with open(filename,'rb') as f:
data=f.read()
header = 16 if data[16]==227 else 12
data=data[header:]
return cls(marshal.loads(data))
@classmethod
def from_file(cls,filename):
if filename.lower().endswith('.pyc'):
return Code.from_pycfile(filename)
else: # .py或pyw文件 (默认utf-8编码)
with open(filename,'rb') as f:
data=f.read().decode('utf-8')
return Code(compile(data,filename,'exec'))
def pickle(self,filename):
with open(filename,'wb') as f:
pickle.dump(self,f)
def info(self):
dis.show_code(self._code)
def dis(self,*args,**kw):
dis.dis(self._code,*args,**kw)
3.压缩字节码
压缩字节码的原理是构造一个新的bytecode, 也就是压缩壳, 然后把原先的bytecode用marshal.dumps()
转为bytes
类型, 然后压缩bytes
, 再放入压缩壳中。类似EXE文件的加壳。
程序运行时, 先解压这个bytes
数据, 再使用marshal.loads()
重新转换为bytecode, 并执行。
import sys,marshal,zlib
try:
from importlib._bootstrap_external import MAGIC_NUMBER
except ImportError:
from importlib._bootstrap import MAGIC_NUMBER
def dump_to_pyc(pycfilename,code,pycheader=None):
c=Code() # 构造一个压缩壳
# 反汇编的co_code
##2 0 LOAD_CONST 0 (455)
## 2 LOAD_CONST 1 (None)
## 4 IMPORT_NAME 0 (zlib)
## 6 STORE_NAME 0 (zlib)
## 8 LOAD_CONST 0 (455)
## 10 LOAD_CONST 1 (None)
## 12 IMPORT_NAME 1 (marshal)
## 14 STORE_NAME 1 (marshal)
##
##3 16 LOAD_NAME 2 (exec)
## 18 LOAD_NAME 1 (marshal)
## 20 LOAD_METHOD 3 (loads)
## 22 LOAD_NAME 0 (zlib)
## 24 LOAD_METHOD 4 (decompress)
## 26 LOAD_CONST 2 (数据)
## 28 CALL_METHOD 1
## 30 CALL_METHOD 1
## 32 CALL_FUNCTION 1
## 34 RETURN_VALUE
c.co_code=b'''d\x00d\x01l\x00Z\x00d\x00d\x01l\x01Z\x01e\x02\
e\x01\xa0\x03e\x00\xa0\x04d\x02\xa1\x01\xa1\x01\x83\x01\x01\x00d\x01S\x00''' # 仅支持Python 3.7及以上, 因为不同版本Python使用的字节码有微小的差别
c.co_names=('zlib', 'marshal', 'exec', 'loads', 'decompress')
#也可换成bz2,lzma等其他压缩模块
c.co_consts=(0, None,zlib.compress(marshal.dumps(code._code),
zlib.Z_BEST_COMPRESSION))
c.co_flags=64 # NOFREE
c.co_stacksize=6
with open(pycfilename,'wb') as f:
# 写入 pyc 文件头
if pycheader is None:
# 自动生成 pyc 文件头
if sys.version_info.minor >= 7:
pycheader=MAGIC_NUMBER+b'\x00'*12
else:
pycheader=MAGIC_NUMBER+b'\x00'*8
f.write(pycheader)
# 写入bytecode
marshal.dump(c._code,f)
if len(sys.argv) == 1:
print('Usage: %s [filename]' % sys.argv[0])
for file in sys.argv[1:]:
data=open(file,'rb').read()
if data[16]==0xe3: #标识pyc文件头的结束, marshal数据的开始
old_header=data[:16];data=data[16:]
else:
old_header=data[:12];data=data[12:]
co = Code(marshal.loads(data))
dump_to_pyc(file,co,pycheader=old_header)
print('Processed:',file)
4.加壳字节码(方法一):修改co_code
加壳字节码与压缩不同, 加壳字节码会阻止字节码被uncompyle6
之类的反编译器反编译。
这种方法在每个bytecode的co_code
末尾加上多余的S\x00
。
但co_consts
里依然有bytecode, 而这些bytecode又有co_consts
, 所以需要递归处理。
# pyc文件压缩、保护工具
import sys,marshal
from inspect import iscode
try:
from importlib._bootstrap_external import MAGIC_NUMBER
except ImportError:
from importlib._bootstrap import MAGIC_NUMBER
def process_code(co):
# 在`co_code`末尾加上多余的`S\x00'。
co.co_lnotab = b''
co.co_code += b'S\x00'
co.co_filename = ''
#co.co_name = ''
co_consts = co.co_consts
# 递归处理
for i in range(len(co_consts)):
obj = co_consts[i]
if iscode(obj):
data=process_code(Code(obj))
co_consts = co_consts[:i] + (data._code,) + co_consts[i+1:]
co.co_consts = co_consts
return co
def dump_to_pyc(pycfilename,code,pycheader=None):
# 制作pyc文件
with open(pycfilename,'wb') as f:
# 写入 pyc 文件头
if pycheader is None:
# 自动生成 pyc 文件头
if sys.version_info.minor >= 7:
pycheader=MAGIC_NUMBER+b'\x00'*12
else:
pycheader=MAGIC_NUMBER+b'\x00'*8
f.write(pycheader)
# 写入bytecode
marshal.dump(code._code,f)
for file in sys.argv[1:]:
data=open(file,'rb').read()
if data[16]==0xe3:
old_header=data[:16];data=data[16:]
else:old_header=data[:12];data=data[12:]
co = Code(marshal.loads(data))
process_code(co)
dump_to_pyc(file,co,pycheader=old_header)
print('Processed:',file)
尝试反编译加壳后的pyc文件, 意外发现:
# --- This code section failed: ---
L. 2 0 LOAD_CONST 0
2 LOAD_CONST None
4 IMPORT_NAME sys
6 STORE_NAME sys
8 LOAD_CONST 0
10 LOAD_CONST None
12 IMPORT_NAME marshal
14 STORE_NAME marshal
... ...
294 LOAD_CONST None
296 RETURN_VALUE
298 RETURN_VALUE
-1 RETURN_LAST
Parse error at or near `None' instruction at offset -1
说明加壳字节码, 的确能阻止字节码被uncompyle6
等反编译器反编译。
5.加壳字节码(方法二):混淆变量名
还有一种更加彻底的方法, 也就是将字节码中的变量名改成其他名称, 甚至不符合Python语法的变量名都可以。
这样可以使反编译后的代码难以理解。如果不符合Python语法, 甚至根本无法反编译。
co_varnames
属性包含了该code使用的本地变量的名称。这里将其中的变量名全部修改为"0", “1”, “2”, …。
基于方法一的代码, 将process_code函数改成下面这样:
def process_code(co):
co.co_lnotab = b''
co.co_code += b'S\x00' # 增加一个无用的RETURN_VALUE指令,用于干扰反编译器的解析
co.co_filename = ''
co_consts = co.co_consts
# 无需加上co.co_posonlyargcount的值 (Python 3.8+中)
argcount = co.co_argcount+co.co_kwonlyargcount # 计算参数个数
# 修改、混淆本地变量的名称 (参数名不需要混淆)
co.co_varnames = co.co_varnames[:argcount] + \
tuple(str(i) for i in range(argcount,len(co.co_varnames)))
# 递归处理自身包含的字节码
for i in range(len(co_consts)):
obj = co_consts[i]
if iscode(obj):
data=process_code(Code(obj))
co_consts = co_consts[:i] + (data._code,) + co_consts[i+1:]
co.co_consts = co_consts
return co
6.解压缩, 脱壳字节码
解压缩, 脱壳字节码, 也就是解压原先压缩壳中的bytes
数据, 再使用marshal.loads()
重新转换为bytecode, 并写入pyc文件。
import sys,marshal,traceback
try:
from importlib._bootstrap_external import MAGIC_NUMBER
except ImportError:
from importlib._bootstrap import MAGIC_NUMBER
def dump_to_pyc(pycfilename,data,pycheader=None):
# --snip-- 见前文
for file in sys.argv[1:]:
try:
with open(file,'rb') as f:
d=f.read()
if d[16]==227: # 寻找数据开始的'\xe3'标志
old_header=d[:16];d=d[16:]
else:
old_header=d[:12];d=d[12:]
c=marshal.loads(d)
modname=c.co_names[0] if len(c.co_names)>=1 else ''
if modname in ('bz2','lzma','zlib'):
mod=__import__(modname)
data=mod.decompress(c.co_consts[2]) # 解压数据
marshal.loads(data) # 测试解压后数据完整性
dump_to_pyc(file,data,old_header)
print('Processed:',file)
else:
raise TypeError('不是压缩的pyc文件: '+file)
except Exception:
traceback.print_exc()
7.将字节码用PyInstaller打包
用PyInstaller打包字节码的方法非常多,这里介绍其中的一种方法:
新建一个Python文件,命名为example.py
,添加入以下代码:(注意example.py
需和example_bytecode.pyc
位于同一个目录下)
# 导入example_bytecode.pyc
import example_bytecode
example_bytecode.main()
然后切换到example.py
的所在目录,运行命令PyInstaller example.py
,即可打包我们加壳过的.pyc
文件了。
总结
前面介绍了Python字节码的压缩, 加壳和脱壳, 主要途径是修改字节码的指令, 以及修改、混淆变量名。
Python的字节码特性有着广泛的用途, 例如pyc文件的加密、结构优化、防止反编译等, 也可以用于PyInstaller
等库打包的exe的源码保护。欢迎点赞、收藏~