Python3 字节码混淆

Python3 字节码混淆文章目录前言什么是 pyc 文件 pyc 的版本号 pyc 的基本格式解题前言 emmm 关于字节码混淆 最早碰到还是在校赛的时候 当时一脸懵逼 什么情况 怎么 uncompyle6 不能反编译 pyc 了 不过之后也就不了了之了 今天特地写此博文纪念 DASCTFOctX 吉林工师魔法赛中的一道 RE 题 魔法叠加 出题人是真的阴间 nbsp 什么是 pyc 文件 简单来说 pyc 文件就是 Python 的字节码文件 众所周知 Python 是一种全平台的解释性语言 全平台其

前言

什么是 pyc 文件?

简单来说,pyc 文件就是 Python 的字节码文件;

众所周知,Python 是一种全平台的解释性语言,全平台其实就是 Python 文件在经过解释器解释之后(或者称为编译)生成的 pyc 文件可以在多个平台下运行,这样同样也可以隐藏源代码。

其实,Python 是完全面向对象的语言,Python 文件在经过解释器解释后生成字节码对象 PyCodeObject,pyc 文件可以理解为是 PyCodeObject 对象的持久化保存方式,在 Python 源代码运行的时候,Python 解释器会先将代码处理成 PythonCodeObject 对象,保存在内存中处理。

pyc 的版本号

Python 在生成 pyc 文件的时候也引入了 MagicNumber,来标示此 pyc 文件对应的版本号,

需要注意的是,pyc 文件只能运行在生成出此文件的解释器版本上

在 Python 解释器目录下 \Lib\importlib\_bootstrap_external.py 中有明确的版本号记录:

# Magic word to reject .pyc files generated by other Python versions. # It should change for each incompatible change to the bytecode. # # The value of CR and LF is incorporated so if you ever read or write # a .pyc file in text mode the magic number will be wrong; also, the # Apple MPW compiler swaps their values, botching string constants. # # There were a variety of old schemes for setting the magic number. # The current working scheme is to increment the previous value by # 10. # # Starting with the adoption of PEP 3147 in Python 3.2, every bump in magic # number also includes a new "magic tag", i.e. a human readable string used # to represent the magic number in __pycache__ directories. When you change # the magic number, you must also set a new unique magic tag. Generally this # can be named after the Python major version of the magic number bump, but # it can really be anything, as long as it's different than anything else # that's come before. The tags are included in the following table, starting # with Python 3.2a0. # # Known values: # Python 1.5: 20121 # Python 1.5.1: 20121 # Python 1.5.2: 20121 # Python 1.6: 50428 # Python 2.0: 50823 # Python 2.0.1: 50823 # Python 2.1: 60202 # Python 2.1.1: 60202 # Python 2.1.2: 60202 # Python 2.2: 60717 # Python 2.3a0: 62011 # Python 2.3a0: 62021 # Python 2.3a0: 62011 (!) # Python 2.4a0: 62041 # Python 2.4a3: 62051 # Python 2.4b1: 62061 # Python 2.5a0: 62071 # Python 2.5a0: 62081 (ast-branch) # Python 2.5a0: 62091 (with) # Python 2.5a0: 62092 (changed WITH_CLEANUP opcode) # Python 2.5b3: 62101 (fix wrong code: for x, in ...) # Python 2.5b3: 62111 (fix wrong code: x += yield) # Python 2.5c1: 62121 (fix wrong lnotab with for loops and # storing constants that should have been removed) # Python 2.5c2: 62131 (fix wrong code: for x, in ... in listcomp/genexp) # Python 2.6a0: 62151 (peephole optimizations and STORE_MAP opcode) # Python 2.6a1: 62161 (WITH_CLEANUP optimization) # Python 2.7a0: 62171 (optimize list comprehensions/change LIST_APPEND) # Python 2.7a0: 62181 (optimize conditional branches: # introduce POP_JUMP_IF_FALSE and POP_JUMP_IF_TRUE) # Python 2.7a0 62191 (introduce SETUP_WITH) # Python 2.7a0 62201 (introduce BUILD_SET) # Python 2.7a0 62211 (introduce MAP_ADD and SET_ADD) # Python 3000: 3000 # 3010 (removed UNARY_CONVERT) # 3020 (added BUILD_SET) # 3030 (added keyword-only parameters) # 3040 (added signature annotations) # 3050 (print becomes a function) # 3060 (PEP 3115 metaclass syntax) # 3061 (string literals become unicode) # 3071 (PEP 3109 raise changes) # 3081 (PEP 3137 make __file__ and __name__ unicode) # 3091 (kill str8 interning) # 3101 (merge from 2.6a0, see 62151) # 3103 (__file__ points to source file) # Python 3.0a4: 3111 (WITH_CLEANUP optimization). # Python 3.0b1: 3131 (lexical exception stacking, including POP_EXCEPT #3021) # Python 3.1a1: 3141 (optimize list, set and dict comprehensions: # change LIST_APPEND and SET_ADD, add MAP_ADD #2183) # Python 3.1a1: 3151 (optimize conditional branches: # introduce POP_JUMP_IF_FALSE and POP_JUMP_IF_TRUE #4715) # Python 3.2a1: 3160 (add SETUP_WITH #6101) # tag: cpython-32 # Python 3.2a2: 3170 (add DUP_TOP_TWO, remove DUP_TOPX and ROT_FOUR #9225) # tag: cpython-32 # Python 3.2a3 3180 (add DELETE_DEREF #4617) # Python 3.3a1 3190 (__class__ super closure changed) # Python 3.3a1 3200 (PEP 3155 __qualname__ added #13448) # Python 3.3a1 3210 (added size modulo 232 to the pyc header #13645) # Python 3.3a2 3220 (changed PEP 380 implementation #14230) # Python 3.3a4 3230 (revert changes to implicit __class__ closure #14857) # Python 3.4a1 3250 (evaluate positional default arguments before # keyword-only defaults #16967) # Python 3.4a1 3260 (add LOAD_CLASSDEREF; allow locals of class to override # free vars #17853) # Python 3.4a1 3270 (various tweaks to the __class__ closure #12370) # Python 3.4a1 3280 (remove implicit class argument) # Python 3.4a4 3290 (changes to __qualname__ computation #19301) # Python 3.4a4 3300 (more changes to __qualname__ computation #19301) # Python 3.4rc2 3310 (alter __qualname__ computation #20625) # Python 3.5a1 3320 (PEP 465: Matrix multiplication operator #21176) # Python 3.5b1 3330 (PEP 448: Additional Unpacking Generalizations #2292) # Python 3.5b2 3340 (fix dictionary display evaluation order #11205) # Python 3.5b3 3350 (add GET_YIELD_FROM_ITER opcode #24400) # Python 3.5.2 3351 (fix BUILD_MAP_UNPACK_WITH_CALL opcode #27286) # Python 3.6a0 3360 (add FORMAT_VALUE opcode #25483) # Python 3.6a1 3361 (lineno delta of code.co_lnotab becomes signed #26107) # Python 3.6a2 3370 (16 bit wordcode #26647) # Python 3.6a2 3371 (add BUILD_CONST_KEY_MAP opcode #27140) # Python 3.6a2 3372 (MAKE_FUNCTION simplification, remove MAKE_CLOSURE # #27095) # Python 3.6b1 3373 (add BUILD_STRING opcode #27078) # Python 3.6b1 3375 (add SETUP_ANNOTATIONS and STORE_ANNOTATION opcodes # #27985) # Python 3.6b1 3376 (simplify CALL_FUNCTIONs & BUILD_MAP_UNPACK_WITH_CALL #27213) # Python 3.6b1 3377 (set __class__ cell from type.__new__ #23722) # Python 3.6b2 3378 (add BUILD_TUPLE_UNPACK_WITH_CALL #28257) # Python 3.6rc1 3379 (more thorough __class__ validation #23722) # Python 3.7a1 3390 (add LOAD_METHOD and CALL_METHOD opcodes #26110) # Python 3.7a2 3391 (update GET_AITER #31709) # Python 3.7a4 3392 (PEP 552: Deterministic pycs #31650) # Python 3.7b1 3393 (remove STORE_ANNOTATION opcode #32550) # Python 3.7b5 3394 (restored docstring as the first stmt in the body; # this might affected the first line number #32911) # Python 3.8a1 3400 (move frame block handling to compiler #17611) # Python 3.8a1 3401 (add END_ASYNC_FOR #33041) # Python 3.8a1 3410 (PEP570 Python Positional-Only Parameters #36540) # Python 3.8b2 3411 (Reverse evaluation order of key: value in dict # comprehensions #35224) # Python 3.8b2 3412 (Swap the position of positional args and positional # only args in ast.arguments #37593) # Python 3.8b4 3413 (Fix "break" and "continue" in "finally" #37830) # # MAGIC must change whenever the bytecode emitted by the compiler may no # longer be understood by older implementations of the eval loop (usually # due to the addition of new opcodes). # # Whenever MAGIC_NUMBER is changed, the ranges in the magic_values array # in PC/launcher.c must also be updated. 

 

