有没有可能“黑进”Python 的打印功能?

注意: 本问题仅供参考。我很有兴趣看到 Python 的内部结构有多深入。

不久前,某个 有个问题内部开始讨论传递给 print 语句的字符串是否可以在调用 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 的解释去做呢?

15529 次浏览

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

或者,类似地,您可以对 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、可以处理的更普通的 RuntimeError,或者在您尝试使用它们时可能只会引发 TypeErrorAttributeError的垃圾值。例如,尝试创建一个代码对象,只有一个 RETURN_VALUE,栈上没有任何东西(字节码 b'S\0'为3.6 + ,之前是 b'S') ,或者当字节码中有一个 LOAD_CONST 0时,使用空元组 co_consts,或者使用 varnames减1,这样最高的 RuntimeError0实际上加载一个 freevar/cellvar 单元格。对于一些真正有趣的事情,如果您得到的 RuntimeError1足够错误,您的代码将只有在调试器中运行 Segfault。

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


现在看第二条。

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

但是如果您可以直接更改字符串呢?

好吧,在被子的深处,一切都只是一个指向某些 C 数据的指针,对吗?如果您正在使用 CPython,则有 访问对象的 C API您可以使用 ABC0从 Python 内部访问该 API,这是一个非常糟糕的想法,以至于他们在 stdlib 的 ABC0模块中放置了一个 pythonapi。:)您需要知道的最重要的技巧是,id(x)是内存中指向 x的实际指针(作为 int)。

不幸的是,字符串的 CAPI 不允许我们安全地访问已经冻结的字符串的内部存储。所以安全起见,我们只需要 读取头文件自己去找那个仓库。

如果你使用 CPython 3.4-3.7(旧版本不一样,谁知道以后会怎样) ,一个纯 ASCII 模块中的字符串文字将会使用紧凑的 ASCII 格式存储,这意味着结构提前结束,ASCII 字节的缓冲区紧随其后在内存中。如果在字符串中放入一个非 ASCII 字符或某些类型的非文字字符串,这将会中断(可能会使用 Segfault) ,但是您可以阅读其他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,而股票解释器不是?

捕获 print函数的所有输出并进行处理的一个简单方法是将输出流更改为其他内容,例如文件。

我将使用 PHP命名约定(开始B _ get _ content、 ...)

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"))

会打印出来

嗨,约翰 再见,约翰

猴子补丁 print

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

这个过程称为 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.

或者如果你把它写到一个文件中:

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.

所以它确实如预期的那样工作。

然而,如果你只是暂时想修补打印,你可以把它包装在上下文管理器中:

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。注意,这是一个动态默认参数(每次调用 print时,真的都会查找 sys.stdout) ,与 Python 中的普通默认参数不同。因此,如果你改变 sys.stdout print实际上会打印到不同的目标甚至更方便,Python 也提供了一个 file0函数(从 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__) ,以及如何使这种修改只是临时的(使用上下文管理器)。

让我们结合框架自省!

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}!")

您会发现这个技巧用调用函数或方法作为每次问候语的前缀。这对于日志记录或调试可能非常有用; 特别是当它允许您“劫持”第三方代码中的打印语句时。