Python的print函数能被“黑客攻击”吗?

152
注意:本问题仅供参考。我想了解在Python内部可以深入多少。
不久前,在某个问题中,开始讨论是否可以在调用print后/期间修改传递给打印语句的字符串。例如,考虑以下函数:
def print_something():
    print('This cat was scared.')

现在,当运行print时,终端的输出应该显示:
This dog was scared.

注意,单词“cat”被替换为“dog”。某个地方以某种方式能够修改这些内部缓冲区以更改打印内容。假设这是在原始代码作者未经明确许可的情况下完成的(因此是黑客/劫持)。
特别是来自智者@abarnert的评论,让我开始思考:
引用: 有几种方法可以做到这一点,但它们都非常丑陋,而且不应该这样做。最不丑陋的方法可能是将函数内的code对象替换为具有不同co_consts列表的对象。接下来可能是通过C API访问str的内部缓冲区。[...]
所以,看起来这是真的可能的。
这是我处理这个问题的天真方式:
>>> import inspect
>>> exec(inspect.getsource(print_something).replace('cat', 'dog'))
>>> print_something()
This dog was scared.

当然,exec是不好的,但这并不能真正回答问题,因为它在调用print期间/之后并没有实际修改任何内容。@abarnert解释了如何完成这个任务?

3
顺便提一下,整数的内部存储比字符串要简单得多,浮点数则更是如此。作为额外的奖励,如果将值42更改为23,为什么这是一个坏主意就更加明显了,而将值“我的名字是Y”更改为“我的名字是X”为什么是个坏主意却不那么明显。 - abarnert
4个回答

250

首先,实际上有一种更简单的方法。我们想要做的只是改变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)

或者同样地,您可以对print进行猴子补丁,而是对sys.stdout进行猴子补丁。

此外,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()

使用代码对象进行hack可能会出现什么问题?主要是段错误,耗尽整个堆栈的RuntimeError,可以处理的更正常的RuntimeError,或者在尝试使用它们时可能会引发TypeError或AttributeError的垃圾值。例如,尝试创建一个只有RETURN_VALUE但堆栈上没有任何内容的代码对象(对于3.6+的字节码b'S\0',以前是b'S'),或者在字节码中有LOAD_CONST 0时,使用空元组作为co_consts,或者将varnames减1,以便最高的LOAD_FAST实际上加载freevar/cellvar单元格。如果lnotab足够错误,运行调试器时你的代码将仅产生段错误。
使用bytecode或byteplay不能保护你免受所有这些问题的影响,但它们具有一些基本的健全性检查和好用的辅助工具,例如插入一段代码并让它担心更新所有偏移量和标签,以便您不会弄错等等。(此外,它们使您无需键入那个荒谬的6行构造函数,并且无需调试由此产生的愚蠢的打字错误。)
现在我们来看第二个问题。
我提到了代码对象是不可变的。当然,常量是一个元组,所以我们不能直接更改它。而元组中的东西是一个字符串,我们也不能直接更改它。这就是为什么我不得不构建一个新字符串来构建一个新元组来构建一个新的代码对象。
但如果你可以直接更改字符串呢?
嗯,在底层深处,一切都只是指向某些C数据的指针,对吧?如果你正在使用CPython,那么有一个C API用于访问对象你可以使用ctypes从Python内部访问该API,这是一个非常糟糕的想法,他们在stdlib的ctypes模块中放了一个pythonapi。 :) 你需要知道的最重要的技巧是,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()

如果你想玩这个东西,那么在内部实现上,intstr 简单得多。而且通过更改 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,而标准解释器没有这个特性?