pyc 的基本格式

pyc 文件一般由3个部分组成:

  • 最开始4个字节是一个 Maigc int,标识此 pyc 的版本信息,不同的版本的 Magic 都在 Python/import.c 内定义;
  • 接下来四个字节还是个 int,是 pyc 产生的时间 (TIMESTAMP,1970.01.01 到产生 pyc 时候的秒数);
  • 接下来是个序列化了的 PyCodeObject(此结构在 Include/code.h 内定义),序列化方法在 Python/marshal.c 内定义;

这里对第二点再做些补充:

也就是魔术头之后其实是源代码文件信息,根据 Python 的版本不同,源代码文件信息差别还是比较大的

  1. 在 Python2 的时候,这部分只有4个字节,为源代码文件的修改时间的 Unix timestamp(精确到秒),以小端序写入,().to_bytes(4, ‘little’).hex() -> b9c7 895e
    Python3 字节码混淆

  2. Python3.5 和 3.6 相对于 Python2,源代码文件信息这部分,在时间后面增加了4个字节表示源代码文件的大小,单位字节,同样以小端序写入。如源码文件大小为87个字节,那么文件信息部分就写入 5700 0000。加上前面的修改时间,就是 b9c7 895e 5700 0000
    Python3 字节码混淆

  3. 从 Python3.7 开始支持 hash-based pyc 文件,也是就说,Python 不仅支持校验 timestrap 来判断文件是否修改过了,也支持校验 hash 值。Python 为了支持 hash 校验又使源代码文件信息这部分增加了4个字节,变为一共12个字节。
    Python3 字节码混淆
    但是这个 hash 校验默认是不启用的(可以通过调用 py_compile 模块的 compile 函数时传入参数invalidation_mode=PycInvalidationMode.CHECKED_HASH 启用)。不启用时前4个字节为 0000 0000,后8个字节为3.6和3.7版本一样的源码文件的修改时间和大小;当启用时前4个字节变为 0100 0000 或者 0300 0000,后8个字节为源码文件的 hash 值。




  • 最前面的 4 个字节为 Magic Number,其中前两个直接为解释器的版本号:
    • 此处前两个字节为 62211,也就是 Python 2.7.0a0 版本的字节码解释器;
    • 注意这里是小端序(内存存储),高位在后面,所以是 0xF303;
       

  • Magic Number 之后的四个字节为时间戳,这里是 0x5EC652B0,之后就是 Python 代码对象 PyCodeObject
  • 代码对象首先一个字节表示此处的对象类型,这里值为 TYPE_CODE,值为 0x63;
  • 此后四个字节表示参数的个数,也就是 co_argcount 的值;
  • 往后四个字节是局部变量的个数 co_nlocals
  • 往后四个字节是栈空间大小 co_stacksize
  • 往后四个字节是 co_flags
  • 之后就是 co_code 了,也就是编译好的字节码的部分:
    • co_code 部分首先的一个字节也是表示此处的对象类型,这里是 TYPE_STRING,为 0x73;
    • 接下来四个字节表示此 co_code 对象的长度,此后就是代码对象,这里的代码长度为 0xA7,也就是后方 163 个字节的长度都是代码对象;
       

  • co_code 对象的字节码内容结束后,接着是 co_consts 内容,也就是用到的常量的内容:
    • 最开始是 TYPE_TUPLE,表示这是个元组类型:
    • 此后四个字节是元素个数,这里是 0x23,之后每一个字节与对应的值一组,一共 0x23 组:
      • 每组中第一个字节表示元素类型,比如 0x69 指 TYPE_INT,此后为对应的值;
  • 后方也对应结构体中的相应内容;
     

