Анатомия LLM RCE
По мере того, как большие языковые модели (LLM) становятся все более сложными, и разработчики наделяют их новыми возможностями, резко увеличиваются случаи возникновения угрозы безопасности. Манипулируемые LLM представляют собой не только риск нарушения этических стандартов, но и реальную угрозу безопасности, способную скомпрометировать системы, в которые они интегрированы. Такие критические уязвимости недавно были выявлены в различных приложениях, включая платформу управления данными для LLM под названием LlamaIndex, SQL-агент Vanna.AI и даже фреймворк для интеграции LLM LangChain.
В этой статье мы проанализируем данный риск, рассмотрев анатомию уязвимости удалённого выполнения кода (RCE) в LLM. Мы начнём с объяснения того, как языковые модели могут исполнять код, и подробно разберем конкретную уязвимость, которую нам удалось выявить.
Если просто дать LLM текст, он будет генерировать ответы бесконечно; если научить LLM исполнять код, он сможет решать задачи бесконечно.
Сегодня это, вероятно, является основной идеей для большинства компаний, работающих в сфере ИИ, но с точки зрения безопасности такая способность может оказаться «палкой о двух концах». Мощные LLM могут быть использованы против своих же разработчиков злоумышленниками. LLM, как следует из названия, представляет собой крупную языковую модель. Она получает данные от пользователя и выдаёт текст построчно, который может представлять собой ответ на вопрос, генерацию, а также код. Однако для того, чтобы модель могла выполнять задачи и исполнять код, необходимо внешнее программное обеспечение, а не сама нейросеть, как LLM.
Зная, что LLM можно обойти или «взломать» практически в любом случае, и что сама модель не способна исполнять код, мы должны задаться вопросом не о том, является ли LLM уязвимой для атаки сама по себе, а уязвимы ли интеграции LLM с внешними компонентами.
Прояснение в выполнение кода LLM
Чтобы лучше понять интерфейс LLM, рассмотрим простую интеграцию, которая используется в LoLLMs. LoLLMs представляет собой центр для крупномасштабных языковых моделей (LLM) и мультимодальных интеллектуальных систем. Проект направлен на создание удобного интерфейса для доступа и использования различных LLM и других моделей ИИ для выполнения широкого спектра задач. В нашем случае мы использовали интеграцию с OpenAI, версию gpt-4-turbo-2024-04-09.
Мы сосредоточимся на функции расчётов, которая при необходимости позволяет LLM выполнять арифметические операции. В общем случае компоненты в такой интеграции между LLM и внешним элементом можно представить в виде следующей схемы.
Давайте рассмотрим, как эти компоненты проявляются в функции вычислений LoLLMs, представленной на рисунке ниже.
На первом этапе у нас есть системная подсказка. Системная подсказка — это инструкция для LLM, добавляемая к каждой сессии, предназначенная для настройки поведения, тона, стиля и содержания ответов модели в ходе данной сессии. Системная подсказка также может показать доступные ей инструментах. В нашем случае это функция арифметических вычислений. В приведенном ниже фрагменте системной подсказки можно увидеть соответствующие части, относящиеся к нашему калькулятору.
!@>system: !@>Available functions: ... Function: calculate Description: Whenever you need to perform mathematic computations, you can call this function with the math expression and you will get the answer. Parameters: - expression (string): ... Your objective is interact with the user and if you need to call a function, then use the available functions above and call them using the following json format inside a markdown tag:```function { "function_name":the name of the function to be called, "function_parameters": a list of parameter values } ``` !@>current_language: english !@>discussion_messages:
В начале системной подсказки LLM сообщается, что ей доступны определенные функции, а затем предоставляется описание каждой из них. В их числе представлена наша функция вычислений с описанием, позволяющим модели понимать, что она может использовать ее для выполнения математических расчетов по мере необходимости. В конце подсказки LLM указывается ее цель: взаимодействовать с пользователем и, если потребуется, вызвать функции с использованием простого формата JSON. В этом формате нужно указать название функции и ее параметры. Ожидается, что LLM “поймет” доступные ей возможности и, увидев сложное математическое выражение, представит результат следующим образом:
```function { "function_name": "calculate", "function_parameters": { "expression": " " } } ```
На данный момент мы рассмотрели следующие компоненты: Системную подсказку, которая обучает LLM, как использовать интеграцию, математическое выражение, вводимое пользователем, и конкретный формат, который LLM должна использовать для выполнения функции. Как же мы переходим от простого вывода LLM к выполнению кода? Здесь на помощь приходит немного классического кода на Python.
Давайте проследим за стеком вызовов, начиная с функции interact_with_function_call(), которая вызывает функцию generate_with_function_calls(), выполняющую вывод LLM с использованием ввода пользователя. Эта функция должна возвращать текст, сгенерированный LLM, а также массив вызовов функций, которые необходимо выполнить в соответствии с вводом пользователя (шаг 3 на Рисунке 1). Для этого она вызывает функцию extract_function_calls_as_json().
# https://github.com/ParisNeo/lollms/blob/ccf237faba17935efd1e8ecbbf12f494c837333b/lollms/personality.py#L3870-L3871 # Extract the function calls from the generated text. function_calls = self.extract_function_calls_as_json(generated_text) return generated_text, function_calls
Функция, отвечающая за извлечение вызовов функций, просто обнаруживает блоки кода, выведенные LLM, проверяет, соответствуют ли они формату, необходимому для вызова функции, и извлекает имя функции и параметры следующим образом:
# https://github.com/ParisNeo/lollms/blob/ccf237faba17935efd1e8ecbbf12f494c837333b/lollms/personality.py#L4049 function_calls = [] for block in code_blocks: if block["type"]=="function" or block["type"]=="json" or block["type"]=="": content = block.get("content", "") try: # Attempt to parse the JSON content of the code block. function_call = json.loads(content) if type(function_call)==dict: function_calls.append(function_call) elif type(function_call)==list: function_calls+=function_call except json.JSONDecodeError: # If the content is not valid JSON, skip it. continue
После извлечения объекта вызова функции, содержащего имя функции и ее параметры, функция interact_with_function_call() использует execute_function_calls() для выполнения этого вызова. Для выполнения указанного имени функции execute_function_calls() ищет имя в доступных определениях функций (определение функции для каждого из доступных инструментов) и просто вызывает ее:
# https://github.com/ParisNeo/lollms/blob/ccf237faba17935efd1e8ecbbf12f494c837333b/lollms/personality.py#L3930-L3931 fn = functions_dict.get(function_name) if fn: function = fn['function'] try: # Assuming parameters is a dictionary that maps directly to the function's arguments. if type(parameters)==list: f_parameters ={k:v for k,v in zip([p['name'] for p in fn['function_parameters']],parameters)} result = function(**f_parameters) results.append(result) elif type(parameters)==dict: result = function(**parameters) results.append(result)
Последним шагом для компонента (4) является вызов функции calculate:
def calculate(expression: str) -> float: try: # Add the math module functions to the local namespace allowed_names = {k: v for k, v in math.__dict__.items() if not k.startswith("__")} # Evaluate the expression safely using the allowed names result = eval(expression, {"__builtins__": None}, allowed_names) return result except Exception as e: return str(e)
Функция calculate просто принимает математическое выражение, предоставленное LLM, и использует eval для вычисления его в песочнице Python. После всех этих шагов LLM получит отформатированный результат и создаст окончательный вывод, который будет возвращен пользователю в компоненте (5). Теперь у нас есть полное понимание процесса от ввода пользователя до выполнения кода.
От командной строки к выполнению произвольного кода
Теперь, надев шляпу злодея, мы рассмотрим безопасность этой интеграции. Можем ли мы заставить LLM выполнять любой код, который нам нравится?
Начнем с изучения песочницы Python в функции calculate(). Мы видим, что выражение передается в eval без каких-либо встроенных функций ("__builtins__": None) и с ограниченным списком разрешенных имен, взятых из модуля math с помощью выражения k: v for k, v in math.__dict__.items() if not k.startswith("__"), т.е. все функции, кроме внутренних, которые обычно начинаются с __. Достаточно ли этого, чтобы предотвратить выполнение произвольного кода?
Песочница для кода на Python представляет собой сложную задачу из-за динамической типизации, которая позволяет коду изменять себя, а также из-за мощных встроенных функций, которые трудно ограничить, не нарушив функциональность. Легче всего продемонстрировать, как выполнить произвольный код, исполнив команду на сервере, на котором работает песочница Python. Это можно сделать с помощью функции os.system() в Python.
В нашем случае песочница пытается предотвратить импорт любых внешних модулей, отключив встроенные функции. Чтобы обойти это ограничение, мы воспользуемся известным приемом PyJail (одним из многих), который извлекает объект _frozen_importlib.BuiltinImporter из нового кортежа и использует его для импорта модуля os следующим образом: ().__class__.__base__.__subclasses__()[108].load_module(‘os’). После импорта модуля остается лишь вызвать функцию .system(), чтобы запустить любую команду на сервере.
Попробуем попросить LLM вычислить это выражение и посмотрим, сможем ли мы выполнить произвольный код. Начнем с запроса вычисления простого математического выражения, за которым последует наш полезный код.
Мы знаем, как обойти песочницу Python, но нам нужно заставить LLM отправить наше входное выражение в функцию calculate, чтобы это сработало. Похоже, что GPT-4o чувствует, что что-то не так, и даже не пытается вызвать эту функцию. Вероятно, это связано с безопасной настройкой OpenAI. Мы понимаем, что для выполнения произвольного кода нам не только нужно обойти песочницу, но и обойти выравнивание LLM, используя какую-то форму jailbreak.
Стоит отметить, что поскольку LoLLMs не зависит от конкретной модели и может работать с несколькими моделями, требуемый jailbreak зависит от модели. У нас есть два варианта: либо мы «взламываем» модель и заставляем её соглашаться на атаку и выполнять произвольный код на сервере, либо мы обманываем её, чтобы она вызвала уязвимую функцию, не осознавая, что это скомпрометирует сервер. Последний вариант может быть более изящным, поэтому мы выберем его. Помните, что для работы нашего внешнего исполнителя кода всё, что LLM должно сделать, это вывести правильно отформатированный блок кода JSON, содержащий имя функции и её параметры. Давайте наивно попросим LLM вывести его.
Похоже, что GPT-4o не так легко обмануть. Нам нужно придумать что-то другое, что заставит его вывести этот JSON, не осознавая его истинного назначения. А что если мы попросим его помочь в форматировании JSON?
Это сработало! GPT-4o думает, что просто помог нам с этим простым JSON, но на самом деле это активировало наше выполнение кода, и файл был создан. Вот так мы можем использовать инструменты, доступные в LLM, и заставить его выполнять произвольный код, начиная с простого текстового запроса на естественном языке. Эта уязвимость была раскрыта команде LoLLMs и была исправлена. Ей был присвоен CVE-2024-6982.
Интеграции LLM — это меч с двумя лезвиями. Мы получаем лучшую функциональность и улучшенный пользовательский опыт, но это связано с риском безопасности. В конечном итоге мы должны понимать, что любая возможность, предоставленная LLM, может быть использована против системы злоумышленником. Техники постэксплуатации могут сосредоточиться как на самом LLM, например, через внедрение скрытых уязвимостей, извлечение данных или дальнейшую эксплуатацию интеграций, так и на традиционных компонентах, не связанных с LLM, таких как повышение привилегий на хосте или боковое перемещение.