跳过正文
  1. 博客文章/

从一次 TDS 抓包到只读 MCP:老 ERP 接入 AI 的安全边界

·624 字·3 分钟·
AI DevOps MCP ERP SQL Server Wireshark AI 安全 威胁建模
Zayn
作者
Zayn
专注 Kubernetes、CI/CD、可观测性等云原生技术栈,记录生产环境中的实战经验与踩坑复盘。
目录
AI 工程化实践 - 这篇文章属于一个选集。
4: 本文
我原本把注意力放在 MCP 工具怎么设计。抓包后,问题换了:一个看起来只做查询的 ERP 页面,在 1.27 秒内发出 15 条 SELECT,同时还有 2 条 DELETE。我停止了客户端点击模拟,把查询口径拆出来,放进一条更窄的只读链路。

本文基于一次经过授权的企业内部 POC 整理。主机、IP、数据库、账号、页面编码、表名、文件哈希和业务字段均已泛化;原始抓包与未脱敏截图不对外公开。

先说明几个缩写:POC 是上线前的小范围验证;TDS 是 SQL Server 客户端与服务端交换查询和结果的协议;MCP 把受控工具以明确的输入输出接口提供给 AI 客户端;RPC 是 TDS 中携带过程调用或参数的一类请求。读懂本文不需要先掌握这些协议的报文细节。

一个查询页面实际发出了什么
#

现场系统是典型的 Windows 胖客户端:本地 EXE 和业务模块直接连接 SQL Server,没有稳定的 HTTP API,也不适合直接修改客户端代码。

POC 选择了一个业务查询页面,在批准的测试窗口中执行一次查询,同时抓取客户端到 SQL Server 的 TDS 流量。Wireshark 的基础过滤条件很简单:

tcp.port == 1433 && ip.addr == <db-host> && tds

原始证据按下面的顺序保存:

flowchart TD
    Action["执行一次批准的页面查询"] --> Capture["保存原始 pcapng 与采集元数据"]
    Capture --> Filter["过滤 TDS 并导出 SQL 动作"]
    Filter --> Evidence["保留查询摘要与 RPC 样例"]
    Evidence --> Review["人工复核并与页面结果对账"]

抓包窗口持续约 1.27 秒,共解析出 33 条请求:

SQL 动作数量现场含义
SET16会话状态、行数和客户端查询行为设置
SELECT15权限、格式、配置和业务数据读取
DELETE2页面流程中的报表格式清理语句

TDS 查询清单片段,只保留 SQL 动作类型

抓包清单前 10 条请求的脱敏片段,两条 DELETE 均在其中。图中只保留动作类型,IP、库表名、字段、条件值和证据标识均已遮挡。

这两条 DELETE 没有证明业务数据已经被删除,但足以说明页面动作链包含写语句。

只给客户端换成只读账号也解决不了客户端自动化。写语句会被数据库拒绝,但客户端可能因此报错,页面流程也可能停在未知状态。

抓包文件本身可能包含 SQL、条件值和业务字段,应放在受限目录,记录哈希和采集时间,只向参与复核的人开放。

我为什么停掉了客户端点击模拟
#

抓包前有两条候选路线:

  1. 让自动化程序登录 ERP,按页面完成查询;
  2. 从真实请求中提取查询口径,封装成独立服务。

第一条路线会继承客户端整条动作链。POC 已经看到 SETSELECTDELETE 混在同一次页面操作中,继续模拟点击无法给出可靠的只读保证。

我改走第二条路线。每接入一个页面,按同一套步骤处理:

flowchart TD
    Page["选择一个 ERP 查询页面"] --> Capture["抓取 TDS 并按动作分类"]
    Capture --> Select["丢弃写语句和会话状态
保留主业务 SELECT"] Select --> Template["改写为参数化模板或安全视图"] Template --> Verify["最小权限验证
与 ERP 页面结果对账"] Verify --> Tool["评审后注册 MCP 工具"]

这条路线不会复制客户端行为,只复用经过人工确认的查询语义。页面结果、筛选条件和合计口径对不上时,工具不能进入下一阶段。

只读链路长什么样
#

AI 客户端不持有数据库凭据,也不直接建立 SQL Server 连接。凭据和连接池只存在于 MCP Server;AI 看到的是业务工具及其输入结构。

flowchart TD
    User["用户"] --> AI["AI 客户端"]
    AI --> MCP["ERP MCP Server"]
    MCP --> Entry["入口控制
身份与角色 + 工具白名单 + 参数校验"] Entry --> Query["查询控制
固定参数化模板 + mcp 安全视图"] Query --> Data["数据控制
只读账号 + 隔离库"] MCP --> Audit["审计日志"]

这条链路有七个可以单独收紧的边界:调用身份、工具范围、参数结构、SQL 模板、数据库对象权限、数据源网络和运行预算。

ApplicationIntent=ReadOnly 可以参与 SQL Server AlwaysOn 的只读路由,但它只是客户端路由意图。数据库权限仍由登录账号、用户映射和 GRANT 决定。

隔离库也不能代替权限控制。即使查询不落主库,只读账号仍可能执行慢查询、读取敏感字段,或者返回与 ERP 页面不一致的数据。

MCP 工具要比 SQL 窄
#

我没有设计这种工具:

query(sql: string)

任意 SQL 把表名、字段、连接范围和执行成本都交给模型决定。关键词黑名单也不是可靠边界,注释、CTE、存储过程和语法变化都可能绕过字符串匹配。

首批工具按业务问题命名,例如 erp_get_inventory。它的输入只包含经过评审的业务参数:

{
  "name": "erp_get_inventory",
  "description": "Return inventory for one item from the approved reporting view.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "itemCode": {
        "type": "string",
        "minLength": 1,
        "maxLength": 40
      },
      "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 100,
        "default": 20
      }
    },
    "required": ["itemCode"],
    "additionalProperties": false
  }
}

