如何使用Python反汇编器停止函数打印输出?

3

我有个这个函数,当它被反汇编时看起来像这样:

def game_on():    
    def other_function():
        print('Statement within a another function')
    print("Hello World")
    sys.exit()
    print("Statement after sys.exit")

8           0 LOAD_CONST               1 (<code object easter_egg at 0x0000000005609C90, file "filename", line 8>)
              3 LOAD_CONST               2 ('game_on.<locals>.other_function')
              6 MAKE_FUNCTION            0
              9 STORE_FAST               0 (other_function)

10          12 LOAD_GLOBAL              0 (print)
             15 LOAD_CONST               3 ('Hello World')
             18 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             21 POP_TOP

11          22 LOAD_GLOBAL              1 (sys)
             25 LOAD_ATTR                2 (exit)
             28 CALL_FUNCTION            0 (0 positional, 0 keyword pair)
             31 POP_TOP

12          32 LOAD_GLOBAL              0 (print)
             35 LOAD_CONST               4 ('second print statement')
             38 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             41 POP_TOP
             42 LOAD_CONST               5 (None)
             45 RETURN_VALUE

有没有一种方法可以修改字节码,使其不打印“Hello world。”就像我想跳过第10行并继续到第11行。

有很多类似检查器和settrace的材料,但它们不是非常直接。有人对此有什么信息或者能指点我该怎么做吗?


