Strona główna / Artykuły

Zabezpieczenia zakresu dla agentów AI: Dekodowanie ukrytych IP i domen przed uruchomieniem

Zaawansowane mechanizmy ochronne dla agentów AI: dekodowanie zagnieżdżonego kodowania Base64, hex oraz URL, normalizacja nietypowych formatów IP oraz filtrowanie nazw plików, aby cele poza zakresem nie mogły zostać uruchomione.

Wydawca: REACTAPP.TOP6 min czytania1434 słów

Źródła: Keeping an Autonomous Agent Inside the Lines

Zazwyczaj projekty agentów traktują je jako bezpieczny komponent. Wykonują one kod w środowisku izolowanym należącym do zespołu, edytują dostarczone im pliki oraz czytają logi zapisane przez zespół. Jedna sytuacja niszczy tę zaufaność: agent przetwarza tekst napisany przez kogoś innego, a część tego tekstu ma na celu skłonienie go do wykonywania czynności wykraczających poza jego zadania.

Sytuacja

Rozważmy model językowy rekurencyjny zatrudniony do zatwierdzonego testu penetracyjnego. Jego instrukcje określają zakresy IP i domeny, do których może uzyskać dostęp, a wszystko poza tą listą jest zabronione, niezależnie od ścieżki, którą model wybierze, aby do tego dotrzeć. W pewnym momencie model wysyła polecenie wiersza poleceń. Pomiędzy tym wyjściem a rzeczywistym wierszem poleceń musi znajdować się punkt kontrolny, który musi sam egzekwować te ograniczenia, zamiast polegać na pamięci modelu.

Porównywanie polecenia z listą białych adresów nie jest wystarczające. Atakujący oraz modele wpływane przez przeczytany tekst rzadko zapisują zakazany adres w sposób jawny. Ukrywają go, dlatego punkt kontrolny musi być równie dobry w jego wykrywaniu, jak one w ukrywaniu.

Ukryte w kodowaniu

Załóżmy, że jedynym dozwolonym celem jest target.htb. Prosty check zakresu przeszukuje polecenie w poszukiwaniu podciągów przypominających domeny i porównuje je z tą jedną wartością. Działa to, dopóki polecenie nie wygląda w ten sposób:

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

192.168.2.1 nie pojawia się w czystym tekście, ponieważ jest zakodowany procentowo wewnątrz adresu URL. Po jednorazowym dekodowaniu znajduje się on bezpośrednio tam. Jeśli otoczymy go kodem base64, rozdzielimy na fragmenty za pomocą escape’ów hex lub użyjemy kilku warstw kodowania, to sprawdzenie polegające na odczytaniu wyłącznie literalnego tekstu przejdzie we wszystkich wariantach, mimo że każdy z nich odnosi się do tego samego hosta poza zakresem.

To nie jest osobliwość jednego sposobu kodowania. Wartość może zazwyczaj być zapisana na wiele równoważnych sposobów, a walidator, który sprawdza tekst powierzchniowy, bada tylko jeden z nich.

Rozwiązaniem jest najpierw dekodowanie, a dopiero potem ocena. Zanim sprawdzenie zakresu zapyta, czy adres jest dozwolony, usuwa rekurencyjnie każde znalezione kodowanie i ocenia uzyskane w ten sposób wartości w formie czystego tekstu:

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

Rekurencja wprowadza wynik każdej próby dekodowania z powrotem do tej samej funkcji, z ograniczeniem co do głębokości. Wyobraź sobie kodowanie URL otoczone kodowaniem base64, a to z kolei otoczone ucieczkami hex: proces ten odbywa się krok po kroku, a każda wygenerowana w trakcie ścieżka ciąg znaków, nie tylko najgłębsza, jest dodawana do zbioru, który jest później sprawdzany.

W rezultacie otrzymujemy zbiór: oryginalną komendę wraz z każdą prostą wartością, którą można odzyskać poprzez dekodowanie, na głębokość do trzech warstw. Każda z nich jest sprawdzana pod kątem zakresu dozwolonych adresów. Jeśli któraś zawiera adres poza tym zakresem, cała komenda jest odrzucana, nawet jeśli tekst modelu w żaden sposób nie wspominał o tym adresie.

Ukryte w formatach liczbowych

Kodowanie to nie jedyne przykrycie. Adres IP to liczba, a liczby mogą być zapisywane na kilka różnych sposobów. 192.168.2.1 może być pojedynczą liczbą całkowitą 32-bitową, czterema częściami w systemie ośmiennym lub szesnastkowym, albo połączeniem wypełnionym znakami, które akceptują większość narzędzi. Reguły wyrażeń regularnych dostosowane do zapisu z kropkami pomijają wszystkie te formy, a mimo to rzeczywisty stos sieciowy przypisuje je do tej samej maszyny.

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

