Заметка первая. Про необычное поведение юникода и CSV в питоне

Посвящается Полине, которая рассказала мне про странное поведение своей питоновской программы и тем самым подсказала потрясающую идею для сервиса на CTF.

Всё началось месяца полтора назад. Полина рассказала мне, как она со своим начальником долго искала проблему в скрипте, который всего-навсего читал большой юникодный файл и как-то его обрабатывал. Проблема заключалась в том, что в файле было, например, 4 миллиона строк, а объектов в итоге оказывалось на два больше — 4 000 002. Это при том, что файл обрабатывался стандартной для питона конструкцией:

import codecs

with codecs.open('file.txt', 'r', 'utf-8') as f:
    for line in f:
        # Обрабатываем строчку line

Я никак не мог за такое не ухватиться. Расспросил Полину подробно, выяснил, что ничего стороннего для работы с файлом не использовалось, и вообще конструкция была проста как топор. Откуда она берёт лишние строчки?

Пришёл домой и сразу сел проверять:

import codecs

with codecs.open('file.txt', 'r', 'utf-8') as f:
    print(len(f.readlines()))

Попробовал на нескольких файлах и всё, конечно, работает верно. Но Полина упоминала, что в её файле были какие-то плохие символы, из-за которых и возникали лишние строчки. Я решил сгенерировать файл с кучей случайных юникодных символов:

import codecs
import random

def generate_string():
    """
    Генерируем строчку случайных символов длины 100.
    Специально обходим символы с кодами 10 и 13, чтобы строка не содержала переводов строк
    """
    return ''.join(chr(random.randint(20, 10000)) for _ in range(100))

with codecs.open('file.txt', 'w', 'utf-8') as f:
    # Печатаем в файл 1000 строк
    for i in range(1000):
        f.write(generate_string() + '\n')

На всякий случай открываю файл в Фаре. Его немножко корёжит, но строчек он показывает ровно 1001, что абсолютно верно, ведь в файле 1000 переводов строк:

Запустил скрипт и удивился: он видит в файле 1076 строчек, а вовсе не 1000 и не 1001, как можно было бы предположить. Разобравшись и сгенерировав файл поменьше, нашёл тот самый символ, который всё портит. Им оказался символ с кодом 8233 — Paragraph Separator. Рядом с ним есть ещё один такой же символ — Line Separator, он имеет код 8232. И если подумать, то нет ничего странного, что с точки зрения модуля codecs в файле за этими символами начинаются новые строки.

Кстати, новый open() в третьем питоне сам умеет читать файлы в UTF-8, модуль codecs можно не использовать. Но в данном случае он ведёт себя иначе:

with open('file.txt', 'r', encoding='utf-8') as f:
    print(len(f.readlines()))

На том же самом файле этот код выводет 1000. Такая разница в поведении уже кажется странной и неприятной, но давайте вернёмся к коду с модулем codecs и копнём поглубже.

В юникоде есть символы, которые заставляют модуль codecs думать, что в файле началась новая строка. Это же отличное место для модифицированной CRLF-инъекции! Правда, в данном случае она будет скорее ParagraphSeparator-инъекцией, но сути это не меняет. Представим код, который сохраняет пользовательские строчки в файл, одну за другой. Чтобы злоумышленник не мог повлиять на структуру файла, из строчек выкидываются все символы ‘\r’ и ‘\n’. Раньше мы могли думать, что мы в безопасности, но теперь мы знаем — злоумышленник может использовать Paragraph Separator или Line Separator, чтобы повлиять на структуру файла.

Приглашаем на наш праздник CSV

Ну что ж, разобрались с Paragraph Separator. Теперь пора на основе этой фичи вытворить что-нибудь совсем интересное. Наш следующий скрипт будет сохранять юникодные данные в CSV-файл, а другой — считывать эти данные строчка за строчкой. Включаем питоновский модуль csv и экспериментируем:

import codecs
import csv

with codecs.open('file.txt', 'w', 'utf-8') as f:
    writer = csv.DictWriter(f, fieldnames=['first', 'second'])
    writer.writeheader()
    writer.writerow({'first': 'Hello world', 'second': 'Text with\n newline'})

В полученном файле оказывается три строки:

first,second
Hello world,"Text with
 newline"

Умный DictWriter поставил кавычки в нужных местах, и теперь симметричный ему DictReader отлично считает эти данные:

import codecs
import csv

with codecs.open('file.txt', 'r', 'utf-8') as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row)

Выводит то, что надо, отлично:

{'first': 'Hello world', 'second': 'Text with\n newline'}

Начинаем вставлять везде где ни попадя Paragraph Separator:

writer.writerow({'first': 'Hello world', 'second': 'Text with' + chr(8233) + ' paragraph separator'})

Внезапно на выходе получаем файл без кавычек. DictWriter не считает этот символ чем-то опасным:

first,second
Hello world,Text with
 paragraph separator

Ну что ж, натравим теперь на этот файл DictReader. В первый раз не мог поверить своим глазам, если честно:

{'first': 'Hello world', 'second': 'Text with\u2029'}
{'first': ' paragraph separator', 'second': None}

То есть DictReader повёл себя несимметрично по отношению к DictWriter’у — сохраняли одну строчку, а получили две! Пользователь, влияющий на данные во втором поле первой строки, смог повлиять на первое поле во второй строке.

Кульминация

Собираем всё вместе и задаём вопрос знатокам питона:

import codecs
import csv

data = {'first': '...', 'second': '...'}

with codecs.open('file.txt', 'w', 'utf-8') as f:
    writer = csv.DictWriter(f, fieldnames=data.keys())
    writer.writeheader()
    writer.writerow(data)

count = 0
with codecs.open('file.txt', 'r', 'utf-8') as f:
    reader = csv.DictReader(f)
    for row in reader:
        count += 1

print(count)

Каким может быть вывод переменной count в конце программы?

Теперь-то мы знаем, что абсолютно любым — хоть 1, хоть 30, хоть 100. Например, инициализировав переменную data так:

P_SEP = chr(8233)
data = {'first': P_SEP * 10, 'second': P_SEP * 10}

получим ответ 20.

Известно ли про это что-нибудь миру?

В документации к модулю csv написано: если вы используете csv.reader(), открывайте файл с опцией newline=’’:

Но, во-первых, мы используем не простой csv.reader(), а более умный csv.DictReader(), а, во-вторых, модуль codecs не поддерживает опцию newline, мы просто не сможем открыть файл с ней.

Интернет про эту проблему мне тоже ничего не рассказал. Но, может, я просто плохо искал?..

В следующей заметке я рассказал, как из этого получился отличный сервис для PHDays CTF.

Send
Share
Pin