服务端把它映射到固定模板:

SELECT TOP (@limit)
    item_code,
    warehouse_code,
    available_quantity,
    data_as_of
FROM mcp.v_inventory
WHERE item_code = @item_code
ORDER BY warehouse_code;

这里没有可由调用方传入的表名、字段名、排序表达式或 SQL 片段。itemCodelimit 使用数据库驱动参数绑定,limit 在进入数据库前再次限制到允许范围。

如果工具需要切换排序或查询状态,输入只能是有限枚举,服务端将枚举映射到预先写好的模板。不能把枚举值直接拼进 SQL。

SQL Server 只授权安全视图
#

数据库层建立独立 schema,将业务字段改成稳定、可读的名称,并在视图中排除不需要的敏感列:

CREATE SCHEMA mcp AUTHORIZATION dbo;
GO

CREATE VIEW mcp.v_inventory AS
SELECT
    source_item_code AS item_code,
    source_warehouse_code AS warehouse_code,
    source_available_quantity AS available_quantity,
    source_updated_at AS data_as_of
FROM dbo.approved_inventory_source;
GO

GRANT SELECT ON OBJECT::mcp.v_inventory TO erp_mcp_readonly;
GO

账号只获得已批准视图的对象级 SELECT,不加入 db_ownerdb_datawriter 或长期的全库 db_datareader。新增视图需要单独评审字段、过滤条件和业务口径。

上线前必须使用目标只读账号连接目标数据库,再执行权限内省查询。管理员会话直接执行 HAS_PERMS_BY_NAME,检查到的是管理员自己的权限,不能作为只读账号的验收结果:

SELECT
    HAS_PERMS_BY_NAME(N'mcp.v_inventory', N'OBJECT', N'SELECT') AS can_read_view,
    HAS_PERMS_BY_NAME(N'mcp.v_inventory', N'OBJECT', N'UPDATE') AS can_update_view,
    HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'CREATE TABLE') AS can_create_table;

预期结果是 can_read_view = 1,其余两项为 0。主动写入失败测试只在隔离测试库执行,不在生产库用 CREATE TABLE 探测权限。

只读账号降低了写入风险,无法独自解决可用性和信息泄露问题。最大行数、超时、限流、字段白名单和审计仍然要在 MCP 层落实。

MCP 应该连接哪类数据源
#

POC 没有让 MCP 直接访问 ERP 主库。可选数据源取决于实时性和现有运维能力:

数据源数据时效实施成本适合场景
备份恢复报表库小时级或 T+1初始 POC、管理查询、主库隔离要求高
MCP 快照库分钟级到天级工具少、字段范围窄、需要快速验证
SQL Server 可读副本接近实时已有 DBA、AlwaysOn 或复制运维能力
数据仓库或数据集市批次或准实时还要承接 BI、指标治理和跨系统分析
ERP 报表导出入库批次低到中已有可信固定报表、暂不适合建设从库

已有可信固定报表时,先校验导出文件的生成时间、格式和行数,再导入独立报表库。MCP 只查询入库后的数据,不直接解析用户上传的 CSV 或 Excel。

没有现成报表时,我会从报表恢复库或快照库开始。它们更容易证明 MCP 没有主库访问路径,也方便把首批工具的字段压到最小。

MCP 配置只保存隔离数据源:

ERP_DB_MODE=isolated_readonly
ERP_DB_HOST=<reporting-host>
ERP_DB_PORT=1433
ERP_DB_NAME=ERP_REPORTING
ERP_DB_USER=erp_mcp_readonly
ERP_DB_CONNECT_TIMEOUT_SECONDS=5
ERP_DB_QUERY_TIMEOUT_SECONDS=10
ERP_DB_MAX_ROWS=200
ERP_DB_ALLOW_PRIMARY_FALLBACK=false

配置、DNS 和防火墙都不能提供主库回退。隔离库不可用时,查询失败并返回数据源状态,不能自动切到生产主库。

如果采用 AlwaysOn,可在连接串中加入 ApplicationIntent=ReadOnly 配合只读路由。账号最小权限、网络规则和数据源校验仍要保留。

启动和查询阶段都要自检
#

MCP Server 启动时先读取实际实例名与数据库名,并与配置中的允许列表比较:

