python_bytecode


昨天汇报完论文,这两天要准备考试,抓住休息的空档,解决一下之前遗留的题目。

题目介绍

image-20220719110634665

  • 这道题是n1book上的题目,之前刷buuctf的时候尝试了一下,发现easy_python并不easy 😫

  • 之前对python字节码这块完全不了解,只会使用一些网站和工具尝试反编译

  • 看了官方的wp,才知道原来python bytecode也可以混淆

初步尝试

题目提供的是一个pyc文件,首先尝试使用marshal和dis获取bytecode

1
2
3
4
5
6
7
8
9
import dis,marshal
ifile=open('easy_python.pyc','rb')
header=ifile.read(8)

code=marshal.load(ifile)

# print code.co_names

dis.dis(code)

题目中提示了python环境,这里必须使用python2.7来运行

第三行的read header的操作是必要的,否则marshal会出错 (咱也不清楚为啥,模仿其他人的操作

运行结果:

image-20220719112146220

emmm,在这里卡了很久,然后看了一下官方提供的wp https://pan.baidu.com/s/16p8ADuKJZI4xb_HWOS-OwA

(密码:rt7o)大致意思是需要对pyc 字节码解混淆之后才能够dis

原理分析

这里我先了解了一下字节码解混淆的相关知识 https://www.anquanke.com/post/id/185481

python字节码在处理过程大致如下:

首先所有的指令可以分为两类,不需要参数和需要参数的,Python字节码在设计的时候故意把没有参数的指令分配在了对应编号的低位,高位都是有参数的,以Include/opcode.h中的HAVE_ARGUMENT分界。他们的在二进制级别上的组织是这样的:

  • [指令] 不需要参数的指令只占用一个字节
  • [指令] [参数低字节] [参数高字节] 需要参数的指令占用三个字节,一个字节指令,两个字节参数

例子如下:

1
2
3
4
5
6
7
8
9
10
11
>>> def add(a,b):
... return a+b
...
>>> add.__code__.co_code
'|x00x00|x01x00x17S'
>>> import dis
>>> dis.dis(add.__code__.co_code)
0 LOAD_FAST 0 (0)
3 LOAD_FAST 1 (1)
6 BINARY_ADD
7 RETURN_VALUE

|x00x00 表示成16进制: 7c 00 00

7c对应的是指令的索引,当索引大于dis.HAVE_ARGUMENT的时候,指令是有参数的

1
2
3
>>> dis.opname[ord('|')]
'LOAD_FAST'
>>>

而0x7c对应的指令是LOAD_FAST,参数=0*256+0

所以|x00x00解释成指令 LOAD_FAST 0 (0)

但是如果遇到junk code的时候,可能会由于解析的时候数组越界,导致dis失败,比如下面的代码:

1
2
3
4
5
6
7
8
9
10
>>> list(map(ord,code.co_code[:9]))
[113, 158, 2, 136, 104, 110, 126, 58, 140]
>>> dis.opname[113]
'JUMP_ABSOLUTE'
>>> 2*256+158
670
>>> dis.opname[136]
'LOAD_DEREF'
>>> 110*256+104
28264

应当被解析成:

JUMP_ABSOLUTE 670

LOAD_DEREF 28264

显然第二条指令越界了,但是由于jump跳转,第二条指令实际上是不会被执行到的,也就是所谓的dead code()

pyc文件作者是故意加入不影响执行的非法指令触发分析软件崩溃,阻碍对该pyc文件的分析。

工具使用

在github上面找了很久有关python bytecode反混淆的,最后发现了

使用python2.7配置相应环境,然后运行

1
2
pip install networkx==1.11 pydotplus 
py2 main.py -i easy_python.pyc -o de_easypy.pyc

image-20220719130618404

将输出的de_easypy.pyc 拖到pyc反编译网站https://tool.lu/pyc/

得到源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import sys
import os
import hashlib
import time
import base64

class rc4:

def __init__(self, public_key = None):
if not public_key:
pass
self.public_key = 'none_public_key'
key = hashlib.md5(self.public_key).hexdigest()
self.keya = hashlib.md5(key[0:16]).hexdigest()
self.keyb = hashlib.md5(key[16:32]).hexdigest()


def encode(self, string):
string = hashlib.md5(string + self.keyb).hexdigest()[0:16] + string
self.result = ''
self.docrypt(string)
return base64.b64encode(self.result)


def decode(self, string):
string = base64.b64decode(string)
self.result = ''
self.docrypt(string)
result = self.result
if result[:16] == hashlib.md5(result[16:] + self.keyb).hexdigest()[0:16]:
return result[16:]


def docrypt(self, string):
string_lenth = len(string)
result = ''
box = list(range(256))
randkey = []
cryptkey = self.keya + hashlib.md5(self.keya).hexdigest()
key_lenth = len(cryptkey)
for i in xrange(255):
randkey.append(ord(cryptkey[i % key_lenth]))

for i in xrange(255):
j = 0
j = (j + box[i] + randkey[i]) % 256
tmp = box[i]
box[i] = box[j]
box[j] = tmp

for i in xrange(string_lenth):
a = j = 0
a = (a + 1) % 256
j = (j + box[a]) % 256
tmp = box[a]
box[a] = box[j]
box[j] = tmp
self.result += chr(ord(string[i]) ^ box[(box[a] + box[j]) % 256])



if __name__ == '__main__':
rc = rc4('zBhzAVLG6XTu2w0H')
string = raw_input('Input your flag:')
rlt = rc.encode(string)
print rlt
if rlt == 'oxurpmahzeM2kHKblmTkWlLpb2i5jXKrBN/uf3+Xn5n0lYKMJA==':
print 'True!'
else:
print 'False!'

encode和decode操作是对应的,理论上将比较字符串的内容作为输入,然后调用decode函数就能获取flag,但是可能在处理过程中出现了一些差错,直接decode的得到的结果是不对的。

image-20220719132158369

最终办法

这里需要换个思路,直接修改py源码是没办法得到正确结果的,如果能修改原始pyc文件,将encode替换成decode,然后输入oxurpmahzeM2kHKblmTkWlLpb2i5jXKrBN/uf3+Xn5n0lYKMJA== 就可以得到flag

去混淆之后使用dis再次解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
55          79 NOP
80 NOP
81 NOP
82 NOP
83 NOP
>> 84 LOAD_NAME 8 (raw_input)
87 LOAD_CONST 6 ('Input your flag:')
90 CALL_FUNCTION 1
93 STORE_NAME 9 (string)
96 LOAD_NAME 7 (rc)
99 LOAD_ATTR 10 (encode)
102 LOAD_NAME 9 (string)
105 CALL_FUNCTION 1
108 STORE_NAME 11 (rlt)
111 JUMP_FORWARD 79 (to 193)
114 NOP

encode附近的字节码

使用dis命令可以获取LOAD_ATTR对应的索引

1
2
3
>>> hex(dis.opname.index('LOAD_ATTR'))
'0x6a'
>>>

于是 LOAD_ATTR 10 (encode)对应的 十六进制为 6a 0a 00

image-20220719131609836

10表示的是encode早co_name中的索引,可以通过code.co_names查看所有的co_name

image-20220719131801203

修改encode最简单的办法就是直接在pyc文件中搜索encode并替换成decode(注意不要替换b64encode)

image-20220719131908874

然后将修改的pyc脚本运行起来

1
py2 easy_python.pyc

image-20220719132034271

成功获得flag: n1book{JUnk_c0D3_p7c}


文章作者: Smile Song
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Smile Song !
  目录