Головна / Статті

Захисники діапазону для AI-агентів: декодування прихованих IP та доменів перед запуском

Захисники діапазону для AI-агентів: декодування вкладених форматів кодування base64, hex та URL, нормалізація незвичайних форматів IP та фільтрація імен файлів, щоб цілі, які не належать до діапазону, ніколи не виконувалися.

Видавець: REACTAPP.TOP6 хв читання1434 слів

Джерела: Keeping an Autonomous Agent Inside the Lines

Зазвичай концепції агентів розглядають їх як надійні компоненти. Вони виконують код у сандбоксі, який належить команді, редагують файли, які їм надано, та читають журнали, записані командою. Однак існує ситуація, яка підриває цю довіру: агент працює з текстом, написаним іншою особою, і частина цього тексту призначена для того, щоб змусити його виконувати дії, які виходять за межі його завдань.

Сценарій

Уявімо рекурсивну модель мови, найняту для санкціонованого тесту на проникнення. У її інструкціях вказані діапазони IP та домени, до яких вона може отримати доступ, а все, що знаходиться поза цим списком, заборонено, незалежно від шляху, яким модель намагається до нього дістатися. У певний момент модель генерує команду шелу. Між цим виводом та справжнім шелом має знаходитися контрольна точка, яка сама має контролювати ці межі, замість того щоб сподіватися, що модель про це пам’ятатиме.

Порівняння команди зі списком дозволених рядків є недостатнім. Зловмисники та моделі, які піддаються впливу прочитаного тексту, рідко вказують заборонену адресу відкрито. Вони її приховують, тому система контролю має бути такою ж ефективною у виявленні, як і вони у приховуванні.

Приховано в кодуванні

Припустимо, єдиним дозволеним об’єктом є target.htb. Примітивна перевірка діапазону сканує команду на наявність підрядків, схожих на домени, та порівнює їх із цим значенням. Цей підхід працює доти, доки команда не виглядає ось так:

curl http://localhost/exec?cmd=ping%20192.168.2.1

192.168.2.1 не з’являється у чистому тексті, тому що він кодується у відсотках всередині URL. Декодуйте його один раз — і він буде прямо там. Обгорніть його у base64, розділіть на частини за допомогою шістнадцяткових ескейпів чи використайте кілька рівнів кодування, і перевірка, яка читає лише буквальний текст, пройде для кожної з версій, хоча кожна з них відноситься до одного й того ж хоста, що не належить до діапазону.

Це не особливість одного формату кодування. Зазвичай значення можна записати багатьма еквівалентними способами, а валідатор, який перевіряє поверхневий рядок, бере до уваги лише один з них.

Рішення полягає у спочатку декодуванні, а потім оцінці. Перш ніж перевірка діапазону запитує, чи дозволена адреса, вона рекурсивно знімає кожне знайдене кодування та оцінює отримані чисті значення:

def _de_cloak_payloads(cmd: str, depth: int = 0, max_depth: int = 3) -> set[str]:
    """
    Recursively searches for base64, hex, and URL encodings inside cmd.
    Returns a set of all extracted/decoded plain strings.
    """
    extracted = {cmd}
    if depth >= max_depth:
        return extracted

    # 1. URL Decoding
    decoded_url = urllib.parse.unquote(cmd)
    if decoded_url != cmd:
        extracted.update(_de_cloak_payloads(decoded_url, depth + 1, max_depth))

    # 2. Hex escape and raw hex string decoding
    for match in re.findall(r"(?:\\x[0-9a-fA-F]{2})+", cmd):
        hex_bytes = bytes.fromhex(match.replace("\\x", ""))
        extracted.update(_de_cloak_payloads(hex_bytes.decode("utf-8", errors="ignore"), depth + 1, max_depth))

    # 3. Base64 decoding
    for match in re.findall(r"\b[A-Za-z0-9+/]{12,}={0,2}\b", cmd):
        padded = match + "=" * ((4 - len(match) % 4) % 4)
        decoded = base64.b64decode(padded.encode("ascii")).decode("utf-8", errors="ignore")
        extracted.update(_de_cloak_payloads(decoded, depth + 1, max_depth))

    return extracted

Рекурсія передає результат кожної спроби декодування назад у ту саму функцію, існуючи з обмеженням за глибиною. Уявіть собі URL-кодування, обгорнуте навколо base64, яке, у свою чергу, обгорнуте навколо ескейп-символів hex: воно розпаковується крок за кроком, і кожна струнка, що утворюється на цьому шляху, включаючи найвнутрішню, додається до набору, який згодом перевіряється.

У результаті отримується набір: початкова команда плюс кожне просте значення, яке можна відновити шляхом декодування, на глибину до трьох рівнів. Кожен з них перевіряється щодо діапазону допуску. Якщо хоча б один містить адресу поза дозволеним діапазоном, вся команда відхиляється, навіть якщо у буквальному тексті моделі ніколи не згадувалася ця адреса.

Приховано у форматах чисел

Кодування — це не єдиний спосіб маскування. IP-адреса — це число, а числа мають кілька допустимих форм запису. 192.168.2.1 може бути одним 32-бітним цілим числом, чотирма октальними або шістнадцятковими компонентами чи комбінацією з заповнювачами, яку приймають більшість інструментів. Регулярні вирази, налаштовані під десяткову форму з крапками, не враховують усі ці варіанти, проте справжній мережевий стек перетворює їх на одну й ту саму машину.