11
@cᴏʟᴅsᴘᴇᴇᴅ 这段代码处理可能是比较合理的 Python,尽管通常只有在更好的原因下才需要操作代码对象(例如将字节码通过自定义优化器运行)。另一方面,访问PyUnicodeObject的内部存储可能仅仅是因为 Python 解释器可以运行它,而并非考虑到 Python 的本质。 - abarnert
4
你的第一个代码片段引发了一个NameError: name 'arg' is not defined的错误。 你的意思是: args = [arg.replace('cat', 'dog') if isinstance(arg, str) else arg for arg in args]吗?一个更好的编写方式可能是:args = [str(arg).replace('cat', 'dog') for arg in args]。甚至更短的选项是:args = map(lambda a: str(a).replace('cat', 'dog'), args)。这个方法的额外好处是args是惰性求值的(使用生成器表达式也可以实现,*args两种方式都可以)。 - Konstantin
1
@cᴏʟᴅsᴘᴇᴇᴅ,如果我没记错的话,我只使用了PyUnicodeObject结构定义,但是将其复制到答案中可能会妨碍阅读。而且,我认为superhackyinternals的自述文件和/或源代码注释实际上解释了如何访问缓冲区(至少足以提醒我下一次关心时;不确定是否足够其他人使用...),这里我不想深入讨论。相关部分是如何通过ctypes从活动Python对象获取其PyObject *(以及可能模拟指针算术,避免自动char_p转换等)。 - abarnert
1
@jpmc26 我认为你不需要在导入模块之前这样做,只要在它们打印之前就可以了。模块每次都会进行名称查找,除非它们明确地将 print 绑定到一个名称上。你也可以为它们绑定名称 printimport yourmodule; yourmodule.print = badprint - leewz
1
@l'L'l我添加了一些关于你可以使用每种技术引起哪些问题的内容。但实际上,你不能通过篡改int来唤醒克苏鲁(Cthulhu),只能通过str来唤醒它,所以不要太担心。:) - abarnert
显示剩余10条评论

38

猴子补丁print

print是一个内置函数,因此它将使用在builtins模块中定义的print函数(或者Python 2中的__builtin__)。因此,每当您想要修改或更改内置函数的行为时,您可以简单地重新分配该模块中的名称。

这个过程被称为猴子补丁

# 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.

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

test_file.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.

所以它确实按照预期工作。

但是,如果您只想暂时地用monkey-patch来打印输出,您可以将其包装在一个上下文管理器中:

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.

这就是通过猴子补丁(monkey-patching)“黑客”print的方法。

修改目标而不是print

如果您查看print的签名,您会注意到一个file参数,默认情况下为sys.stdout。请注意,这是动态默认参数(每次调用print时它真正地查找sys.stdout),而不像Python中的普通默认参数。因此,如果您更改sys.stdoutprint实际上将打印到另一个目标,更方便的是Python还提供了redirect_stdout函数(从Python 3.4开始,但很容易为早期版本的Python创建等效函数)。

缺点是它不适用于不将内容打印到sys.stdoutprint语句,并且创建自己的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__)以及如何使该更改仅为临时性的(使用上下文管理器)。


5
是的,任何人应该真正想要做的最接近这个问题的事情就是使用redirect_stdout,因此有一个清晰的答案来指导这一点是很好的。 - abarnert

5
让我们将其与帧内省相结合!
import sys

_print = print

def print(*args, **kw):
    frame = sys._getframe(1)
    _print(frame.f_code.co_name)
    _print(*args, **kw)

def greetly(name, greeting = "Hi")
    print(f"{greeting}, {name}!")

class Greeter:
    def __init__(self, greeting = "Hi"):
        self.greeting = greeting
    def greet(self, name):
        print(f"{self.greeting}, {name}!")

这个技巧可以在调用函数或方法前添加一些内容。这对于日志记录或调试非常有用,特别是它让你“劫持”第三方代码中的打印语句。


5

一种简单的方法来捕获print函数产生的所有输出并进行处理,是将输出流改为其他类型,例如文件。

我将使用PHP的命名约定(ob_start, ob_get_contents,...)

from functools import partial
output_buffer = None
print_orig = print
def ob_start(fname="print.txt"):
    global print
    global output_buffer
    print = partial(print_orig, file=output_buffer)
    output_buffer = open(fname, 'w')
def ob_end():
    global output_buffer
    close(output_buffer)
    print = print_orig
def ob_get_contents(fname="print.txt"):
    return open(fname, 'r').read()

使用方法:

print ("Hi John")
ob_start()
print ("Hi John")
ob_end()
print (ob_get_contents().replace("Hi", "Bye"))

输出以下内容

你好 John 再见 John


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