是否有可能“破解”Python 的打印功能?

新手上路,请多包涵

注意:此问题仅供参考。我很想知道它可以深入 Python 的内部结构。

不久前,在某个 问题 中开始讨论是否可以在调用 print 之后/期间修改传递给 print 语句的字符串。例如,考虑以下功能:

 def print_something():
    print('This cat was scared.')

现在,当运行 print 时,终端的输出应该显示:

 This dog was scared.

请注意,“猫”一词已被“狗”一词取代。某个地方的某些东西能够以某种方式修改这些内部缓冲区以更改打印的内容。假设这是在没有原始代码作者明确许可的情况下完成的(因此,黑客攻击/劫持)。

来自明智的@abarnert 的 评论 尤其让我思考:

有几种方法可以做到这一点,但它们都很丑陋,永远不应该这样做。最不丑陋的方法可能是用一个不同的 co_consts 列表替换函数内的 code 对象。接下来可能是进入 C API 以访问 str 的内部缓冲区。 […]

所以,看起来这实际上是可能的。

这是我解决这个问题的天真方法:

 >>> import inspect
>>> exec(inspect.getsource(print_something).replace('cat', 'dog'))
>>> print_something()
This dog was scared.

当然, exec 是不好的,但这并不能真正回答问题,因为它在调用 print 期间/之后 实际上并没有修改任何内容。

正如@abarnert 所解释的那样,它将如何完成?

原文由 cs95 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 479
2 个回答

首先,实际上有一种更简单的方法。我们想要做的就是改变 print 打印的内容,对吗?

 _print = print
def print(*args, **kw):
    args = (arg.replace('cat', 'dog') if isinstance(arg, str) else arg
            for arg in args)
    _print(*args, **kw)

或者,类似地,您可以使用 monkeypatch sys.stdout 而不是 print


此外, exec … getsource … 想法没有错。好吧,当然它有 很多 错误,但比这里接下来的要少……


但是如果你确实想修改函数对象的代码常量,我们可以这样做。

如果你真的想真正地玩代码对象,你应该使用像 bytecode (当它完成时)或 byteplay 这样的库(直到那时,或者对于旧的 Python 版本)手动完成。即使对于这种微不足道的事情, CodeType 初始化器也是一种痛苦;如果你真的需要做一些像修复 lnotab 这样的事情,只有疯子才会手动去做。

此外,不用说,并非所有 Python 实现都使用 CPython 样式的代码对象。这段代码将在 CPython 3.7 中工作,并且可能所有版本至少回到 2.2 并进行一些小的更改(不是代码黑客的东西,而是生成器表达式之类的东西),但它不适用于任何版本的 IronPython。

 import types

def print_function():
    print ("This cat was scared.")

def main():
    # A function object is a wrapper around a code object, with
    # a bit of extra stuff like default values and closure cells.
    # See inspect module docs for more details.
    co = print_function.__code__
    # A code object is a wrapper around a string of bytecode, with a
    # whole bunch of extra stuff, including a list of constants used
    # by that bytecode. Again see inspect module docs. Anyway, inside
    # the bytecode for string (which you can read by typing
    # dis.dis(string) in your REPL), there's going to be an
    # instruction like LOAD_CONST 1 to load the string literal onto
    # the stack to pass to the print function, and that works by just
    # reading co.co_consts[1]. So, that's what we want to change.
    consts = tuple(c.replace("cat", "dog") if isinstance(c, str) else c
                   for c in co.co_consts)
    # Unfortunately, code objects are immutable, so we have to create
    # a new one, copying over everything except for co_consts, which
    # we'll replace. And the initializer has a zillion parameters.
    # Try help(types.CodeType) at the REPL to see the whole list.
    co = types.CodeType(
        co.co_argcount, co.co_kwonlyargcount, co.co_nlocals,
        co.co_stacksize, co.co_flags, co.co_code,
        consts, co.co_names, co.co_varnames, co.co_filename,
        co.co_name, co.co_firstlineno, co.co_lnotab,
        co.co_freevars, co.co_cellvars)
    print_function.__code__ = co
    print_function()

