Заметка первая. Про необычное поведение юникода и 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.