首页 / 文章

AI智能体的范围保护机制:在运行前解码隐藏的IP和域名。

AI智能体的范围防护机制:解码嵌套的Base64、十六进制及URL编码,规范异常的IP格式,并过滤文件名,从而确保超出范围的目标永远无法运行。

发布者:REACTAPP.TOP阅读约 6 分钟1434 词

来源: Keeping an Autonomous Agent Inside the Lines

代理设计通常将代理视为安全组件。它在团队控制的沙箱中执行代码,修改分配给它的文件,并读取团队记录的日志。但有一种情况会打破这种信任:当代理需要处理他人编写的文本时,而这些文本中可能包含诱导它执行超出任务范围操作的指令。

场景描述

假设有一个被用于授权渗透测试的递归语言模型。其任务说明中列出了它可访问的IP范围和域名,任何不在列表内的内容无论模型通过何种途径试图访问都是被禁止的。在某个时刻,该模型会输出一条shell命令。此时必须在这条输出与实际shell之间设置一个检查点,由该检查点来强制执行边界限制,而不能依赖模型自己记住这些规则。

仅通过将命令与字符串白名单进行比对是不足的。攻击者以及被其读取的文本影响而偏离正常行为的模型,很少会直接写出被禁止的地址。他们会将其隐藏起来,因此检测机制必须在揭露隐藏内容方面与攻击者同样熟练。

隐藏在编码中

假设唯一允许的目标是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编码之外,再将其包裹在十六进制转义字符之中:它会逐步解开,途中产生的每一个字符串——而不仅仅是最内层的那个——都会被加入后续检查的集合中。

最终得到的是一个集合:原始指令加上通过解码可恢复的所有纯文本值,深度最多为三层。每个值都会经过范围验证。如果其中任何值包含超出允许范围的地址,即使模型中的原始文本从未提及该地址,整个指令也会被拒绝。

隐藏在数字格式中

编码并非唯一的伪装手段。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.")

无论哪种解码后的形式,只要存在一个异常值,命令就会终止执行。无需证明模型具体想表达什么,只要命令内容的某种合理解读会导致访问被禁止的地址即可。

超越安全测试

去掉渗透测试的背景,剩下的就是所有处理不可信输入的程序都会遵循的模式。比如将金额与支出上限进行比较的审批流程、根据黑名单筛选提交文本的过滤器,或是根据白名单检查请求资源的访问控制机制。简单的实现方式会直接测试传入的原始字符串,而可靠的实现方式则会先将所有等价的值形式规范化,再对其进行测试。shell命令防护机制所传达的教训,在程序决定是否继续处理JSON字段、文件路径或URL参数时同样适用。

下一个问题是成本

上述内容均涉及正确性与控制问题:代理是否始终在限定范围内、修复后的文件是否仍能正常运行、解码后的值是否可以安全判断。但这些都无法回答一旦出现真实用户就会出现的更为实际的问题。递归系统会进行大量模型调用,而每次调用都会耗费时间和金钱。这些成本的来源以及究竟什么因素能降低成本,则是另一个问题。

对于那些正在构建类似安全机制的人而言,实际可行的做法是将解码器和规范化工具视为安全边界的一部分,使用三重嵌套编码和混合进制地址等恶意输入对它们进行测试,并定期审查允许的扩展名列表。上季度还有效的防护措施,一旦有新的文件类型和工具被引入工作流程,就可能会悄然出现漏洞。

相关阅读

  • 递归语言模型沙箱:哪些问题阻碍了发展,哪些需要重新构建 —— 对一个递归语言模型平台的回顾:隔离层、代理、CDR、用户空间追踪限制、单主机限制,以及为何限制措施最为重要。