pyc 混淆

思路:

由于 pyc 文件有现成的工具 uncompyle6,可以还原成 Python 代码,所以说我们不了解 pyc 格式也没有关系。这样我们混淆 pyc 的思路就可以是欺骗像 uncompyle6 这类反编译的工具,让它误以为指令的序列不合法,但是又不影响真正的 Python 虚拟机执行。

Python 的虚拟机是根据 PyCodeObject 中的 co_code 这个字段中存储的 opcode 序列来决定程序的执行流程的。所以说一个混淆的手段就是修改 co_code 字段中的 opcode 序列,可以添加一些加载超出范围的变量的指令,再用一些指令去跳过这些会出错的指令,这样执行的时候就不会出错了,但是反编译工具就不能正常工作了。

比如,

0 LOAD_NAME 0 (print) 2 LOAD_CONST 0 ('Hello World! --idiot.') 4 CALL_FUNCTION 1 6 POP_TOP 

以上是由 Python 3.7 生成的 pyc 的一段 opcode 序列,这时考虑在它的前面加两条指令来进行混淆。

0 JUMP_ABSOLUTE 4 2 LOAD_CONST 255 4 LOAD_NAME 0 (print) 6 LOAD_CONST 0 ('Hello, world') 8 CALL_FUNCTION 1 10 POP_TOP 

其中 JUMP_ABSOLUTE 4 表示直接跳转到 offset 为4的位置去执行指令,也就是插入的第二条指令 LOAD_CONST 255 并不会被执行,所以所以也并不会报错。但是对于反编译工具来说,这就是一个错误了,直接导致了反编译的失败。
 

实现:

根据上面的那个思路,我们可以插入许多这样类似的指令,任意的不合法指令(其实随机数据都可以),然后用一些 JUMP 指令去跳过这样的不合法指令,上面的 JUMP_ABSOLUTE 只是一个简单的例子。甚至我们可以跳转到一些自行添加的虚假分支再跳转到到真实的分支(参考 ROP 的思路)。

Python 的 opcode 中与 JUMP 相关的有:

'JUMP_FORWARD', 'JUMP_IF_FALSE_OR_POP', 'JUMP_IF_TRUE_OR_POP', 'JUMP_ABSOLUTE', 'POP_JUMP_IF_FALSE', 'POP_JUMP_IF_TRUE', 

原则上这六个都可以使用,但是实际上为了方便的话,其实还是 JUMP_FORWARDJUMP_ABSOLUTE 比较好用,因为其他的 JUMP 指令存在一些当前栈顶元素判断的问题(要做也可以,只不过实现同样的功能可能需要写更多的指令)。
 

在添加混淆指令的时候可能会遇到的问题:

  • 首先是 Python 版本的问题,Python3.6 之前使用的是变长指令,3.6及之后都是用的是定长指令了,这样对于不同的版本需要有不用的处理;
  • 由于添加了指令,一些原本存在的绝对跳转指令就会失效,所以需要对原本存在的绝对跳转指令计算偏;
  • 不定长指令的参数长度是两个字节,而定长指令的参数只有一个字节,可能存在参数长度不够用的时候,这个时候可以使用 EXTENDED_ARG 指令去扩展参数的长度,最多可以有四个字节。
  • 跳转的混淆最好还是不要从循环内到循环外或者循环外到循环内。其实最好是根据 co_lnotab 字段中的指令偏移和行号来插入混淆指令,不在属于同一行的指令中间插入,这样可以避免一些可能存在的问题。

 

