Garde-fous de portée pour les agents IA : Décodage des adresses IP et des noms de domaines cachés avant exécution
Protection des plages d’action pour les agents IA : décodage de l’encodage base64, hexadécimal et URL imbriqués, normalisation des formats IP inhabituels et filtrage des noms de fichiers afin que les cibles hors plage d’action ne s’exécutent jamais.
Les conceptions d’agents traitent généralement l’agent comme un composant sûr. Il exécute du code à l’intérieur d’un environnement isolé appartenant à l’équipe, modifie les fichiers qui lui ont été confiés et lit les journaux enregistrés par l’équipe. Une situation vient briser cette confiance : l’agent travaille à partir de texte écrit par quelqu’un d’autre, et une partie de ce texte est conçue pour le pousser à effectuer des actions au-delà de ses instructions.
Le scénario
Imaginons un modèle de langage récursif engagé pour un test de pénétration autorisé. Son cahier des charges indique les plages d’adresses IP et les domaines qu’il peut atteindre, et tout ce qui se trouve en dehors de cette liste est interdit, quel que soit le chemin emprunté par le modèle pour y accéder. À un moment donné, le modèle émet une commande shell. Un point de contrôle doit se situer entre cette sortie et le shell réel, et il doit lui-même en respecter les limites au lieu de compter sur le fait que le modèle s’en souvienne.
Comparer la commande à une liste blanche de chaînes de caractères est insuffisant. Les attaquants, ainsi que les modèles influencés par le texte qu’ils ont lu, écrivent rarement ouvertement une adresse interdite. Ils la cachent, ce qui exige que le point de contrôle soit tout aussi doué pour la déceler que pour la cacher.
Caché dans l’encodage
Supposons que la seule cible autorisée soit target.htb. Une vérification de portée naïve cherche dans la commande des sous-chaînes ressemblant à des noms de domaine et les compare à cette seule valeur. Cette méthode fonctionne tant que la commande a cet aspect :
curl http://localhost/exec?cmd=ping%20192.168.2.1
192.168.2.1 n’apparaît pas en texte brut, car il est encodé en pourcentage à l’intérieur de l’URL. Une fois décodé, il se trouve bien là. En l’encapsulant dans du base64, en le divisant en séquences hexadécimales ou en superposant plusieurs couches d’encodage, une vérification qui ne lit que le texte littéral passe pour toutes les variantes, même si chacune renvoie au même hôte hors champ.
Ce n’est pas une particularité d’un seul encodage. Une valeur peut généralement être écrite de nombreuses manières équivalentes, et un validateur qui examine la chaîne visible ne vérifie qu’une d’entre elles.
La solution consiste à décoder d’abord puis à juger ensuite. Avant que la vérification de champ ne demande si une adresse est autorisée, elle retire récursivement tous les encodages qu’elle peut trouver et évalue les valeurs brutes qui en résultent :
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
La récursivité renvoie la sortie de chaque tentative de décodage dans la même fonction, avec une limite de profondeur. Imaginez un encodage URL entourant un encodage base64 lui-même entouré d’échappements hexadécimaux : cela se déroule étape par étape, et chaque chaîne générée en cours de route, y compris celles les plus internes, est ajoutée au pool que les vérifications ultérieures examinent.
Le résultat est un ensemble : la commande originale ainsi que toutes les valeurs simples pouvant être récupérées par décodage, jusqu’à trois niveaux de profondeur. Chacune d’elles est testée en fonction de son champ d’application. Si l’une d’elles contient une adresse en dehors de la plage autorisée, toute la commande est rejetée, même si le texte littéral du modèle n’a jamais mentionné cette adresse.
Caché dans les formats numériques
L’encodage n’est pas le seul déguisement. Une adresse IP est un nombre, et les nombres peuvent être écrits de plusieurs manières valables. 192.168.2.1 peut représenter un entier de 32 bits unique, quatre parties octales ou hexadécimales, ou une combinaison complétée que la plupart des outils acceptent. Une expression régulière conçue pour le format décimal ponctué ne prend pas en compte toutes ces formes, pourtant un véritable stack réseau les résout comme étant la même machine.
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
Toute orthographe inhabituelle est réécrite sous une notation standard à points avant qu’on ne la compare aux plages autorisées. Cela signifie que 0xC0A80201 et 192.168.2.1 sont considérés comme le même hôte portant des « vêtements » différents.
Noms de domaine et noms de fichiers
Vérifier les adresses IP consiste à détecter les déguisements. La vérification des noms de domaine présente une difficulté inverse : le réseau doit être suffisamment vaste pour identifier les références de domaines réels sans signaler chaque nom de fichier contenant un point.
Une expression régulière conçue pour trouver des tokens ayant la forme d’un nom de domaine reconnaît aussi bien config.yaml ou report.pdf que evil.example.com, car chacun présente la même structure : des lettres, un point, puis davantage de lettres. Une décodage supplémentaire n’aide pas ici. Une deuxième liste, à savoir les extensions de fichiers courantes, comparée à ce qui se trouverait à l’endroit où devrait se situer un nom de domaine de premier niveau, permet de résoudre ce problème.
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.")
La liste doit être ajustée, car les deux extrêmes sont préjudiciables. Si elle est trop courte, les chemins de fichiers ordinaires continuent de déclencher des alertes, ce qui pousse les utilisateurs à ignorer complètement l’outil. Si elle est trop longue, un domaine réel hors champ peut se cacher derrière un suffixe qui se trouve justement exclu. La liste réelle couvre plusieurs dizaines d’extensions, de py et sh à pem et elf, car les opérations impliquent toutes sortes de fichiers, et chacun d’eux risque de provoquer une fausse alerte.
La vérification complète fait passer chaque candidat décodé à travers les deux filtres avant d’autoriser l’exécution d’une commande :
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.")
La présence d’une seule valeur problématique dans n’importe quelle variante décodée bloque la commande. Il n’est pas nécessaire de prouver ce que le modèle entendait par là ; il suffit qu’une interprétation plausible du contenu de la commande atteigne l’adresse interdite.
Au-delà des tests de sécurité
En supprimant le contexte des tests de pénétration, on obtient un schéma commun à tous les agents qui traitent des entrées non fiables. Pensez à un flux d’approbation qui compare un montant à un plafond de dépenses, à un filtre qui compare le texte soumis à une liste noire, ou à une vérification d’accès qui teste une ressource demandée contre une liste blanche. Une implémentation naïve teste simplement la chaîne de caractères telle qu’elle est arrivée. Une implémentation fiable normalise d’abord toutes les formes équivalentes de la valeur avant de tester le résultat. Un mécanisme de protection basé sur des commandes shell enseigne une leçon qui s’applique tout aussi bien aux champs JSON, aux chemins de fichiers et aux paramètres URL, chaque fois qu’un agent doit décider s’il doit procéder.
Le coût est la question suivante
Tout ce qui a été mentionné concerne la correction et la limitation : le fait que l’agent reste à l’intérieur des limites prévues, que un fichier corrigé continue de fonctionner, et que la valeur décodée puisse être évaluée en toute sécurité. Rien de tout cela ne répond à une question bien plus pratique qui se pose dès l’apparition d’utilisateurs réels. Les systèmes récursifs effectuent de nombreuses appels de modèles, et chacun de ces appels coûte du temps et de l’argent. L’origine de ces coûts et ce qui peut réellement les réduire sont des questions distinctes.
Conseil pratique pour quiconque développe un mécanisme similaire : considérez le décodeur et le normaliseur comme faisant partie de la barrière de sécurité, testez-les avec des entrées adverses telles que des encodages imbriqués à trois niveaux et des adresses à radicale mixte, et maintenez la liste des extensions autorisées à jour. Une protection qui était fiable au cours du trimestre précédent peut devenir vulnérable dès l’arrivée de nouveaux types de fichiers et d’outils dans le flux de travail.
Lectures complémentaires
- Les systèmes multi-agents sont des systèmes backend comportant des éléments non déterministes — Pourquoi les systèmes multi-agents en production favorisent les habitudes backend : contrats explicites, prompts en tant que configuration, contexte restreint, un rôle par agent, code fiable et robuste.
- Notes pratiques : Construire un agent-critique UI autonome avec le nouveau Google — Guide pas à pas des notes pratiques : Construire un agent-critique UI autonome avec le nouveau Google : contrats, vérifications et emplacements de code prêts à l’emploi pour les équipes souhaitant mettre en œuvre ce modèle.