def _normalize_ip_token(token: str) -> str | None:
    token = token.strip().lower()

    # A bare integer or hex integer standing in for a full IP
    if token.isdigit() or (token.startswith("0x") and all(c in "0123456789abcdef" for c in token[2:])):
        val = int(token, 16) if token.startswith("0x") else int(token)
        if 0 <= val <= 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:
            if val > 1024 or val in (0,):
                return str(ipaddress.ip_address(val))

    # Dotted octal or dotted hex, one part at a time
    if "." in token:
        parts = token.split(".")
        if len(parts) == 4:
            normalized_parts = []
            for p in parts:
                val = int(p, 16) if p.startswith("0x") else int(p, 8) if p.startswith("0") and len(p) > 1 else int(p)
                if 0 <= val <= 255:
                    normalized_parts.append(str(val))
            if len(normalized_parts) == 4:
                return ".".join(normalized_parts)
    return None

Кожна неправильна форма запису переписується у стандартну форматування з крапками, перш ніж її порівнюють із дозволеними діапазонами. Це означає, що 0xC0A80201 та 192.168.2.1 вважаються однаковим хостом, який має різну «одяг».

Домени та назви файлів

Перевірка IP-адрес полягає у виявленні маскувань. Перевірка доменів має протилежні труднощі: мережа має бути достатньо широкою, щоб виявити справжні доменні посилання, не позначаючи кожну назву файлу, яка містить крапку.

Регулярний вираз, створений для пошуку елементів у форматі домену, так само ефективно підходить до config.yaml чи report.pdf, як і до evil.example.com, адже кожен з них має однакову структуру — літери, крапка та ще літери. Додаткове декодування тут не допомагає. Допомагає другий список — поширені розширення файлів, які порівнюються з тим, що знаходиться на місці верхнього рівня домену.

tld = token.rsplit(".", 1)[-1]
if tld in _FILE_EXTENSIONS:
    continue
if not any(token == d or token.endswith("." + d) for d in self.domains):
    raise ScopeViolation(f"Domain {token!r} is outside engagement scope.")

Список потрібно налаштовувати, адже обидва крайнощі є шкідливими. Якщо він занадто короткий, звичайні шляхи до файлів постійно викликають тривогу, тож користувачі починають ігнорувати цей інструмент повністю. Якщо він занадто довгий, справжній домен, який не належить до діапазону, може сховатися за суфіксом, який випадково було виключено. Справжній список охоплює кілька десятків розширень — від py та sh до pem та elf, адже робота пов’язана з різноманітними файлами, і кожен з них може спричинити хибний сигнал.

Повна перевірка перед тим, як дозволити виконати команду, проходить кожен розшифрований кандидат через обидва фільтри:

def check(self, cmd: str) -> None:
    payloads = _de_cloak_payloads(cmd)

    for payload in payloads:
        if self.networks:
            for m in _IPV4_RE.finditer(payload):
                self._validate_ip(m.group(1))
            for token in re.split(r"[\s\"'$,;()|&<>`\\/]", payload):
                normalized = _normalize_ip_token(token)
                if normalized:
                    self._validate_ip(normalized)

        if self.domains:
            for m in _DOMAIN_RE.finditer(payload):
                token = m.group(0).lower()
                tld = token.rsplit(".", 1)[-1]
                if tld in _FILE_EXTENSIONS:
                    continue
                if not any(token == d or token.endswith("." + d) for d in self.domains):
                    raise ScopeViolation(f"Domain {token!r} is outside engagement scope.")

Одна некоректна значення в будь-якій розкодованій версії зупиняє виконання команди. Не потрібно доводити, що саме мав на увазі модель — достатньо того, що якась правдоподібна інтерпретація змісту команди потрапляє до забороненої адреси.

Поза межами тестування на безпеку

Якщо видалити контекст тестування на проникнення, залишиться шаблон, спільний для кожного агента, який працює з ненадійним вхідним даним. Уявіть собі процес схвалення, при якому сума порівнюється з лімітом витрат, фільтр, який перевіряє надісланий текст проти списку заборонених значень, або перевірку доступу, під час якої запитуваний ресурс порівнюється зі списком дозволених. Наївна реалізація перевіряє будь-яку буквальну послідовність символів, яка надійшла. Надійна реалізація спочатку канонізує кожну еквівалентну форму значення, а потім перевіряє отриманий результат. Механізм захисту на рівні команд шелу дає урок, який так само доречний для полів JSON, шляхів до файлів та параметрів URL кожного разу, коли агент вирішує, чи продовжувати роботу.

Слідуюче питання — це вартість

Усе вищесказане стосується коректності та обмежень: чи залишається агент у межах визначених ліній, чи продовжує працювати файл після виправлень, чи можна безпечно оцінити розшифроване значення. Жоден з цих аспектів не відповідає на більш просте питання, яке виникає як тільки з’являються справжні користувачі. Рекурсивні системи здійснюють багато викликів моделі, а кожен такий виклик коштує часу та грошей. Те, звідки походить ця вартість та що насправді її зменшує, — це окрема тема.

Практичні рекомендації для тих, хто створює подібні механізми контролю: розглядайте декодер та нормалізатор як частину меж безпеки, тестуйте їх з агресивними вхідними даними, такими як потрійно вкладені кодування та адреси змішаної системи числення, та постійно переглядайте список дозволених розширень. Механізм захисту, який був коректним минулого кварталу, може стати вразливим, як тільки у робочий процес потрапляють нові типи файлів та інструменти.

Пов’язана література

  • Сандбокс для рекурсивних мовних моделей: що завадило, що потребує перебудови — огляд платформи для рекурсивних мовних моделей: шари ізоляції, брокери, CDR, обмеження трасування в користувацькому просторі, обмеження одного хоста та чому саме стриманість була найважливішою.