main()

破解代码对象会出什么问题?大多数只是段错误, RuntimeError s 吃掉整个堆栈,更正常的 RuntimeError 可以处理的 s,或者可能只会引发的垃圾值 TypeErrorAttributeError 当您尝试使用它们时。例如,尝试创建一个只有 RETURN_VALUE 的代码对象,堆栈上没有任何内容(字节码 b'S\0' 对于 3.6+, b'S' ),或者之前为空tuple for co_consts when there’s a LOAD_CONST 0 in the bytecode, or with varnames decremented by 1 so the highest LOAD_FAST actually loads a freevar/单元格单元格。为了一些真正的乐趣,如果你得到 lnotab 足够错误,你的代码只会在调试器中运行时出现段错误。

使用 bytecodebyteplay 不会保护你免受所有这些问题的困扰,但它们确实有一些基本的健全性检查,以及可以让你做一些事情的好帮手,比如插入一段代码让它担心更新所有的偏移量和标签,这样你就不会弄错了,等等。 (另外,它们使您不必输入那个荒谬的 6 行构造函数,也不必调试由此产生的愚蠢拼写错误。)


现在进入#2。

我提到代码对象是不可变的。当然,常量是一个元组,所以我们不能直接改变它。而const元组里面的东西是一个字符串,我们也不能直接改变。这就是为什么我必须构建一个新的字符串来构建一个新的元组来构建一个新的代码对象。

但是如果你可以直接改变一个字符串呢?

好吧,在幕后足够深,一切都只是指向一些 C 数据的指针,对吧?如果您使用的是 CPython,则有 一个 C API 可以访问对象您可以使用 ctypes 从 Python 本身访问该 API,这是一个非常糟糕的想法,他们将 pythonapi 就在 stdlib 的 ctypes 模块 中。 :) 您需要知道的最重要的技巧是 id(x) 是内存中指向 x 的实际指针(作为 int )。

不幸的是,字符串的 C API 不允许我们安全地获取已冻结字符串的内部存储。所以安全地拧紧,让我们 只读头文件 并自己找到那个存储。

如果您使用的是 CPython 3.4 - 3.7(旧版本不同,未来谁知道),来自纯 ASCII 模块的字符串文字将使用紧凑的 ASCII 格式存储,这意味着结构提前结束,ASCII 字节的缓冲区紧跟在内存中。如果您在字符串中放入非 ASCII 字符或某些类型的非文字字符串,这将中断(可能在段错误中),但您可以阅读其他 4 种方法来访问不同类型字符串的缓冲区。

为了让事情稍微简单一些,我使用了我的 GitHub 上的 superhackyinternals 项目。 (它有意不能通过 pip 安装,因为你真的不应该使用它,除非你在本地构建解释器等进行试验。)

 import ctypes
import internals # https://github.com/abarnert/superhackyinternals/blob/master/internals.py

def print_function():
    print ("This cat was scared.")

def main():
    for c in print_function.__code__.co_consts:
        if isinstance(c, str):
            idx = c.find('cat')
            if idx != -1:
                # Too much to explain here; just guess and learn to
                # love the segfaults...
                p = internals.PyUnicodeObject.from_address(id(c))
                assert p.compact and p.ascii
                addr = id(c) + internals.PyUnicodeObject.utf8_length.offset
                buf = (ctypes.c_int8 * 3).from_address(addr + idx)
                buf[:3] = b'dog'

    print_function()

main()

如果你想玩这个东西, int 在幕后比 str 要简单得多。通过将 --- 的值更改为 2 1 更容易猜出你能破解什么,对吧?实际上,忘记想象,让我们开始吧(再次使用 superhackyinternals 中的类型):

 >>> n = 2
>>> pn = PyLongObject.from_address(id(n))
>>> pn.ob_digit[0]
2
>>> pn.ob_digit[0] = 1
>>> 2
1
>>> n * 3
3
>>> i = 10
>>> while i < 40:
...     i *= 2
...     print(i)
10
10
10

…假设代码框有一个无限长的滚动条。

