handcrafted-pyc-攻防世界

很早以前就想写一篇关于python的字节码的文章,但是一直没什么时间,借着刚好做到这一题,写一写我对相关内容的理解。

准备工作

拿到的题目并不是一个pyc格式的文件

1
2
3
4
5
6
7
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import marshal, zlib, base64

exec(marshal.loads(zlib.decompress(base64.b64decode('eJyNVktv00AQXm/eL0igiaFA01IO4cIVCUGFBBJwqRAckLhEIQmtRfPwI0QIeio/hRO/hJ/CiStH2M/prj07diGRP43Hs9+MZ2fWMxbnP6mux+oK9xVMHPFViLdCTB0xkeKDFEFfTIU4E8KZq8dCvB4UlN3hGEsdddXU9QTLv1eFiGKGM4cKUgsFCNLFH7dFrS9poayFYmIZm1b0gyqxMOwJaU3r6xs9sW1ooakXuRv+un7Q0sIlLVzOCZq/XtsK2oTSYaZlStogXi1HV0iazoN2CV2HZeXqRQ54TlJRb7FUlKyUatISsdzo+P7UU1Gb1POdMruckepGwk9tIXQTftz2yBaT5JQovWvpSa6poJPuqgao+b9l5Aj/R+mLQIP4f6Q8Vb3g/5TB/TJxWGdZr9EQrmn99fwKtTvAZGU7wzS7GNpZpDm2JgCrr8wrmPoo54UqGampFIeS9ojXjc4E2yI06bq/4DRoUAc0nVnng4k6p7Ks0+j/S8z9V+NZ5dhmrJUM/y7JTJeRtnJ2TSYJvsFq3CQt/vnfqmQXt5KlpuRcIvDAmhnn2E0t9BJ3SvB/SfLWhuOWNiNVZ+h28g4wlwUp00w95si43rZ3r6+fUIEdgOZbQAsyFRRvBR6dla8KCzRdslar7WS+a5HFb39peIAmG7uZTHVm17Czxju4m6bayz8e7J40DzqM0jr0bmv9PmPvk6y5z57HU8wdTDHeiUJvBMAM4+0CpoAZ4BPgJeAYEAHmgAUgAHiAj4AVAGORtwd4AVgC3gEmgBBwCPgMWANOAQ8AbwBHgHuAp4D3gLuARwoGmNUizF/j4yDC5BWM1kNvvlxFA8xikRrBxHIUhutFMBlgQoshhPphGAXe/OggKqqb2cibxwuEXjUcQjccxi5eFRL1fDSbKrUhy2CMb2aLyepkegDWsBwPlrVC0/kLHmeCBQ=='))))

通过解码之后运行,提示输入密码,将其转为pyc格式的文件

1
2
3
4
5
6
7
8
import marshal, zlib, base64
import imp

