Всем привет, я Вячеслав Жуйко – Lead команды разработки Audiogram в MTS AI.
При переходе от On-Cloud размещений ПО на On-Premises в большинстве случае перед вами неизбежно встанет задача защиты интеллектуальной собственности – и она особенно критична для рынка AI, где задействуются модели, обладающие высокой ценностью для компании. К тому же, в этой сфере широко используется интерпретируемый язык Python, ПО на котором содержит алгоритмы, являющиеся интеллектуальной собственностью компании, но фактически распространяется в виде исходных кодов. Это не является проблемой для On-Cloud решений, но в случае с On-Premises требует особой защиты как от утечек кода, так и самих данных.
Рассказываю реальную историю решения этой, казалось бы, не самой тривиальной задачи.
Почему нам потребовалось шифровать код и данные
Мы с коллегами разрабатываем Audiogram — платформу синтеза и распознавания речи. Она состоит из большого количества микросервисов, связанных между собой. Обычно мы разворачивали это решение On-Cloud, и поэтому задачи защитить код у нас не возникало. Однако все изменилось, после того как к нам пришел заказчик, которому нужно было установить Audiogram On-Premises. Мы не могли передать код программы клиенту — это создало бы опасность кражи нашей интеллектуальной собственности. Именно поэтому мы начали искать способ зашифровать информацию и остановились на одном простом и эффективном варианте. Далее я расскажу подробнее, как сгенерировать и зашифровать код. Итак, разберем все по шагам.
Генерируем из кода на Python код на C++
Для простоты представим, что у нас уже есть минимальный проект на Python, который загружает файл с данными, а затем использует их. Пусть это будет совсем просто:
with open(path, 'rb') as f:
data = f.read()
use_data(data)
Понятно, что мы не можем передавать заказчику ни исходный код, ни тем более данные. Последние предстоит зашифровать, а код обфусцировать.
В случае с кодом я использовал Cython – для генерации из кода на Python кода на C или C++. Вообще способы генерации могут быть разными, а самым распространенным является Setuptools. Однако сходу у меня не вышло написать setup.py для генерации исполняемого файла (не библиотеки), поэтому пошел путем использования Cython через коммандную строку.
Примеры вызовов Cython:
cython -3 --no-docstrings --fast-fail --output-file lib.c lib.py
cython -3 --no-docstrings --fast-fail --cplus --output-file lib.cpp lib.py
cython -3 --no-docstrings --fast-fail --embed --output-file app.c app.py
Рассмотрим используемые параметры:-3
-- версия Python--no-docstrings
-- не включает Python Docstrings в сгенерированный файл--fast-fail
-- процесс генерации прерывается по первой ошибке--embed
-- включает функцию main()
, что позволяет собрать как исполняемый файл и запускать не через интерпретатор, а напрямую: ./app
--cplus
-- генерирует C++
вместо C
Теперь полученные C или C++ файлы нужно собрать. Безусловно, применить можно разные подходы, в том числе написать Makefile. Я пошел схожим путем, как и в случае с Cython, и вызвал сборку из командной строки. Если попробуете повторить, сначала убедитесь, что у вас установлены пакеты build-essential python-dev-is-python3
.
Примеры вызова сборки из коммандной строки:
gcc $(python3.8-config --cflags) -fPIC -g0 -s -shared lib.c -o lib.so
gcc $(python3.8-config --cflags) -fPIC -g0 -s app.c -o app $(python3.8-config --libs --embed)
Рассмотрим используемые параметры:python3.8-config --cflags
-- возвращает CFLAGS-fPIC
-- генерировать позиционно независимый код-g0
-- не включать информацию для отладки-s
-- удаляет таблицу символов и информацию о релокации-shared
-- собирает динамическую библиотекуpython3.8-config --libs --embed
-- возвращает строку с библиотеками для линкера
Параметры -g0
и -s
я добавил для затруднения отладки.
Написал скрипт на Python, который проходится по дереву исходников и выполняет две вышеописанные операции: ситонизирует и собирает, после чего удаляет исходник.
Итак, с кодом разобрались – мы больше не поставляем исходники, заменив их на бинарные ELF файлы без Python Docstrings и отладочной информации. Кстати, приятный бонус – ситонизация может увеличить скорость работы по сравнению с Python, особенно если используется type hinting.
Все ли всегда так гладко? Увы, нет. Cython отстает по фичам от Python, например, не поддерживаются "the walrus operator", Data Classes, а функции из inspect возвращают неадекватные результаты – это только то, с чем столкнулись мы. Это все неприятно, но жить с этим можно. К тому же, где-то с проблемой можно справиться, например, в случае с Data Classes достаточно добавить annotations вручную, после чего их можно использовать.
Шифруем данные
Теперь нам осталось зашифровать данные. Для шифрования используем SDK от одного из решений для HASP, в нашем случае это Sentinel.
Средства шифрования файлов в SDK предоставляются "из коробки". Данные зашифрованы, теперь предстоит научить наш код на Python их расшифровывать. Сделать это можно, просто добавить вызов:
with open(path, 'rb') as f:
data = f.read()
data = decrypt(data)
use_data(data)
Правда, сосчитав количество вариантов загрузки данных из файла в реальном коде, я решил все же пойти другим путем. В самых общих словах – где-то данные загружаются как в приведенном примере, где-то построчно, а самое страшное: f
передается как параметр в сторонний пакет, в который не хотелось бы погружаться вообще.
В итоге, показалось проще сделать на основе Sentinel SDK статическую библиотеку на C++ libsentinel.a с единственной экспортируемой функцией:
std::vector<unsigned char> sentinel_decrypt(const std::string& path);
Почему именно статическую? Если в файловой системе будет лежать библиотека libsentinel.so с экспортируемой функцией sentinel_decrypt(), воспользоваться ей сможет любой.
Написал на Cython подмену стандартного механизма чтения файла:
import os
import io
from typing import Union, List, AnyStr, Iterator
from libcpp.vector cimport vector
from libcpp.string cimport string
cdef extern from "sentinel.h":
vector[unsigned char] sentinel_decrypt(const string& path) except +
def sentinel_open(path: Union[str, bytes, os.PathLike], mode: str, **kwargs) -> 'SentinelFileIo':
return SentinelFileIo(path, mode, **kwargs)
class SentinelFileIo:
def init(self, path: Union[str, bytes, os.PathLike], mode: str, **kwargs) -> None:
if isinstance(path, os.PathLike):
path = str(path)
if isinstance(path, str):
path = path.encode('utf-8')
data = bytes(sentinel_decrypt(path))
encoding = kwargs.get('encoding', None)
if encoding is not None:
data = data.decode(encoding)
self._data = data
self._size = len(self._data)
self._pos = 0
def close(self) -> None:
self._data = None
def closed(self) -> bool:
return self._data is None
def tell(self) -> int:
self._ensure_open()
return self._pos
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
self._ensure_open()
if whence == io.SEEK_SET:
if offset < 0:
raise ValueError(f'negative seek position {offset}')
self._pos = offset
else:
raise io.UnsupportedOperation("can't do nonzero cur-relative seeks")
return self._pos
def read(self, n: int = -1) -> AnyStr:
self._ensure_open()
data = self._data[self._pos:self._pos+n] if n >= 0 else self._data[self._pos:]
self._pos += len(data)
return data
def readline(self, limit: int = -1) -> AnyStr:
self._ensure_open()
data = self._data[self._pos:]
stream = io.BytesIO(data) if isinstance(data, bytes) else io.StringIO(data)
line = stream.readline(limit)
self._pos += len(line)
return line
def readlines(self, hint: int = -1) -> List[AnyStr]:
self._ensure_open()
data = self._data[self._pos:]
stream = io.BytesIO(data) if isinstance(data, bytes) else io.StringIO(data)
lines = stream.readlines(hint)
self._pos += sum([len(line) for line in lines])
return lines
def __enter__(self) -> 'SentinelFileIo':
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
self.close()
return exc_type is None
def __iter__(self) -> Iterator[AnyStr]:
self._ensure_open()
while self._pos < self._size:
yield self.readline()
def _ensure_open(self) -> None:
if self.closed():
raise ValueError('I/O operation on closed file.')
Все, что теперь остается сделать с кодом -- это поменять open()
на sentinel_open()
больше не трогая ни строчки. Теперь код выглядит так:
with sentinel_open(path, 'rb') as f:
data = f.read()
use_data(data)
На самом деле действий нужно чуть больше:
переименовать файл .py -> .pyx
вставить вышеприведенный кусок кода в файл
Теперь с кодом точно все. Осталось сгенерировать C++ код из – теперь уже – Cython кода вышеописанным способом, а также собрать, прилинковав libsentinel.a и библиотеку из Sentinel SDK.
Примеры вызовов:
g++ $(python3.8-config --cflags) -fPIC -g0 -s -shared lib.cpp -o lib.so -lsentinel -lhasp_cpp_linux_x86_64
g++ $(python3.8-config --cflags) -fPIC -g0 -s app.cpp -o app $(python3.8-config --libs --embed) -lsentinel -lhasp_cpp_linux_x86_64
Теперь у нас есть зашифрованный файл данных и бинарный ELF-файл, который сможет расшифровать их при загрузке. Результат достигнут? Казалось бы, да, но есть еще особенность.
В собранном файле, к которому был прилинкован libsentinel.a, можно найти строку с vendor code, имея который и Sentinel SDK, данные возможно расшифровать. На помощь нам приходит утилита из того же Sentinel SDK: Envelope, которая особым образом трансформирует файл, после чего вызов утилиты strings больше не выводит ни единой читаемой строки.
Пример вызова утилиты:
Sentinel-LDK/VendorTools/Envelope/linuxenv --vcf:company-product.hvc --fid:1 input-file.so output-file.so
А как же лицензионная защита упомянутая в заголовке статьи? После обработки утилитой Envelope, ELF-файл может быть использован только на машине с установленной лицензией. А это значит, что код под надежной защитой Вот такой способ мы нашли при установке клиенту On-Premises-версии Audiogram. Пишите в комментариях, была ли полезна вам моя статья, и делитесь лайфхаками, как вы решаете задачу с защитой кода и данных.
P.S.
Решение от Sentinel – это коммерческий продукт и нужна платная лицензия. И Sentinel ушел из России, но есть другие похожие решения. Например, Guardant.
Комментарии (10)
frazer
26.07.2022 16:25А зачем использовать Sentinel, если есть OpenSSL и можно шифровать/дешифровать свободно и все ограниченно только личной фантазией?
slava_zhuyko Автор
26.07.2022 16:30Аппаратная защита + защита от взлома бинарника
frazer
26.07.2022 16:34Ну в данной задаче аппаратная защита, вряд ли даёт преимущества, зашифрованным 256 битным ключом или даже 512 битным, уже не взломать. Вы имеете в виду защита от взлома бирнарника, после применении утилиты Envelope ?
slava_zhuyko Автор
26.07.2022 16:39Да. Для того, чтобы расшифровать нужно иметь ключь, его нужно где-то хранить, если его хранить в необфусцированном бинарнике, то его можно там найти. Еще можно сделать мемори дамп и найти его там. Средства типа Envelope позволяют решить эти проблемы.
Urub
27.07.2022 18:43После запуска - будет расшифровка данных и они и ранее обфусцированные строки программы будут в памяти в открытом виде или применяются еще механизмы защиты ?
slava_zhuyko Автор
27.07.2022 19:22Да. Будут в открытом виде в памяти процесса, но дастать их будет затруднительно, т.к. благодяря Envelope отладчик не работает и дамп снять нельзя.
Urub
27.07.2022 19:32хм, интересно, а есть ли тех. информация как Envelope это удается ?
хотелось бы попробовать это сделать самому )slava_zhuyko Автор
27.07.2022 19:46Я не знаю, но предполагаю, что можно как-то детектировать дебагер. Само название Envelope говорит о том, что изначальный файл оборачивается в этот Envelope в котором, видимо, встроены эти механизмы
eugvig
А после того, как Вы прочитали модель, вы вызываете что-нибудь типа torch.load() ?
slava_zhuyko Автор
Да