如果你想要修改字节码,你可能需要使用 bytecode 库(https://github.com/vstinner/bytecode)。不过看起来你还在使用 Python 3.4,所以你可能需要使用旧版的 byteplay 库,可以尝试使用 Serprex 的 3.x 移植版本 - abarnert
无论如何,您想要跳过的是CALL_FUNCTIONprint。由于该print恰好在一行上,因此很明显偏移量[12,22)是相关的。作为双重检查,如果您足够了解字节码,可以看到偏移量12将函数推入堆栈,偏移量15将唯一参数推入堆栈,偏移量18调用函数,偏移量21丢弃返回值。因此,您只需使用该范围内的所有字节替换为dis.opmap['NOP']。然后重新构建代码对象,并将函数对象的__code__替换为结果,就完成了。 - abarnert
只是手动NOP掉一堆字节非常简单,只是types.CodeType的构造函数调用起来很麻烦,所以我不喜欢向任何我不讨厌的人推荐它。 - abarnert
@abarnert,有没有任何与如何做到这一点的参考代码链接?我正在尝试一些方法,但没有找到相关信息。 - Dart Feld
参考文档中没有示例;除非您已经在查看C源代码,否则不应该这样做...但是byteplaybytecode文档可能有示例和其他资源的链接。我相信有一些博客文章可以帮助您 - 我可能甚至写过一两篇;我有机会时会检查一下。 - abarnert
我找到了一篇几年前没能完成的博客文章,它几乎完全符合你想要做的事情。因此,我进行了一些小修改并将其发布为答案在这里。 - abarnert
1个回答

3
修改函数的字节码最好使用第三方库(当然,首先要假设有什么是好的方式……)。目前为止,看起来bytecode 是最好的选择,但对于旧版本的 Python,可能需要使用 byteplay。对于 3.4 版本(你似乎正在使用它),特别是使用Seprex 的 3.x 版本移植。 不过,您也可以手动完成所有操作。至少应该这样做一次,以确保您理解所有内容(并学会为什么 bytecode 是如此酷的库)。
正如您从inspect文档中看到的那样,函数基本上是一个包装器,包含一个__code__对象及其它额外的东西(闭包单元、默认值和反射内容,如名称和类型注释),而代码对象则是一个包装器,包含一个由字节码组成的co_code 字符串,其中包含许多额外的信息。
所以,你可能认为剪裁一些字节码只是一个简单的问题:
del func.__code__.co_code[12:22]

但可悲的是,字节码中所有指令都是以偏移量为基础的,从跳转指令到生成回溯时使用的行号表。你可以修复所有问题,但这很痛苦。所以,你可以用NOP替换你想要删除的指令。(在底层,编译器和窥孔优化器在所有地方都插入NOP,然后在最后进行一个大修复。但是执行该修复的代码没有暴露给Python。)
此外,字节码存储在不可变的bytes中,而不是可变的bytearray中,并且code对象本身也是不可变的(尝试通过C API黑客攻击在解释器背后更改它们是非常糟糕的想法)。因此,你必须围绕已修改的字节码构建一个新的code对象。但是函数是可变的,所以你可以修改你的函数来指向那个新的代码对象。
因此,在这里有一个函数,可以按偏移量NOP出一段指令:
import dis
import sys
import types

NOP = bytes([dis.opmap['NOP']])

def noprange(func, start, end):
    c = func.__code__
    cc = c.co_code
    if sys.version_info >= (3,6):
        if (end - start) % 2:
            raise ValueError('Cannot nop out partial wordcodes')
        nops = (NOP + b'\0') * ((end-start)//2)
    else:
        nops = NOP * (end-start)
    newcc = cc[:start] + nops + cc[end:]
    newc = types.CodeType(
        c.co_argcount, c.co_kwonlyargcount, c.co_nlocals, c.co_stacksize,
        c.co_flags, newcc, c.co_consts, c.co_names, c.co_varnames,
        c.co_filename, c.co_name, c.co_firstlineno, c.co_lnotab,
        c.co_freevars, c.co_cellvars)
    func.__code__ = newc

如果你对版本检查感到困惑:在Python 2.x和3.0-3.5中,每个指令的长度为1或3个字节,具体取决于它是否需要任何参数,因此NOP是1个字节;而在3.6+中,每个指令都是包括NOP在内的2个字节。无论如何,我只在3.6上进行了实际测试,没有测试3.4或3.5,所以希望我没有弄错那部分。同时,希望我没有添加在3.4之后新增的任何函数到dis中。所以,交叉你的手指,然后:
noprange(game_on, 12, 22)

如果你想确保函数可以正常运行,或者在尝试调用它时修改函数以引发 RuntimeError 或崩溃,但段错误是学习的一部分,对吧?无论如何,如果你运行 dis.dis(noprange) ,你应该会看到从第10行开始的四个指令被一串NOP行替换,然后函数的其余部分保持不变,所以在调用函数之前,请先尝试这样做。


一旦你有信心已经将其正确地解决了,如果你想摆脱一个源代码行中的所有指令,并且不必手动dis函数并阅读它们,你可以使用findlinestarts来以编程的方式进行:

def nopline(func, line):
    linestarts = dis.findlinestarts(func.__code__)
    for offset, lineno in linestarts:
        if lineno > line:
            raise ValueError('No code found for line')
        if lineno == line:
            try:
                nextoffset, _ = next(linestarts)
            except StopIteration:
                raise ValueError('Do not nop out the last return')
            noprange(func, offset, nextoffset)
            return
    raise ValueError('No line found')

现在只需要:

nopline(game_on, 10)

这样做的好处是,你可以在代码中使用它,而它在3.4和3.8中的工作方式(或崩溃方式)是相同的,因为Python版本之间的偏移量可能会改变,但是行号计数的方式显然不会改变。

这是一个很棒的回答。我喜欢“段错误是学习的一部分”的建议。这让我想起了当我和一个朋友作为大一的计算机科学专业学生来回尝试制作最小的Hello World程序,以便在我们的Linux系统上运行。我认为我们设法将其缩减到大约30个字节,方法是将代码和字符串嵌入不完整的ELF头文件中(当然最终会有很多虚假值,但令人惊讶的是它并没有导致程序崩溃)。当你用十六进制编辑器手写机器码时,你知道你正在玩得开心。 - Blckknght
@Blckknght 你不能使用 a.out 可执行文件来代替 ELF 文件以获得更小的头吗?或者是 Linux 停止支持这些比其他 *nixes 更早了吗?我记不清了。 - abarnert
我已经记不清所有的细节了,因为那是15年前的事情了。也许我们尝试过使用a.out,但发现通过在规范上作弊(省略某些头部值或让它们成为无意义的值)可以使ELF文件更小?我还记得最终结果比正常的ELF头部应该小(我们只是截断了它,但它仍然能够工作)。我们在开始这项工作时并不了解汇编语言或机器码,我们从一个用C编译的Hello World程序开始,逐渐使二进制文件变得越来越小,并在此过程中阅读ELF规范和x86指令集手册。 - Blckknght
@DartFeld 但是无论如何,如果你好奇的话,你应该一定要尝试不同的可能性 - 但在这种情况下,你更需要 bytecodebyteplay,因为它们让你用更少的工作做更复杂的事情。例如,如果你想玩转跳转标签、在现有函数中插入新的字节码、将全局变量替换为闭包变量等等,完全手动完成这些操作会更加痛苦。 - abarnert
非常感谢您的帮助。了解字节码运作的过程真是有趣。 - Dart Feld
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接