Возможно ли "взломать" функцию печати Python?

Примечание. Этот вопрос предназначен только для информационных целей. Мне интересно посмотреть, как глубоко внутри Python можно с этим справиться.

Не так давно в рамках некоторого вопроса началось обсуждение вопроса о том, могут ли быть изменены строки, переданные для операторов печати после/во время вызова print. Например, рассмотрим функцию:

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

Теперь, когда print выполняется, тогда вывод на терминал должен отображаться:

This dog was scared.

Обратите внимание, что слово "кошка" было заменено словом "собака". Что-то где-то каким-то образом удалось изменить эти внутренние буферы, чтобы изменить то, что было напечатано. Предположим, что это сделано без явного разрешения автора исходного кода (следовательно, взлома/угона).

Этот комментарий от мудрых @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 объяснил это?

Ответы

Ответ 1

Во-первых, на самом деле гораздо менее хакерский путь. Все, что мы хотим сделать, это изменить то, что 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 … idea. Ну, конечно, там много не так, но меньше того, что следует здесь...


Но если вы хотите изменить константы кода объекта функции, мы можем это сделать.

Если вы действительно хотите поиграть с объектами кода для реального, вы должны использовать библиотеку, такую как 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 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 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()

Что может пойти не так, как взломать объекты кода? В основном просто segfaults, RuntimeError которые съедают весь стек, более нормальное RuntimeError которое можно обрабатывать, или значения мусора, которые, вероятно, просто поднимут TypeError или AttributeError когда вы попытаетесь их использовать. Например, попробуйте создать объект кода с помощью только RETURN_VALUE с ничем в стеке (байт-код b'S\0' для 3. 6+, b'S' before) или с пустым кортежем для co_consts когда LOAD_CONST 0 в байт-коде, или с varnames уменьшенными на 1, поэтому самый высокий LOAD_FAST фактически загружает ячейку freevar/cellvar. Для какой-то настоящей забавы, если вы неправильно используете lnotab, ваш код будет только segfault при запуске в отладчике.

Использование bytecode или byteplay не защитит вас от всех этих проблем, но у них есть некоторые основные проверки на здравомыслие и хорошие помощники, которые позволяют вам делать такие вещи, как вставлять кусок кода и не беспокоиться об обновлении всех смещений и меток, чтобы вы не может ошибиться и так далее. (Кроме того, они не позволяют вам вводить этот нелепый 6-строчный конструктор и должны отлаживать глупые опечатки, которые исходят от этого).


Теперь на # 2.

Я упомянул, что объекты кода неизменяемы. И, конечно, константы - кортеж, поэтому мы не можем напрямую это изменить. И вещь в кортете const - это строка, которую мы также не можем изменить напрямую. Вот почему мне пришлось построить новую строку для создания нового кортежа для создания нового кодового объекта.

Но что, если вы можете напрямую изменить строку?

Ну, достаточно глубоко под обложками, все просто указатель на некоторые данные С, не так ли? Если вы используете CPython, там есть API C для доступа к объектам, и вы можете использовать ctypes для доступа к этому API из самого Python, что является такой ужасной идеей, что они помещают pythonapi прямо в модуль stdlib ctypes. :) Самый важный трюк, который вам нужно знать, это то, что id(x) является фактическим указателем на x в памяти (как int).

К сожалению, C API для строк не позволит нам безопасно получить во внутреннем хранилище уже замороженной строки. Поэтому будьте осторожны, давайте просто прочитайте файлы заголовков и найдите это хранилище.

Если вы используете CPython 3.4 - 3.7 (он отличается для более старых версий и кто знает в будущем), строковый литерал из модуля, который сделан из чистого ASCII, будет храниться с использованием компактного формата ASCII, что означает, что структура заканчивается раньше, и буфер ASCII-байтов следует сразу в памяти. Это сломается (как, вероятно, segfault), если вы поместите символ не ASCII в строку или некоторые типы нелиберальных строк, но вы можете прочитать другие 4 способа доступа к буферу для разных типов строк.

Чтобы сделать вещи немного проще, я использую проект superhackyinternals у своего GitHub. (Он намеренно не устанавливается в pp-installable, потому что вы действительно не должны использовать это, кроме как экспериментировать с вашей локальной сборкой интерпретатора и т.п.).

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 в подсказке, он попал в какой-то непрерывный бесконечный цикл. Предположительно, он использует номер 2 для чего-то в своем REPL-цикле, в то время как интерпретатор запаса нет?

Ответ 2

print обезьян-патчей

print - встроенная функция, поэтому она будет использовать функцию print определенную в модуле builtins модулей (или __builtin__ в 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.

Или если вы напишете это в файл:

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 по умолчанию. Обратите внимание, что это динамический аргумент по умолчанию (он действительно ищет sys.stdout каждый раз, когда вы вызываете print), а не как обычные аргументы по умолчанию в Python. Поэтому, если вы измените sys.stdout print будет фактически печатать на другую цель, еще более удобную, чтобы Python также предоставлял функцию redirect_stdout (начиная с Python 3.4, но легко создать эквивалентную функцию для более ранних версий 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__) и как сделать это изменение только временным (с использованием контекстных менеджеров).

Ответ 3

Простой способ захватить весь вывод из функции 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"))

Будет печатать

Привет, Джон Bye John

Ответ 4

Позвольте объединить это с интроспекцией кадра!

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

Вы найдете этот трюк для каждого приветствия с помощью вызывающей функции или метода. Это может быть очень полезно для ведения журнала или отладки; тем более, что он позволяет вам "захватывать" утверждения печати в стороннем коде.