我在 IPython 中尝试了同样的事情,我第一次尝试在提示符下计算 2 时,它进入了某种不可中断的无限循环。大概它在 REPL 循环中使用数字 2 来表示某些内容,而股票解释器不是?

原文由 abarnert 发布,翻译遵循 CC BY-SA 4.0 许可协议

猴子补丁 print

print is a builtin function so it will use the print function defined in the builtins module (or __builtin__ in Python 2).因此,无论何时您想要修改或更改内置函数的行为,您都可以简单地重新分配该模块中的名称。

这个过程称为 monkey-patching

 # Store the real print function in another variable otherwise
# it will be inaccessible after being modified.
_print = print

# Actual implementation of the new print
def custom_print(*args, **options):
    _print('custom print called')
    _print(*args, **options)

# Change the print function globally
import builtins
builtins.print = custom_print

之后每个 print 调用都会通过 custom_print ,即使 print 在外部模块中。

但是,您并不是真的想打印额外的文本,而是想更改打印的文本。解决此问题的一种方法是将其替换为将要打印的字符串:

 _print = print

def custom_print(*args, **options):
    # Get the desired seperator or the default whitspace
    sep = options.pop('sep', ' ')
    # Create the final string
    printed_string = sep.join(args)
    # Modify the final string
    printed_string = printed_string.replace('cat', 'dog')
    # Call the default print function
    _print(printed_string, **options)

import builtins
builtins.print = custom_print

事实上,如果你运行:

 >>> def print_something():
...     print('This cat was scared.')
>>> print_something()
This dog was scared.

或者,如果您将其写入文件:

测试文件.py

 def print_something():
    print('This cat was scared.')

print_something()

并导入它:

 >>> import test_file
This dog was scared.
>>> test_file.print_something()
This dog was scared.

所以它确实按预期工作。

但是,如果您只是暂时想要猴子补丁打印,您可以将其包装在上下文管理器中:

 import builtins

class ChangePrint(object):
    def __init__(self):
        self.old_print = print

    def __enter__(self):
        def custom_print(*args, **options):
            # Get the desired seperator or the default whitspace
            sep = options.pop('sep', ' ')
            # Create the final string
            printed_string = sep.join(args)
            # Modify the final string
            printed_string = printed_string.replace('cat', 'dog')
            # Call the default print function
            self.old_print(printed_string, **options)

        builtins.print = custom_print

    def __exit__(self, *args, **kwargs):
        builtins.print = self.old_print

因此,当您运行它时,它取决于打印的上下文:

 >>> with ChangePrint() as x:
...     test_file.print_something()
...
This dog was scared.
>>> test_file.print_something()
This cat was scared.

这就是你如何通过猴子补丁“破解” print

修改目标而不是 print

如果您查看 print 的签名,您会注意到 file 参数默认为 sys.stdout 。请注意,这是一个动态默认参数(它 实际上 会查找 sys.stdout 每次您调用 print )并且不像 Python 中的普通默认参数。因此,如果您更改 sys.stdout print 实际上将打印到不同的目标更方便 Python 还提供了 redirect_stdout 功能(Python it’s easy,但是从为早期的 Python 版本创建等效函数)。

缺点是它不适用于 print 不打印到 sys.stdout --- 的语句,并且创建你自己的 stdout 并不是很简单。

 import io
import sys

class CustomStdout(object):
    def __init__(self, *args, **kwargs):
        self.current_stdout = sys.stdout

    def write(self, string):
        self.current_stdout.write(string.replace('cat', 'dog'))

然而,这也有效:

 >>> import contextlib
>>> with contextlib.redirect_stdout(CustomStdout()):
...     test_file.print_something()
...
This dog was scared.
>>> test_file.print_something()
This cat was scared.

概括

@abarnet 已经提到了其中一些要点,但我想更详细地探讨这些选项。特别是如何跨模块修改它(使用 builtins / __builtin__ )以及如何使该更改只是临时的(使用上下文管理器)。

原文由 MSeifert 发布,翻译遵循 CC BY-SA 3.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题