b64d = base64.b64decode('eJyNVktv00AQXm/eL0igiaFA01IO4cIVCUGFBBJwqRAckLhEIQmtRfPwI0QIeio/hRO/hJ/CiStH2M/prj07diGRP43Hs9+MZ2fWMxbnP6mux+oK9xVMHPFViLdCTB0xkeKDFEFfTIU4E8KZq8dCvB4UlN3hGEsdddXU9QTLv1eFiGKGM4cKUgsFCNLFH7dFrS9poayFYmIZm1b0gyqxMOwJaU3r6xs9sW1ooakXuRv+un7Q0sIlLVzOCZq/XtsK2oTSYaZlStogXi1HV0iazoN2CV2HZeXqRQ54TlJRb7FUlKyUatISsdzo+P7UU1Gb1POdMruckepGwk9tIXQTftz2yBaT5JQovWvpSa6poJPuqgao+b9l5Aj/R+mLQIP4f6Q8Vb3g/5TB/TJxWGdZr9EQrmn99fwKtTvAZGU7wzS7GNpZpDm2JgCrr8wrmPoo54UqGampFIeS9ojXjc4E2yI06bq/4DRoUAc0nVnng4k6p7Ks0+j/S8z9V+NZ5dhmrJUM/y7JTJeRtnJ2TSYJvsFq3CQt/vnfqmQXt5KlpuRcIvDAmhnn2E0t9BJ3SvB/SfLWhuOWNiNVZ+h28g4wlwUp00w95si43rZ3r6+fUIEdgOZbQAsyFRRvBR6dla8KCzRdslar7WS+a5HFb39peIAmG7uZTHVm17Czxju4m6bayz8e7J40DzqM0jr0bmv9PmPvk6y5z57HU8wdTDHeiUJvBMAM4+0CpoAZ4BPgJeAYEAHmgAUgAHiAj4AVAGORtwd4AVgC3gEmgBBwCPgMWANOAQ8AbwBHgHuAp4D3gLuARwoGmNUizF/j4yDC5BWM1kNvvlxFA8xikRrBxHIUhutFMBlgQoshhPphGAXe/OggKqqb2cibxwuEXjUcQjccxi5eFRL1fDSbKrUhy2CMb2aLyepkegDWsBwPlrVC0/kLHmeCBQ==')
zd = zlib.decompress(b64d)
ml = marshal.loads(zd)
with open('crackme.pyc','wb') as f:
f.write(imp.get_magic() + b'\0' * 4 + zd)

具体写入的内容是什么在后面介绍。

文件格式

PyCodeObject定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct {
PyObject_HEAD
int co_argcount; /* #arguments, except *args */
int co_nlocals; /* #local variables */
int co_stacksize; /* #entries needed for evaluation stack */
int co_flags; /* CO_..., see below */
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 doesn't count for hash/cmp */
PyObject *co_filename; /* string (where it was loaded from) */
PyObject *co_name; /* string (name, for reference) */
int co_firstlineno; /* first source line number */
PyObject *co_lnotab; /* string (encoding addr<->lineno mapping) */
} PyCodeObject;

以本题为例

1
2
3
4
5
6
-> hexdump -C crackme.pyc 
00000000 03 f3 0d 0a 00 00 00 00 63 00 00 00 00 00 00 00 |........c.......|
00000010 00 02 00 00 00 40 00 00 00 73 23 00 00 00 64 01 |.....@...s#...d.|
00000020 00 84 00 00 5a 00 00 65 01 00 64 02 00 6b 02 00 |....Z..e..d..k..|
00000030 72 1f 00 65 00 00 83 00 00 01 6e 00 00 64 00 00 |r..e......n..d..|
00000040 53

首先几个字节是文件头,首先的四个字节是 MagicNumber , 接下来的四个字节是 时间戳 ,采用小端序,不过写入的都是0,也无所谓,和PE文件格式里的时间一样,这一项实际上并没有什么用,这里对应的是之前写入的imp.get_magic() + b'\0' * 4,只有包含这样的文件头才是一个合法的pyc文件。

后面是 PyCodeObject 。首先会有一个 TYPE_CODE , 这里是字符 , 所以是 C , 即0x63 。后面是参数个数 co_argcount , 局部变量个数 co_nlocals , 栈空间 co_stacksize , 和 co_flags ,每项均占用4个字节。

我们可以解析出来这样的一个结构(需要注意是小端序)

1
2
3
4
5
6
7
8
9
10
magic 03f30d0a
moddate 00000000 (Thu Jan 1 08:00:00 1970)
code
argcount 0
nlocals 0
stacksize 2
flags 0040
code
6401008400005a00006501006402006b0200721f00650000830000016e00
0064000053

co_flags后面是co_code,把它单独拿出来,因为它也有一些自己的结构

co_code

同样先是 TYPE_CODE , 类型标识 , 这里是 s , 即 0x73 。后面的四个字节用来标识指令的 长度 , 这里是 0x23 。紧跟在后面的是具体的字节码,包含指令操作数,有些指令是没有操作数的,指令占用一个字节,操作数占用两个字节,字节码和指令的对应关系和指令的作用可以查阅这篇文章或者直接去查阅dis的手册。