解题

题面如下:

见习魔法少女发现了使用魔法可以提升自身状态,于是她创造了名为“耀眼·状态提升·超·up·无敌·闪亮光环!”的魔法((请把flag{}内的内容md5包上DASCTF{})) 

下来一个压缩包,解压后是一个 pyc 和一个 txt,直接盲猜是将 flag 加密后写入 txt 中了,反编译 pyc,

Python3 字节码混淆

这报了一个断言错误 AssertionError,奇奇怪怪的,也看不出啥,拖进010看看,

Python3 字节码混淆

一看魔术头 03 F3,这是 Python2.7 版本写的,那得用 uncompyle2 进行反编译,尝试了一下,还是报断言错误,
Python3 字节码混淆

当时我就纳闷了,然后仔细瞧了瞧二进制文件,忽然发现这 pyc 的结构像是 py3 写的,那就拿 py3 的魔术头替换了一下,好家伙,可以反编译了,这是第一个阴间的地方

Python3 字节码混淆

然鹅,这还没完,接下来是第二个阴间的地方,出题人在开头加了个混淆,得亏有点人性,没啥难度,Python3 字节码混淆

接下来就能顺利反编译了,uncompyle6 -o magic.py magic.pyc,源码如下:

import struct O0O00O00O00O0O00O = [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '!', '#', '$', '%', '&', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', ']', '^', '_', '`', '{', '|', '}', '~', '"'] def encode(O000O00000OO00OOO): """""" OOOO00OOO00O000OO = 0 OOOOOOOOOO00O0OOO = 0 OO0OOO000000OOOOO = '' for O0O0OO0OOOOOOOO00 in range(len(O000O00000OO00OOO)): O000O0OOOOO00O0O0 = O000O00000OO00OOO[O0O0OO0OOOOOOOO00:O0O0OO0OOOOOOOO00 + 1] OOOO00OOO00O000OO |= struct.unpack('B', O000O0OOOOO00O0O0)[0] << OOOOOOOOOO00O0OOO OOOOOOOOOO00O0OOO += 8 if OOOOOOOOOO00O0OOO > 13: OO00O0OO00OOO000O = OOOO00OOO00O000OO & 8191 if OO00O0OO00OOO000O > 88: OOOO00OOO00O000OO >>= 13 OOOOOOOOOO00O0OOO -= 13 else: OO00O0OO00OOO000O = OOOO00OOO00O000OO & 16383 OOOO00OOO00O000OO >>= 14 OOOOOOOOOO00O0OOO -= 14 OO0OOO000000OOOOO += O0O00O00O00O0O0O0[(OO00O0OO00OOO000O % 91)] + O0O00O00O00O0O0O0[(OO00O0OO00OOO000O // 91)] if OOOOOOOOOO00O0OOO: OO0OOO000000OOOOO += O0O00O00O00O0O0O0[(OOOO00OOO00O000OO % 91)] if OOOOOOOOOO00O0OOO > 7 or OOOO00OOO00O000OO > 90: OO0OOO000000OOOOO += O0O00O00O00O0O0O0[(OOOO00OOO00O000OO // 91)] return OO0OOO000000OOOOO O0O00O00O00O0O0O0 = [] OO000O00O00O0O0O0 = [] O0O0O0O0000O0O00O = input('plz input O0O0O0O0000O0O00O:\n') for i in range(0, 52): O0O00O00O00O0O0O0 = O0O00O00O00O0O00O[i:] + O0O00O00O00O0O00O[0:i] O0O0O0O0000O0O00O = encode(O0O0O0O0000O0O00O.encode('utf-8')) dic = open('./00.txt', 'a') dic.write(O0O0O0O0000O0O00O) dic.close 

这就是本题的第三处阴间,这变量名是人看的吗,傻眼了都,不替换变量名,这谁受得了,

poc 脚本如下:

import struct def decode(encoded_str): ''' Decode Base91 string to a bytearray ''' v = -1 b = 0 n = 0 out = b'' for strletter in encoded_str: t = struct.pack('B', strletter) if not t in decode_table: continue c = decode_table[t] if v < 0: v = c else: v += c * 91 b |= v << n n += 13 if (v & 8191) > 88 else 14 while True: out += struct.pack('B', b & 255) b >>= 8 n -= 8 if not n > 7: break v = -1 if v + 1: out += struct.pack('B', (b | v << n) & 255) return out keyMaps = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '!', '#', '$', '%', '&', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', ']', '^', '_', '`', '{', '|', '}', '~', '"'] allMaps = [] for i in range(0, 52): allMaps.append(keyMaps[i:] + keyMaps[0:i]) dic = open(r"./00.txt", "rb") flags = dic.read() dic.close() allMaps.reverse() for i in range(0, 52): decode_table = dict((v.encode('utf-8'), k) for k, v in enumerate(allMaps[i])) flags = decode(flags) print(flags[:8]) print(flags) # flag{L@y3r_6Y_1ayer_by_layer_by_layer} 

 

