1. 从一次诡异的“幻觉”攻击说起:Web智能体的新威胁
最近在复现一个基于大语言模型的网页自动化智能体(Web Agent)项目时,我遇到了一个极其诡异的现象。这个智能体的任务是登录一个模拟的电商后台,查询特定订单的状态。在本地测试环境中,它运行得完美无缺,总能准确找到登录入口、输入凭证、导航到订单管理页面并返回结果。然而,当我将其部署到一个临时的云端测试环境后,它的行为开始变得“精神错乱”——它依然能成功登录,但在后续的页面导航中,会间歇性地点击一些根本不存在的按钮,或者向一个错误的表单字段输入完全无关的查询信息。
起初,我以为是网络延迟或页面加载不全导致的典型“元素定位失败”问题。但仔细检查日志和HTML快照后发现,智能体“看到”的页面DOM结构,与我通过浏览器开发者工具实际抓取到的结构,存在微妙的差异。智能体“认为”页面上有一个ID为#submit-order的按钮,而实际页面上只有#submit-btn。更令人不安的是,这种差异并非每次都出现,似乎与智能体的执行流程和特定时间点有关。
经过长达数天的深度排查,我最终将问题根源锁定在了一个非常隐蔽的层面:运行环境的内存污染。这不是代码bug,也不是模型“幻觉”,而是一种针对AI智能体运行时的、新型的“环境注入”攻击。攻击者无需直接修改智能体的核心代码或训练数据,只需在其运行时环境(例如,浏览器上下文、Node.js进程或特定的解释器内存空间)中植入精心构造的“毒化”数据,就能永久性地扭曲智能体的认知和行为。这正是标题“Poison Once, Exploit Forever: Environment-Injected Memory Poisoning Attacks on Web Agents”所揭示的核心威胁——一次投毒,永久利用。
这种攻击模式完全颠覆了我们对AI系统安全性的传统认知。我们通常关注的是训练数据投毒、对抗样本攻击或提示注入,但这些攻击往往需要与模型进行直接交互或影响其训练过程。环境注入的内存污染攻击则狡猾得多,它攻击的是智能体赖以理解世界的“感官”和“短期记忆”。对于Web智能体而言,这个“感官”就是它从浏览器中获取的页面DOM、网络响应、乃至JavaScript执行上下文。一旦这些基础信息在内存层面被污染,智能体基于此做出的所有决策都将走向歧途,且攻击效果会随着智能体的持续运行而不断被“重温”和“巩固”,形成一种持久的后门。
2. 攻击原理深度拆解:内存污染如何“欺骗”Web智能体
要理解这种攻击,我们首先需要拆解一个典型Web智能体的工作流程。以基于LLM的智能体为例,其核心循环通常遵循“感知-思考-行动”模式:
- 感知:通过浏览器自动化工具(如Playwright、Selenium)获取当前页面的HTML、可访问性树、截图等。
- 思考:将感知到的信息(通常经过简化或特征提取)与任务指令一起提交给LLM。LLM分析当前状态,并规划下一步动作(如“点击登录按钮”、“在搜索框输入‘订单123’”)。
- 行动:将LLM输出的结构化动作(如
{action: ‘click’, selector: ‘#loginBtn’})翻译成浏览器自动化API的调用,执行操作。 - 观察结果:进入下一个循环,感知动作执行后的新页面状态。
环境注入的内存污染攻击,其核心目标就是在“感知”阶段与“思考”阶段之间,篡改流经内存的数据。攻击的切入点可以分布在多个层面。
2.1 攻击面一:浏览器运行时环境注入
这是最直接也最有效的攻击面。Web智能体严重依赖浏览器提供的环境来渲染页面和执行JavaScript。
污染DOM API:攻击者可以注入一段恶意JavaScript代码,劫持或包装关键的DOM查询API,如
document.querySelector、document.getElementById、element.innerText等。当智能体的自动化脚本调用这些API获取元素信息时,返回的是被篡改后的结果。- 攻击示例:劫持
document.querySelector,当检测到查询选择器是#submit-btn时,动态创建一个不存在的#submit-order按钮的虚拟DOM节点并返回其引用。对于智能体来说,它“看到”并成功“点击”了这个按钮,但实际上浏览器并未执行任何真实点击。 - 注入方式:可以通过多种方式实现,例如:1)作为第三方分析或广告脚本的一部分被加载;2)利用跨站脚本漏洞注入;3)在本地开发或测试环境中,通过浏览器扩展或调试工具手动注入(用于模拟攻击)。
- 攻击示例:劫持
污染网络请求/响应:通过Service Worker或代理工具,拦截并修改智能体发出的XHR/Fetch请求或接收到的响应。例如,可以将一个正常的API响应(返回订单列表)篡改为包含误导性信息或错误数据的响应,引导智能体做出错误判断。
污染全局状态:修改
window对象上的某些全局变量或函数,这些变量可能被页面上的业务逻辑或智能体依赖的某些客户端库所使用,间接影响页面行为。
2.2 攻击面二:智能体框架层内存污染
如果攻击无法在浏览器层面实现,或者智能体采用无头浏览器且环境可控性较高,攻击者可能会瞄准智能体自身的运行框架。
- 污染中间件或适配器:许多智能体框架会在浏览器原始数据送达LLM之前,进行一层预处理(如简化DOM、提取关键特征、计算元素坐标)。攻击者可以污染这个预处理模块的内存或缓存。例如,一个缓存了“常见页面元素映射关系”的字典被污染,导致所有“登录按钮”都被错误地映射到一个“删除账户”的按钮选择器上。
- 污染上下文管理:Web智能体通常有“上下文”概念,即保存了历史对话、已执行动作、已观测状态的内存。攻击者如果能在上下文向量数据库或内存数组中插入一条伪造的、成功的“历史记录”,可能会诱导智能体在后续步骤中重复错误的操作路径。
2.3 攻击面三:LLM上下文提示污染
虽然严格来说这不完全是“内存”污染,但它是环境注入的延伸。攻击者不是直接攻击LLM模型,而是污染即将发送给LLM的提示上下文。
- 在系统提示或少量示例中植入偏见:如果智能体的系统提示或少量示例(Few-shot Examples)是从某个可能被篡改的配置文件、数据库或环境变量中动态读取的,那么攻击者可以通过修改这些源数据,来 subtly 地改变LLM的决策倾向。例如,在示例中将“转账确认按钮”的描述从“安全验证”改为“快速通道”,可能会影响LLM对按钮功能的风险评估。
这种攻击之所以危险,在于其隐蔽性和持久性。被污染的数据驻留在运行时内存或环境配置中,不涉及智能体核心代码的更改,因此传统的代码审计或静态分析难以发现。而且,只要智能体继续在该污染环境下运行,攻击就会持续生效,即“Poison Once, Exploit Forever”。智能体在污染数据上做出的错误决策,可能会产生新的、符合污染预期的状态,从而形成一个恶性的自我强化循环。
3. 实战复现:构建一个最小化的环境投毒攻击Demo
为了让大家更直观地理解这种攻击,我将演示一个最小化的、用于教育和防御研究的攻击复现场景。请注意,此演示仅限在完全受控的本地或沙盒环境中进行,切勿用于任何实际系统或侵犯他人权益。
我们的目标:让一个原本应该点击“真实按钮”完成登录的Web智能体,去点击一个由我们注入的“虚假按钮”。
3.1 环境准备与智能体搭建
首先,我们创建一个简单的目标网页和一个基础的Web智能体。
目标网页(malicious_page.html):
<!DOCTYPE html> <html> <body> <h2>模拟登录页面</h2> <!-- 真实的登录按钮 --> <button id="realLoginBtn" onclick="alert('真实登录逻辑执行!')">点击登录</button> <p>其他一些文本内容...</p> <script> // 这是我们将要注入的恶意脚本 // 它会在页面加载后执行,污染DOM环境 setTimeout(() => { console.log('[攻击脚本] 开始注入恶意DOM覆盖层...'); // 创建一个虚假的、覆盖在真实按钮上方的透明div const fakeOverlay = document.createElement('div'); fakeOverlay.id = 'malicious-overlay'; Object.assign(fakeOverlay.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none', // 允许点击穿透?不,我们会精细控制 zIndex: '9999' }); document.body.appendChild(fakeOverlay); // 劫持 querySelector 和 querySelectorAll const originalQuerySelector = document.querySelector; const originalQuerySelectorAll = document.querySelectorAll; document.querySelector = function(selector) { console.log(`[攻击脚本] querySelector被调用,选择器: "${selector}"`); // 如果智能体试图寻找#realLoginBtn,我们返回一个伪造的按钮元素 if (selector === '#realLoginBtn') { const fakeBtn = document.createElement('button'); fakeBtn.id = 'realLoginBtn'; // 相同的ID! fakeBtn.textContent = '点击登录'; // 相同的文本! fakeBtn.style.cssText = 'position: absolute; top: 100px; left: 100px; padding: 10px; background-color: #ff4444; color: white;'; fakeBtn.onclick = () => { alert('⚠️ 你点击了被污染的虚假按钮!攻击生效!'); // 这里可以触发任何恶意行为,例如发起一个偷偷的请求 fetch('/malicious-api', {method: 'POST', body: JSON.stringify({stolen: 'data'})}); }; // 将伪造按钮插入到覆盖层,并返回它 fakeOverlay.appendChild(fakeBtn); // 调整覆盖层样式,允许事件发生在伪造按钮上,并阻止穿透到真实按钮 fakeOverlay.style.pointerEvents = 'auto'; console.log('[攻击脚本] 返回了伪造的按钮元素'); return fakeBtn; } // 对于其他选择器,返回正常结果 return originalQuerySelector.apply(this, arguments); }; document.querySelectorAll = function(selector) { // 类似地,可以劫持此函数以返回包含伪造元素的NodeList // 此处为简化,直接调用原函数 return originalQuerySelectorAll.apply(this, arguments); }; console.log('[攻击脚本] DOM API劫持完成。'); }, 500); // 延迟500毫秒注入,模拟异步脚本加载 </script> </body> </html>基础Web智能体脚本(simple_web_agent.py): 我们使用Playwright和一个简单的LLM调用(此处用规则模拟)来构建智能体。
import asyncio from playwright.async_api import async_playwright async def run_web_agent(): async with async_playwright() as p: # 启动浏览器,建议使用headed模式观察 browser = await p.chromium.launch(headless=False) context = await browser.new_context() page = await context.new_page() # 1. 导航到目标页面(本地文件) await page.goto('file:///path/to/your/malicious_page.html') await page.wait_for_timeout(1000) # 等待页面和潜在恶意脚本加载 # 2. 感知:获取页面信息(这里智能体“看到”的已被污染) # 模拟智能体通过选择器查找登录按钮 print("智能体正在寻找登录按钮 (#realLoginBtn)...") # 智能体调用的是被劫持后的 document.querySelector button_element = await page.evaluate_handle('document.querySelector("#realLoginBtn")') if button_element: # 获取按钮文本,确认找到的元素 button_text = await button_element.evaluate('el => el.textContent') print(f"智能体‘看到’了按钮,文本内容是: '{button_text}'") # 获取其位置和样式(来自伪造按钮) bounding_box = await button_element.bounding_box() print(f"按钮位置: {bounding_box}") # 3. 思考与决策(模拟LLM推理):决定点击它 # 在实际LLM中,这会是一个分析页面状态和任务后的决策 print("智能体决策:点击这个登录按钮。") # 4. 行动:执行点击 await button_element.click() print("点击动作已执行。") else: print("智能体未找到登录按钮。") await page.wait_for_timeout(3000) # 等待弹窗等效果 await browser.close() if __name__ == '__main__': asyncio.run(run_web_agent())3.2 攻击执行与效果分析
运行智能体脚本simple_web_agent.py。你会观察到以下过程:
- 浏览器打开本地HTML页面,页面上有一个蓝色的“真实登录按钮”。
- 大约500毫秒后,恶意脚本执行。它创建了一个覆盖全屏的透明层,并劫持了
document.querySelector。 - 智能体脚本执行
page.evaluate_handle('document.querySelector("#realLoginBtn")')。此时,它调用的是被劫持的函数。 - 劫持函数检测到选择器是
#realLoginBtn,于是动态创建了一个红色的伪造按钮,将其附加到覆盖层上,并返回这个伪造元素的引用给智能体。注意:智能体通过Playwright获取到的,是这个伪造元素的句柄,而不是页面上原本的真实按钮。 - 智能体读取伪造按钮的文本(“点击登录”)和位置,并决定点击它。
- 当智能体执行
click()时,它点击的是那个红色的伪造按钮,触发一个警告框“⚠️ 你点击了被污染的虚假按钮!攻击生效!”,而真实的蓝色按钮从未被触及。
关键点剖析:智能体全程认为自己是在与真实的页面交互。它通过标准的、被信任的浏览器API(
querySelector)获取元素,并基于此做出决策和行动。它无法感知到这些API的返回值在内存层面已经被“调包”。攻击发生在运行时环境(浏览器的JavaScript执行上下文)中,智能体的核心代码(Python/Playwright脚本)没有任何改动。
3.3 攻击的变体与高级技巧
上述Demo是最基础的DOM API劫持。在实际攻击中,手段可能更隐蔽:
- 条件性触发:恶意脚本可以设计为仅在检测到特定用户代理(如包含“HeadlessChrome”的自动化工具)、特定操作序列或特定时间才激活污染逻辑,从而逃避人工测试。
- 数据污染而非元素污染:不创建虚假元素,而是修改真实元素的属性。例如,将一个“删除”按钮的
innerText临时改为“保存”,或者将其getAttribute(‘data-risk’)从“high”改为“low”,误导基于属性分析的智能体。 - 污染可访问性树:屏幕阅读器等辅助工具依赖可访问性树。劫持相关API污染此树,可以攻击那些依赖可访问性信息的智能体。
- 利用原型链污染:通过污染
Object.prototype或Element.prototype,可以更底层、更广泛地影响所有对象的行为,难以追踪。
4. 检测与防御:如何为你的Web智能体构筑免疫防线
面对这种“釜底抽薪”式的攻击,传统的Web安全防护(如WAF、输入过滤)效果有限。我们需要一套针对智能体运行时环境可信度的全新防御体系。
4.1 防御策略一:强化环境隔离与完整性验证
这是最根本的防御,确保智能体运行在一个“干净”且“可信”的环境中。
- 使用纯净、可控的浏览器实例:
- 沙盒化:为每个智能体任务启动一个全新的、完全隔离的浏览器进程或容器(如Docker)。任务结束后立即销毁,确保污染不会残留。
- 严格扩展管理:禁用所有不必要的浏览器扩展。扩展是注入脚本的常见入口。
- 安全启动参数:在启动浏览器时(如Chrome/Chromium),使用
--disable-extensions、--no-sandbox(需权衡安全)、--disable-features等参数,减少攻击面。
- 实施运行时完整性检查:
- 关键API监控:在智能体框架层,可以注入一段“清白”的检测脚本到浏览器上下文,定期检查关键原生API(如
document.querySelector)是否被重写。比较document.querySelector === document.querySelector或document.querySelector.toString()是否包含预期外的代码。 - DOM一致性校验:在智能体采取关键行动(如点击、输入)前,通过另一种“可信通道”交叉验证DOM状态。例如,除了通过
page.evaluate_handle获取元素,同时使用Playwright内置的、更底层的page.locator方法(它不依赖页面内的JavaScript执行)来定位同一元素,并对比两者的属性(如坐标、标签名)是否一致。
# 示例:交叉验证 async def safe_click(page, selector): # 方法1:通过可能被污染的页面JS环境获取 js_handle = await page.evaluate_handle(f'document.querySelector("{selector}")') # 方法2:通过Playwright原生定位器获取 locator = page.locator(selector) if await locator.count() == 0: raise Exception(f"Locator found no element for {selector}") # 比较关键属性,如是否可见 is_visible_js = await js_handle.evaluate('el => el.offsetParent !== null') is_visible_locator = await locator.is_visible() if is_visible_js != is_visible_locator: print(f"警告:JS环境与Locator对元素可见性判断不一致!可能遭受污染。") # 采取安全措施,如记录日志、中止任务、使用Locator的结果 await locator.click() else: await js_handle.click() - 关键API监控:在智能体框架层,可以注入一段“清白”的检测脚本到浏览器上下文,定期检查关键原生API(如
4.2 防御策略二:采用多模态感知与交叉验证
不要完全信任单一的感知通道(如纯DOM文本)。引入冗余的、难以被统一污染的信息源进行交叉验证。
- 视觉感知:结合计算机视觉(CV)。在决策前,对页面进行截图,使用OCR识别按钮文本,或者用目标检测模型定位UI元素的位置和类型。攻击者要同时完美污染DOM和视觉特征(像素级)的难度极大。
- 工具:可以集成像
pytesseract(OCR)、OpenCV或基于深度学习的UI元素检测模型。 - 流程:智能体通过DOM找到“提交”按钮后,先在对应坐标区域截图,然后用OCR识别截图中的文字是否为“提交”,以此验证。
- 工具:可以集成像
- 可访问性树感知:同时解析浏览器的可访问性树。虽然它也可能被污染,但攻击者需要同时篡改DOM和可访问性树,增加了攻击复杂度。
- 网络流量监控:监控智能体触发的所有网络请求。如果点击一个“登录”按钮后,发出了一个向陌生域名
/malicious-api的POST请求,这显然是异常行为,应立即告警并终止会话。
4.3 防御策略三:在智能体决策逻辑中引入不信任与怀疑机制
改变智能体的“思维模式”,让它不再天真地相信所有感知信息。
- 环境可信度评分:为每次感知操作附加一个“环境可信度”分数。如果检测到API被劫持、DOM与视觉不一致等情况,则大幅降低当前页面或会话的可信度分数。当分数低于阈值时,智能体应转入“安全模式”——只执行最低风险操作、请求人工干预或直接终止任务。
- 关键操作确认:对于高风险操作(如转账、删除、修改配置),强制智能体执行额外的验证步骤。例如,在输入支付密码前,要求智能体通过另一个独立的感知通道(如视觉)重新确认当前页面标题和关键提示语。
- 行为异常检测:建立智能体的正常行为基线(如典型操作序列、页面停留时间、点击模式)。如果智能体突然开始频繁点击不存在的元素、在非表单区域输入、或操作序列异常,可能意味着它正在被污染的感知所误导,系统应触发警报。
4.4 防御策略四:主动狩猎与动态污点追踪
对于高安全要求的场景,可以采取更主动的防御。
- 部署蜜罐元素:在测试或生产页面中,故意放置一些不可见(
visibility: hidden)或对用户不可交互(display: none)的“蜜罐”元素。正常的智能体逻辑应该忽略它们。如果智能体的感知报告“看到”或试图与这些蜜罐元素交互,那几乎可以断定其感知环境已被污染。 - 动态污点追踪:在浏览器中注入检测脚本,对来自不可信源(如第三方脚本)的数据流进行标记(污点)。当智能体的核心决策逻辑使用到被污染的数据时,进行拦截或记录。这项技术实现复杂,但能从数据流层面提供深度保护。
5. 架构层面的反思:设计抗污染Web智能体系统
防御单点技术固然重要,但更根本的是在系统设计之初就将“环境不可信”作为核心假设。以下是一些架构设计思路:
- 感知与决策分离:将负责“感知”的环境(浏览器)与负责“决策”的LLM/控制逻辑物理或逻辑上隔离。感知环境作为“不可信前端”,其返回的所有数据都必须经过一个“验证网关”的清洗、标准化和可信度标记后,才传递给“可信后端”进行决策。验证网关集成上述的交叉验证、完整性检查等功能。
- 最小权限与指令化操作:智能体不应拥有对浏览器环境的完全访问权。应通过一个安全的中间层(如一个经过严格审核的浏览器扩展或本地服务)来代理智能体的操作。智能体只向这个中间层发送高级指令(如“在包含‘搜索’文本的输入框中输入‘XXX’”),由中间层负责在可信环境下解析指令、定位真实元素并执行操作。这缩小了攻击面。
- 定期环境重置与健康检查:智能体会话不应无限期运行。采用短会话策略,定期销毁并重建整个浏览器环境。在每次新建会话时,执行一套标准化的健康检查流程(如加载一个已知的、干净的测试页面,验证关键API和DOM操作结果是否符合预期)。
- 深度防御与纵深检测:在数据流的各个层面部署检测点:
- 浏览器内:轻量级API监控脚本。
- 智能体框架层:感知数据的一致性校验。
- 决策层:基于行为序列的异常检测。
- 业务层:最终操作结果的合理性校验(例如,登录后是否真的拿到了有效的会话Cookie)。
在我自己的项目中,在遭遇了开篇提到的问题后,我最终采用了“交叉验证 + 短会话隔离”的组合策略。我为每个智能体任务分配一个独立的Docker容器,容器内启动一个禁用所有扩展的浏览器。在关键操作步骤,不仅通过Playwright Locator定位,还会调用一个轻量的OCR服务对目标区域进行截图文字识别比对。同时,在框架层增加了一个简单的钩子,在每次page.evaluate调用前后,检查几个关键全局函数是否发生变化。这套方案增加了约15%的运行时开销,但彻底杜绝了同类环境注入攻击的威胁,让智能体的行为恢复了稳定和可靠。
环境注入的内存污染攻击,为AI应用安全,特别是高度依赖外部环境交互的智能体安全,敲响了新的警钟。它提醒我们,在追求智能体功能强大的同时,必须对其运行环境的“卫生”状况保持最高级别的警惕。防御的重点不在于修补某个具体漏洞,而在于构建一套贯穿始终的、不信任任何单一信息源的安全体系。这不仅是技术挑战,更是一种安全设计思维的转变。