给出链接

https://docs.python.org/2/library/dis.html

https://github.com/python/cpython/blob/master/Include/opcode.h

dis模块来解析一下这段字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1           0 LOAD_CONST               1 (<code object main at 0000000003656E30, file "<string>", line 1>)
3 MAKE_FUNCTION 0
6 STORE_NAME 0 (main)

4 9 LOAD_NAME 1 (__name__)
12 LOAD_CONST 2 ('__main__')
15 COMPARE_OP 2 (==)
18 POP_JUMP_IF_FALSE 31

5 21 LOAD_NAME 0 (main)
24 CALL_FUNCTION 0
27 POP_TOP
28 JUMP_FORWARD 0 (to 31)
>> 31 LOAD_CONST 0 (None)
34 RETURN_VALUE

第一列数字是这个代码块在源码中的行数 , 第二列数字表示该指令在 co_code 中的偏移 , 第三列表示具体操作 , 第四列是操作数。

这道题目可以看出来又调用了一个PyCodeObject,这个在后面在分析,先关注一个问题,这个

PyCodeObject是通过LOAD_CONST指令调用的,是储存在co_const中的常量

co_const

既然单拉出来就说明它也有自己的结构

每一项都是以0x28 开头,为 TYPE_TUPLE, 即 '(' 。接下来的四个字节为元素个数,这里是0x03

1
2
>>> code.co_consts
(None, <code object main at 0x7fa4909ea530, file "<string>", line 1>, '__main__')
其它

后面为co_names,标识0x28,接着四个字节为元素个数 , 然后字符类型 , 字符内容。

co_varnames , co_freevars , co_cellvars 结构与上面相同。

然后是co_filename,标识类型,路径长度 ,路径 。

然后是co_name,同样是标识类型,长度,内容。

co_firstlineno,这里为0x01

字节码指令与源文件行号对应关系储存在co_lnotab,同样是标识类型,四字节长度,内容。