后记

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。

发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/205830.html原文链接:https://javaforall.net

(0)
上一篇 2026年3月19日 下午5:05
下一篇 2026年3月19日 下午5:05


相关推荐

  • 计算机病毒的分类

    计算机病毒的分类病毒与木马病毒:指编制或在计算机程序中插入的破坏计算机功能或破坏数据,影响计算机使用并且能够自我复制的一组计算机指令或程序代码。木马:是一种后门程序,被黑客用作控制远程计算机的工具。木马与病毒不同的是,木马不会自我繁殖,并不会刻意地感染其他文件,它的作用就是为黑客打开远程计算机的门户,从而可以让黑客来远程控制计算机,使黑客获取有用的信息。病毒是自动破坏目标计算机,而木马需要人为的去操控…

    2022年5月3日
    48
  • 雷达系统导论_雷达信号处理基础第二版

    雷达系统导论_雷达信号处理基础第二版1.3.3接收机单个散射体的回波波形具有下面的形式:其中,幅度调制A(t)仅仅表示脉冲的包络。接收机处理的主要功能是将雷达信号中承载信息的部分变换到基带,目的是测量。接收机的信号被分到两个通道。为同相“I”通道,在这个通道中接收信号和振荡器(本振)的信号进行混频。这个混频产生了和频和差…

    2022年10月21日
    4
  • 精进Quartz之路—Quartz备忘录

    官网: http://www.quartz-scheduler.org/Quartz任务调度快速入门 http://sishuok.com/forum/posts/list/405.html深入解读Quartz的原理 http://lavasoft.blog.51cto.com/62575/181907/Quartz2之入门示例 http://liuzidong.iteye.com/blog

    2022年2月25日
    65
  • 串口对应的线序

    串口对应的线序使用串口时会涉及到 4 根线 功能对应线的颜色 TX 绿色 RX 白色 GND 黑色 VCC 红色转载于 https www cnblogs com dakewei p 9661082 html

    2026年3月26日
    2
  • Mysql:is not allowed to connect to this MySQL server「建议收藏」

    Mysql:is not allowed to connect to this MySQL server

    2022年2月21日
    54
  • PLSQL developer使用技巧「建议收藏」

    PLSQL developer使用技巧「建议收藏」1.调整字体PLSQLDeveloper的默认字体是大小是小五,看起来比较小。我们可以通过以下方式将字体改编为五号字体。工具-首选项-用户界面-字体-浏览器(编辑器、表格)-选择-大小五号。2.文本替换文本替换可以极大提高我们敲写SQL的效率。替换方式如下:工具-首选项-用户界面-编辑器-自动替换-选择文件如下,提供一个简单的模板。sf

    2022年6月7日
    61

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注全栈程序员社区公众号