SELECT
    @@SERVERNAME AS server_name,
    DB_NAME() AS database_name,
    DATABASEPROPERTYEX(DB_NAME(), N'Updateability') AS updateability;

数据库本身可能是 READ_WRITE 的报表库,因此 updateability 不能代替账号权限检查。服务还应执行上一节的 HAS_PERMS_BY_NAME 查询,确认账号只能读取批准视图。

每次工具调用执行这些限制:

  • 认证用户和角色必须允许调用该工具;
  • 参数通过 JSON Schema 和业务规则校验;
  • SQL 来自注册模板,使用参数绑定;
  • 设置查询超时、最大行数和用户级限流;
  • 记录实际连接实例、数据库、模板 ID 和数据时间;
  • 数据超过新鲜度阈值时,拒绝需要实时性的查询;
  • 隔离数据源异常时直接失败。

响应同时返回数据来源和更新时间:

{
  "request_id": "<request-id>",
  "data_source": "erp_reporting",
  "data_as_of": "<ISO-8601 timestamp>",
  "row_count": 0,
  "data": []
}

审计日志至少保留这些字段:

request_id
actor_id
source_application
tool_name
template_id
parameter_summary
data_source
data_as_of
duration_ms
row_count
outcome
error_code

日志不记录数据库密码、完整连接串、原始 SQL 文本或整批查询结果。敏感参数可以脱敏或保存稳定哈希,便于追踪重复调用。

异常时怎么停下来
#

发现异常查询、账号泄露、误连主库或隔离数据源异常时,按同一顺序处理:

  1. 停用相关 MCP 工具;影响范围不清楚时直接停止 MCP 服务,阻止新请求。
  2. 禁用只读数据库账号;怀疑误连主库时,同时阻断 MCP 到主库的网络访问。
  3. 保留审计日志、实际数据源、模板 ID、参数摘要和时间范围,不在排查中清理现场。
  4. 修复模板、权限或配置;隔离库数据状态不可信时,从可信备份重新构建。
  5. 重新完成权限检查、数据源校验和 ERP 页面对账,复核通过后再恢复服务。

用 STRIDE 检查遗漏
#

STRIDE 把风险分成六类:身份冒用、数据篡改、抵赖、信息泄露、拒绝服务和权限提升。POC 评审用这张精简表复核只读链路:

风险类别这里可能发生什么对应控制
身份冒用未授权用户调用 ERP 查询工具统一认证、角色授权、记录 actor_id
数据篡改工具或账号获得写入能力无写工具、固定模板、对象级只读权限
抵赖无法回答谁在什么时间查了什么请求 ID、工具和参数摘要审计
信息泄露查询返回客户、财务或人员敏感字段安全视图、字段最小化、脱敏和角色限制
拒绝服务大范围查询拖慢隔离库行数上限、超时、限流、慢查询监控
权限提升账号被加入高权限角色定期权限内省、变更审批、凭据轮换

这张表也说明了只读账号的边界。它能挡住多数写操作,挡不住慢查询、越权字段和不可追踪的批量读取。

从两个工具开始上线
#

我把落地过程拆成四个阶段:

阶段交付物退出条件
抓包与口径确认页面请求证据、SQL 分类、结果对账记录主查询与 ERP 页面结果一致
隔离 POC报表库或快照库、两个固定 MCP 工具无主库网络路径,写权限检查通过
安全加固认证、角色、脱敏、限流、审计和告警威胁模型与恢复演练通过
有限试运行小范围用户、数据新鲜度监控、问题回退流程试运行期间无越权和性能事故

上线前逐项检查:

  • AI 客户端和用户拿不到数据库凭据;
  • MCP 没有任意 SQL 工具和写入工具;
  • 每个工具只调用固定参数化模板;
  • 数据库账号只读批准的 mcp 视图;
  • MCP 服务器无法访问 ERP 主库;
  • 配置中没有主库连接串或主库回退;
  • 查询有超时、最大行数和限流;
  • 敏感字段已删除、脱敏或按角色限制;
  • 查询结果带数据来源和数据时间;
  • 工具结果已与 ERP 页面人工对账;
  • 审计日志可以定位调用人、模板和返回行数;
  • 隔离库异常时的停用流程已经演练。

这次 POC 让我改掉了一个假设:页面名字带“查询”,不能据此判断后台动作只读。先观察真实数据路径,再决定集成接口。

首批只需要两个或三个工具。每个工具有自己的安全视图、参数模板、行数上限和对账记录。等这些工具能够稳定回答“谁调用、查了哪份数据、数据截至何时、如何立即停用”,再扩大查询范围。

参考资料
#

AI 工程化实践 - 这篇文章属于一个选集。
4: 本文

相关文章

把 Codex 会话放到远端 Mac mini:一次 Agent Deck 部署记录
·714 字·4 分钟
AI Codex Tmux SSH DevOps Agent Deck
Claude Code + GitLab pr-agent:AI 驱动的持续迭代开发实践
·1308 字·7 分钟
AI CI/CD DevOps Pr-Agent GitLab Claude Code
2026 AI Agent 工程架构与框架选型
·1005 字·5 分钟
AI 系统架构 Agent 架构 DevOps