这是文件对应的信息(const去掉了None

('main', '__name__')
1
2
3
4
5
6
7
8
names ('main', '__name__')
varnames ()
freevars ()
cellvars ()
filename '<string>'
name '<module>'
firstlineno 1
lnotab 000009030c01

pyc文件格式的粗略解析就差不多了,可以看出来比ELFPE都要简单得多。

题目分析

前面看到,本题还有一个PyCodeObject main是主要操作,所以用上面的方法再来解析一下main函数(太长了,就不全放出来了)

主要关注输入操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
737 LOAD_CONST               0 (None)
740 NOP
741 JUMP_ABSOLUTE 759
>> 744 LOAD_GLOBAL 1 (raw_input)
747 JUMP_ABSOLUTE 1480
>> 750 LOAD_FAST 0 (password)
753 COMPARE_OP 2 (==)
756 JUMP_ABSOLUTE 767
>> 759 ROT_TWO
760 STORE_FAST 0 (password)
763 POP_TOP
764 JUMP_ABSOLUTE 744
>> 767 POP_JUMP_IF_FALSE 1591
770 LOAD_GLOBAL 0 (chr)
773 LOAD_CONST 17 (99)

发现这里只是经过一个简单的比较,完全可以bypass,以16进制打开,修改POP_JUMP_IF_FALSE 1591nop就可以了

所以开始查表POP_JUMP_IF_FALSE对应的值为114(0x72),1591(0x637)is \x37\x06 (小端序)

所以要查找72 37 06nop对应的值为09,所以需要改成09 09 09

然后直接运行,输入任何值都可以输出flag

1
2
3
-> ./crackme.pyc 
password: 0
hitcon{Now you can compile and run Python bytecode in your brain!}

当然也可以逐步分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>  767 POP_JUMP_IF_FALSE     1591
770 LOAD_GLOBAL 0 (chr)
773 LOAD_CONST 17 (99)
776 CALL_FUNCTION 1
779 LOAD_GLOBAL 0 (chr)
782 LOAD_CONST 10 (116)
785 CALL_FUNCTION 1
788 LOAD_GLOBAL 0 (chr)
791 LOAD_CONST 14 (105)
794 CALL_FUNCTION 1
797 LOAD_GLOBAL 0 (chr)
800 LOAD_CONST 9 (104)
803 CALL_FUNCTION 1
806 ROT_TWO
807 BINARY_ADD
808 ROT_TWO
809 BINARY_ADD
810 ROT_TWO
811 BINARY_ADD

这是后面的一部分操作,分析一下,调用chr()函数,把存储的几个数字转成字符,此时的栈

1
2
3
4
5
6
|--------high--------|
|--------'c'---------|
|--------'t'---------|
|--------'i'---------|
|--------'h'---------|
|--------...---------|

执行一次ROT_TWO,栈顶两个元素换位,然后BINARY_ADD,经过几次,然后进行下一组

1
2
3
4
5
6
|--------high--------|			|--------high--------|			|--------high--------|
|--------'c'---------| |--------'c'---------| |--------'hitc'------|
|--------'t'---------| |--------'t'---------| |--------...---------|
|--------'i'---------| |--------'hi'--------|
|--------'h'---------| |--------...---------|
|--------...---------|

大概的变化过程就是这样,仔细分析,每四个字符一组,每组做一个倒序处理

然后跳转到最后

1
2
3
4
>> 2212 PRINT_ITEM
2213 PRINT_NEWLINE
2214 LOAD_CONST 0 (None)
2217 RETURN_VALUE

直接输出,这样就得到需要的flag

混淆

从上面也可以看出来,一个不经过处理的pyc文件是没有任何安全性可言的,甚至可以被一些工具如uncompyle6或者在线工具直接反编译成python代码,并且从代码风格来看准确率还是很高的,这时候就需要掌握一些简单的混淆/反混淆的技巧

uncompyle6

这个工具还是很好用的,但是一旦报错就停止对文件的分析,而且想让uncompyle6等工具报错也很简单,只需要在开头添加一个绝对跳转就可以了,JUMP_ABSOLUTE 3对应的字节码为 71 03 00,同时修改co_code的长度,这个时候使用一些工具就会报错

1
<<< Error: Decompiling stopped due to <class 'uncompyle6.semantics.pysource.ParserError'>

但是dis还是可以正常工作的,程序也是可以正常执行的,因为我们自己加入了3个字节,然后跳转到第四个字节(编号为3)的位置,只是多了一个执行周期,对程序的执行流程没有任何影响。

dis

对于一些新手来说,没法使用工具就基本上束手无策了,但是对于熟练掌握汇编语言的人来说,读懂dis解析出来的代码太容易了,就像刚刚我们就很容易的读懂了这道题目的执行逻辑(虽然很简单)

所以还需要一定的方法阻止破解者使用dis进行分析

这会需要多一些处理

给代码段头部添加 0x71 0x00 0x06 0x64 0xff 0xff 。 同样需要修改 co_code 的长度。

这段指令的意义很简单

1
2
3
0 JUMP_ABSOLUTE            6
3 LOAD_CONST 65535
6 ...

直接跳到了编号为6的位置,中间一句是不执行的,但是dis解析的时候会判断这句报错,因为不存在第65535项常量,这是条非法指令。但由于第一条绝对跳转的存在,第二条指令永远都不会被执行。通常的反汇编器如dis并不能理解实际执行的控制流,当反汇编器尝试反汇编第二条指令时,会去读取co_const的第65535项并且抛出一个异常。然后dis就会相应报错:

1
IndexError: tuple index out of range

这比骗过IDA要容易得多

虚假分支

合理设置条件,创造出很多程序不可能执行的分支,但是逆向者需要认真鉴别每一条分支是否被执行。

这不会使逆向者反汇编失败,但是会对分析造成极大的困难,就像是可恨的控制流平坦化,属实劝退。

而且也没什么好办法,只能慢慢分析

重叠指令

重叠指令在有变长指令的机器(如X86)上有广泛应用。直接在网上找了一些x86重叠指令:

1
2
3
4
5
6
7
;单重叠
00: EB 01 jmp 3
02: 68 c3 90 90 90 push 0x909090c3

;实际执行
00: EB 01 jmp 3
03: C3 retn
1
2
3
4
5
6
7
8
9
;多重叠指令
00: EB02 jmp 4
02: 69846A40682C104000EB02 imul eax, [edx + ebp*2 + 0102C6840], 0x002EB0040

;实际执行
00: EB02 jmp 4
04: 6A40 push 040
06: 682C104000 push 0x40102C
0B: EB02 jmp 0xF
1
2
3
4
5
6
7
8
;跳转至自身
00: EBFF jmp 1
02: C0C300 rol bl, 0

;实际执行
00: EBFF jmp 1
01: FFC0 inc eax
03: C3 retn

在python上也同样适用

1
2
3
4
0 JUMP_ABSOLUTE        [71 05 00]     5 
3 NOP [09 -- --]
4 LOAD_CONST [64 64 00] 64
7 STOP_CODE [00 -- --]

一个简单的例子,进行了跳转之后,该位置是64,是有效指令所以读取了两个字节的操作数,实际上这段只执行了一句有效指令LOAD_CONST 0

指令集

现有的指令集是有定义的,但是如果有人修改了原有的定义,按照新的方式去赋值,就完全无法解析,遇见的不多,这样的情况似乎就只能通过函数的逻辑去猜测指令的意义

万恶的VM,万恶的出题人

SMC

程序在循行开始的时候按照自己设定的加解密方式对真正的代码进行加密,然后再执行真正的代码部分,这样的方式利用python也可以实现

后记

本来是由一个题发散出来,结果写了很多,都只是一些个人的理解,整理整理才觉得还有很多需要学的东西。

解析的时候借用了一个国外大佬写的dis的代码,输出的层次很清晰,非常适合学习,原文写的也很不错。

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
import dis, marshal, struct, sys, time, types


def show_file(fname):
f = open(fname, "rb")
magic = f.read(4)
moddate = f.read(4)
modtime = time.asctime(time.localtime(struct.unpack('I', moddate)[0]))
print "magic %s" % (magic.encode('hex'))
print "moddate %s (%s)" % (moddate.encode('hex'), modtime)
code = marshal.load(f)
show_code(code)


def show_code(code, indent=''):
print "%scode" % indent
indent += ' '
print "%sargcount %d" % (indent, code.co_argcount)
print "%snlocals %d" % (indent, code.co_nlocals)
print "%sstacksize %d" % (indent, code.co_stacksize)
print "%sflags %04x" % (indent, code.co_flags)
show_hex("code", code.co_code, indent=indent)
dis.disassemble(code)
print "%sconsts" % indent
for const in code.co_consts:
if type(const) == types.CodeType:
show_code(const, indent + ' ')
else:
print " %s%r" % (indent, const)
print "%snames %r" % (indent, code.co_names)
print "%svarnames %r" % (indent, code.co_varnames)
print "%sfreevars %r" % (indent, code.co_freevars)
print "%scellvars %r" % (indent, code.co_cellvars)
print "%sfilename %r" % (indent, code.co_filename)
print "%sname %r" % (indent, code.co_name)
print "%sfirstlineno %d" % (indent, code.co_firstlineno)
show_hex("lnotab", code.co_lnotab, indent=indent)


def show_hex(label, h, indent):
h = h.encode('hex')
if len(h) < 60:
print "%s%s %s" % (indent, label, h)
else:
print "%s%s" % (indent, label)
for i in range(0, len(h), 60):
print "%s %s" % (indent, h[i:i + 60])


show_file(sys.argv[1])
攻防世界_echo-server_wp b01lersCTF-2020-wp

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×