Każda niestandardowa forma zapisu jest przepisywana na standardową notację z kropkami, zanim zostanie porównana z dozwolonymi zakresami. Oznacza to, że 0xC0A80201 i 192.168.2.1 są traktowane jako ten sam host w różnych „ubraniach”.

Domeny i nazwy plików

Weryfikacja IP służy do wykrywania przebrania. Weryfikacja domen ma odwrotną trudność: sieć musi być wystarczająco szeroka, aby odnaleźć prawdziwe odniesienia do domen, nie sygnalizując przy tym każdej nazwy pliku zawierającej kropkę.

Reguła regularna zaprojektowana do znajdowania tokenów o strukturze domeny pasuje tak samo dobrze do config.yaml lub report.pdf jak do evil.example.com, ponieważ każdy z nich ma tę samą strukturę: litery, kropka i dalsze litery. Dodatkowe dekodowanie w tym przypadku nie pomaga. Pomaga natomiast druga lista, mianowicie powszechne rozszerzenia plików, porównywane z tym, co znajduje się w miejscu, gdzie powinna być domena najwyższego poziomu:

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.")

Lista musi być dostosowana, ponieważ oba skrajności są szkodliwe. Jeśli będzie zbyt krótka, zwykłe ścieżki plików będą wywoływać alarmy, przez co użytkownicy nauczą się odrzucać to narzędzie całkowicie. Jeśli będzie zbyt długa, prawdziwy domenowy adres poza zakresem może się ukryć za sufiksem, który przypadkowo został wykluczony. Prawdziwa lista obejmuje kilkadziesiąt rozszerzeń, od py i sh po pem i elf, ponieważ prace obejmują różnorakie pliki, a każdy z nich niesie ryzyko fałszywego alarmu.

Pełna weryfikacja sprawdza każdy odszyfrowany kandydat przez oba filtry, zanim pozwoli na uruchomienie polecenia:

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.")

Jedna nieprawidłowa wartość w dowolnej zdekodowanej wersji powoduje zatrzymanie polecenia. Nie jest konieczne udowadnianie, co miał na myśli model – wystarczy, że jakaś prawdopodobna interpretacja treści polecenia trafi do zakazanego adresu.

Poza testami bezpieczeństwa

Gdy usuniemy kontekst testów penetracyjnych, pozostaje wzorzec wspólny dla każdego agenta, który przetwarza niepewne dane wejściowe. Można to porównać do procesu zatwierdzania, w którym kwota jest porównywana z limitem wydatków, filtru, który porównuje wysłany tekst z listą blokad, lub sprawdzania dostępu, w którym żądany zasób jest sprawdzany pod kątem listy dozwolonych zasobów. Naiwna implementacja sprawdza dokładnie tę literową sekwencję, która dotarła. Niezawodna natomiast najpierw kanonizuje każdą równoważną formę wartości, a dopiero potem ją sprawdza. Zasada związana z komendami w shellu daje cenną lekcję, która równie dobrze odnosi się do pól JSON, ścieżek plików i parametrów URL, gdy agent musi zdecydować, czy kontynuować działanie.

Kolejnym pytaniem jest koszt

Wszystko powyżej dotyczy poprawności i kontroli: czy agent pozostaje w obrębie wyznaczonych linii, czy plik po naprawie nadal działa, oraz czy wartość zdekodowana można bezpiecznie ocenić. Nic z tego nie odpowiada na prostsze pytanie, które pojawia się, gdy pojawiają się rzeczywiści użytkownicy. Systemy rekurencyjne dokonują wielu wywołań modelu, a każde z nich kosztuje czas i pieniądze. To, skąd pochodzi ten koszt i co go faktycznie zmniejsza, to zupełnie inna kwestia.

Praktyczna rada dla każdego, kto buduje podobną barierę ochronną: traktuj dekoder i normalizator jako część granicy bezpieczeństwa, testuj je za pomocą danych atakujących, takich jak potrójnie zagnieżdżone kodowania i adresy o mieszanej podstawie, oraz regularnie sprawdzaj listę dozwolonych rozszerzeń. Zabezpieczenie, które było poprawne w poprzednim kwartale, może nagle stać się podatne na ataki, gdy do procesu pracy dołączą nowe typy plików i narzędzia.

Pozycje pokrewne

  • Recurencyjna przestrzeń testowa modeli językowych: co stanowiło przeszkodę, co wymaga ponownego zbudowania — retrospektywa platformy opartej na recurencyjnych modelach językowych: warstwy izolacji, brokerzy, CDR, ograniczenia śledzenia w przestrzeni użytkownika, ograniczenia związane z jednym hostem oraz dlaczego umiar miał największe znaczenie.