给封闭内网开一个外网口,技术上五分钟就能做完:防火墙加一条规则,放行 443。难的是评审会上那个问题------这条规则到底能漏多少数据出去?
答不上来。HTTPS 里的内容防火墙看不见,DNS 查询能夹带,协议字段能藏东西。你能列出"放行了哪些目标",列不出"经这条路能走出去多少种数据"。卡住不是因为防护不够,而是因为你无法穷举这条路上的通道------穷举不了就论证不了,没人敢签字。于是很多单位干脆不开,代价是外面十分钟解决的问题,里面要一天。
这篇讲第三条路:换一个结构------让内网终端与互联网之间物理上不存在任何 TCP 连接,把跨界通道收敛成数得清的三条,逐条给出封堵机制,技术堵不住的明确交给制度。
背景与适用场景
环境假设:内网与互联网物理断开;全单位只有一台服务器可联网,它被划进独立隔离区;员工终端在内网,不装任何外网软件。这是涉密单位、军工、金融常见的形态------如果你的终端本就能直连公网,本文的约束是多余的。
这篇讲 :这套隔离在安全术语里叫什么、通道怎么穷举、每条威胁路径怎么堵、堵完还剩什么,以及怎么验证"堵住了"。
这篇不讲:容器怎么开、白名单代理怎么配、断网内网怎么离线编译、一台机器能开多少席------那些是运维的事,已有另一篇专门写。
适合谁读:要为封闭网络做隔离方案、且要把它写成能过安全评审的论证文档的架构师、安全负责人。前置知识:网络分区、正向代理、Docker。
为什么不能直接给内网开一个外网口
任何"让内网终端直接连外网"的做法------哪怕加了防火墙和上网行为管理------都有四条无法根除的风险:
- 泄密通道堵不完:只要存在应用层连接,数据就能经加密隧道、DNS 隧道、协议夹带流出。防火墙看不见加密流量的内容。
- 攻击面直接暴露 :浏览器漏洞、恶意网页、被投毒的 npm / pip 包,会直接在持有内网数据的那台终端上执行。
- 爆炸半径是整个内网:一台终端失陷即可横向渗透,而封闭内网通常缺乏纵深防御------历史上假设"根本连不进来"。
- 审计不可行:终端本地行为难以完整留痕,事后回答不了"什么数据、经谁、何时出去了"。
前三条和第四条性质不同:前三条是防护强度 问题,能靠堆产品缓解;第四条是可论证性 问题,堆产品没用。所以整套设计的目标不是"更强的防护",而是让暴露面变成有限、可枚举、可逐条论证的集合。
先把话说清楚:这套东西在安全术语里叫什么
"一台服务器联网 + 容器里上网干活 + 人不出内网"不是土办法,是多层成熟隔离技术的组合,每层都有标准名称:
| 层次 | 标准术语 | 含义 |
|---|---|---|
| 网络总体 | 物理隔离(Air Gap)+ 网络分区(Network Segmentation) | 核心区与互联网物理断开;联网服务器划入独立隔离区(DMZ),只开放受控通道 |
| 上网模式 | 隔离上网(Remote Isolation / Isolated Browsing) | 用户不在本机上网,而是远程操纵隔离环境里的机器替自己上网 |
| 会话技术 | 远程浏览器隔离 RBI → 远程工作区隔离(Remote Workspace Isolation) | RBI 只隔离浏览器;把范围扩大到整个桌面工作区就是容器化云桌面 |
| 边界机制 | 协议中断(Protocol Break)+ 像素流传输(Pixel Streaming) | 安全性的核心:内网终端与互联网之间不存在任何直接的 TCP / 应用层连接。跨界的只有云桌面会话------出去只有键鼠,回来只有像素 |
| 数据交换 | 受控数据摆渡(安全隔离与信息交换,俗称"网闸") | 确需跨界的文件不走网络连接,走"暂存 → 扫描 → 审批 → 单向导入",全程留痕 |
| 工作负载 | 容器隔离 + 一次性工作区(Disposable Workspace) | 每人每任务一个容器,互不可见;爆炸半径就是容器本身,销毁重建即恢复 |
完整名称:物理隔离 + 隔离上网区 + 容器化远程工作区隔离(协议中断 / 像素流摆渡)。安全内核一句话:人不出内网、数据不出内网、危险不进内网。
协议中断为什么能一次解决两个方向的问题:传统思路里"防外泄"和"防入侵"是两套工程,协议中断把两者合并了------内网数据从未进入联网环境(无泄可防),外网内容从未以可执行形态进内网(无毒可入)。剩下的例外通道收敛成可枚举、逐容器授权的开关,这正是威胁矩阵能"逐条封堵"的前提。
#mermaid-svg-yybTvorKljoSF2im{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yybTvorKljoSF2im .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yybTvorKljoSF2im .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yybTvorKljoSF2im .error-icon{fill:#552222;}#mermaid-svg-yybTvorKljoSF2im .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yybTvorKljoSF2im .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yybTvorKljoSF2im .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yybTvorKljoSF2im .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yybTvorKljoSF2im .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yybTvorKljoSF2im .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yybTvorKljoSF2im .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yybTvorKljoSF2im .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yybTvorKljoSF2im .marker.cross{stroke:#333333;}#mermaid-svg-yybTvorKljoSF2im svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yybTvorKljoSF2im p{margin:0;}#mermaid-svg-yybTvorKljoSF2im .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yybTvorKljoSF2im .cluster-label text{fill:#333;}#mermaid-svg-yybTvorKljoSF2im .cluster-label span{color:#333;}#mermaid-svg-yybTvorKljoSF2im .cluster-label span p{background-color:transparent;}#mermaid-svg-yybTvorKljoSF2im .label text,#mermaid-svg-yybTvorKljoSF2im span{fill:#333;color:#333;}#mermaid-svg-yybTvorKljoSF2im .node rect,#mermaid-svg-yybTvorKljoSF2im .node circle,#mermaid-svg-yybTvorKljoSF2im .node ellipse,#mermaid-svg-yybTvorKljoSF2im .node polygon,#mermaid-svg-yybTvorKljoSF2im .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yybTvorKljoSF2im .rough-node .label text,#mermaid-svg-yybTvorKljoSF2im .node .label text,#mermaid-svg-yybTvorKljoSF2im .image-shape .label,#mermaid-svg-yybTvorKljoSF2im .icon-shape .label{text-anchor:middle;}#mermaid-svg-yybTvorKljoSF2im .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yybTvorKljoSF2im .rough-node .label,#mermaid-svg-yybTvorKljoSF2im .node .label,#mermaid-svg-yybTvorKljoSF2im .image-shape .label,#mermaid-svg-yybTvorKljoSF2im .icon-shape .label{text-align:center;}#mermaid-svg-yybTvorKljoSF2im .node.clickable{cursor:pointer;}#mermaid-svg-yybTvorKljoSF2im .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yybTvorKljoSF2im .arrowheadPath{fill:#333333;}#mermaid-svg-yybTvorKljoSF2im .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yybTvorKljoSF2im .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yybTvorKljoSF2im .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yybTvorKljoSF2im .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yybTvorKljoSF2im .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yybTvorKljoSF2im .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yybTvorKljoSF2im .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yybTvorKljoSF2im .cluster text{fill:#333;}#mermaid-svg-yybTvorKljoSF2im .cluster span{color:#333;}#mermaid-svg-yybTvorKljoSF2im div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yybTvorKljoSF2im .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yybTvorKljoSF2im rect.text{fill:none;stroke-width:0;}#mermaid-svg-yybTvorKljoSF2im .icon-shape,#mermaid-svg-yybTvorKljoSF2im .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yybTvorKljoSF2im .icon-shape p,#mermaid-svg-yybTvorKljoSF2im .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yybTvorKljoSF2im .icon-shape rect,#mermaid-svg-yybTvorKljoSF2im .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yybTvorKljoSF2im .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yybTvorKljoSF2im .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yybTvorKljoSF2im :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 隔离区(唯一联网,默认它会被攻破)
内网核心区(与互联网物理断开)
只有图像与输入事件
仅白名单域名
人工审核后摆渡
内网终端
内部编译机
容器化云桌面工作区
白名单出网代理
互联网
边界上只有三条通道,把它们穷举完
跨界通道只有三条:
| 通道 | 方向 | 承载什么 | 控制手段 |
|---|---|---|---|
| ① 像素流会话 | 内网 ⇄ 隔离区 | 上行只有键鼠事件与登录凭据,下行只有画面帧 | 日常上网干活的唯一通道;会话层四个开关 |
| ② 正向摆渡 | 隔离区 → 内网 | 唯一允许文件跨界的路径:代码、依赖快照、制品 | 扫描 → 审批 → 单向导入,全程留痕 |
| ②′ 反向摆渡 | 内网 → 隔离区 | 仅限脱敏后的缺陷单、需求文档等非敏材料 | 比正向更高级别的审批 |
| ③ 白名单代理 | 隔离区 → 互联网 | 容器出网的唯一路径 | 目的地白名单 + 全量审计;无直连外网的默认路由 |
②和②′ 是同一条摆渡通道的两个方向,口径上仍是三条:像素流、摆渡、白名单出口。
穷举成立靠一条物理前提:那台联网服务器关闭了内核 IP 转发,内外网卡之间没有任何路由或 NAT。没有这条,"通道只有三条"立刻变成假的,所有论证一起垮。
#mermaid-svg-UESV5gRek3F3DwC9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UESV5gRek3F3DwC9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UESV5gRek3F3DwC9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UESV5gRek3F3DwC9 .error-icon{fill:#552222;}#mermaid-svg-UESV5gRek3F3DwC9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UESV5gRek3F3DwC9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UESV5gRek3F3DwC9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UESV5gRek3F3DwC9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UESV5gRek3F3DwC9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UESV5gRek3F3DwC9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UESV5gRek3F3DwC9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UESV5gRek3F3DwC9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UESV5gRek3F3DwC9 .marker.cross{stroke:#333333;}#mermaid-svg-UESV5gRek3F3DwC9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UESV5gRek3F3DwC9 p{margin:0;}#mermaid-svg-UESV5gRek3F3DwC9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UESV5gRek3F3DwC9 .cluster-label text{fill:#333;}#mermaid-svg-UESV5gRek3F3DwC9 .cluster-label span{color:#333;}#mermaid-svg-UESV5gRek3F3DwC9 .cluster-label span p{background-color:transparent;}#mermaid-svg-UESV5gRek3F3DwC9 .label text,#mermaid-svg-UESV5gRek3F3DwC9 span{fill:#333;color:#333;}#mermaid-svg-UESV5gRek3F3DwC9 .node rect,#mermaid-svg-UESV5gRek3F3DwC9 .node circle,#mermaid-svg-UESV5gRek3F3DwC9 .node ellipse,#mermaid-svg-UESV5gRek3F3DwC9 .node polygon,#mermaid-svg-UESV5gRek3F3DwC9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UESV5gRek3F3DwC9 .rough-node .label text,#mermaid-svg-UESV5gRek3F3DwC9 .node .label text,#mermaid-svg-UESV5gRek3F3DwC9 .image-shape .label,#mermaid-svg-UESV5gRek3F3DwC9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-UESV5gRek3F3DwC9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UESV5gRek3F3DwC9 .rough-node .label,#mermaid-svg-UESV5gRek3F3DwC9 .node .label,#mermaid-svg-UESV5gRek3F3DwC9 .image-shape .label,#mermaid-svg-UESV5gRek3F3DwC9 .icon-shape .label{text-align:center;}#mermaid-svg-UESV5gRek3F3DwC9 .node.clickable{cursor:pointer;}#mermaid-svg-UESV5gRek3F3DwC9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UESV5gRek3F3DwC9 .arrowheadPath{fill:#333333;}#mermaid-svg-UESV5gRek3F3DwC9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UESV5gRek3F3DwC9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UESV5gRek3F3DwC9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UESV5gRek3F3DwC9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UESV5gRek3F3DwC9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UESV5gRek3F3DwC9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UESV5gRek3F3DwC9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UESV5gRek3F3DwC9 .cluster text{fill:#333;}#mermaid-svg-UESV5gRek3F3DwC9 .cluster span{color:#333;}#mermaid-svg-UESV5gRek3F3DwC9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UESV5gRek3F3DwC9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UESV5gRek3F3DwC9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-UESV5gRek3F3DwC9 .icon-shape,#mermaid-svg-UESV5gRek3F3DwC9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UESV5gRek3F3DwC9 .icon-shape p,#mermaid-svg-UESV5gRek3F3DwC9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UESV5gRek3F3DwC9 .icon-shape rect,#mermaid-svg-UESV5gRek3F3DwC9 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UESV5gRek3F3DwC9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UESV5gRek3F3DwC9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UESV5gRek3F3DwC9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 上行:键鼠事件 + 登录凭据
① 下行:画面帧
③ 仅白名单域名,全量审计
② 代码 / 依赖快照 / 制品
②′ 脱敏缺陷单,提级审批
内网终端浏览器
隔离区容器:云桌面
白名单出网代理
互联网:模型 API / 开源生态 / 技术资料
摆渡:扫描 → 审批 → 单向导入
内网 Git 与编译机
威胁封堵矩阵:每条路径怎么堵、还剩什么
方法是穷举两个方向的威胁路径,逐条给出封堵机制和剩余暴露。技术不可根除的明确交给制度------评审最反感每格都写"已封堵":
| 方向 | 威胁路径 | 封堵机制 | 剩余暴露 |
|---|---|---|---|
| 内网数据外泄(保密性) | 从内网终端向容器粘贴敏感内容 | 关闭"进容器"方向的剪贴板(服务端强制,非前端隐藏) | 设计上应为"封死"------这一格后文实测没通过 |
| 从内网上传文件到容器 | 关闭上传(后端 403 硬校验) | 封死 | |
| 内网数据经像素流"流出" | 不存在该路径:内网数据从未进入容器;像素流方向是容器 → 终端 | 结构性不存在 | |
| 人为对屏抄写 / 拍照泄密 | 技术不可根除 → 桌面水印(可溯源)+ 保密制度 + 行为审计 | 制度覆盖 | |
| 外网威胁进入内网(完整性) | 恶意网页 / 浏览器 0day 攻击 | 落点在容器内,爆炸半径 = 一个容器;无内网路由、用完即毁、无持久立足点 | 半径受限 |
| 被投毒的 npm / pip 包(供应链攻击) | 依赖只在容器与隔离区侧 CI 执行,不进内网;成果回内网走摆渡扫描(杀毒 + 依赖审计 + 密钥扫描) | 经门禁 | |
| 恶意文件经下载进入内网终端 | 默认禁止会话内下载;确需带回走摆渡通道 | 封死 | |
| 容器 → 内网横向渗透 | 服务器禁 IP 转发;容器网络仅达出口代理;内网口仅暴露网关端口;容器间互不可见 | 封死 | |
| 隔离区自身失陷(纵深) | 容器逃逸攻宿主机 | 非特权运行、最小 capability、定期更新运行时;宿主机上没有内网数据可拿 | 纵深防御 |
| 模型 API key 泄露 | key 由出口代理 / 网关代持,不落容器;按容器计量,异常熔断 | 最小化 | |
| 隔离区侧 Git 数据被窃 | 隔离区只放非涉密项目;涉密项目全程留在内网、用内网本地模型 | 分级隔离 |
最值得单独讲的是第三行------"结构性不存在"和"已封堵"是两种完全不同的安全强度。"已封堵"意味着有一条路,你在路上设了卡;卡可能配错、被绕过、下次升级后失效。"结构性不存在"意味着压根没有那条路,不需要任何机制去防。
评审时把"剩余暴露"列单独拎出来看,那才是真正的风险敞口:出现"制度覆盖"不丢人,出现"已封堵"却拿不出验证命令才丢人------后面会看到这不是假设。
四个开关,两组独立的 DLP
会话层的全部控制就四个开关,两组:剪贴板 (进 / 出)和文件传输 (上传 / 下载)。先把参照系钉死,这是最容易写反的地方。变量名以容器为参照:
CLIPBOARD_IN/FILES_UPLOAD= 进容器 (内网终端 → 容器)= 内网数据流向联网环境 = 泄密方向CLIPBOARD_OUT/FILES_DOWNLOAD= 出容器 (容器 → 内网终端)= 外网内容流向内网 = 引入方向
"内""外"以谁为参照,不同文档能差一百八十度------以容器为参照的"OUT",站在内网视角恰恰是"进来"。写反的后果不对称:你以为关掉的是泄密方向,实际关掉的是引入方向。所以别靠读变量名确认,要靠验生效值。
方向必须独立控制,因为风险等级根本不同:外网纯文本复制回内网笔记,低危高频刚需;内网敏感文本粘贴进联网容器,是直接泄密。单一的"剪贴板开/关"会逼你在可用性和泄密之间二选一。拆开才有这张模板表------资料进得来、数据出不去:
| 容器模板 | CLIPBOARD_IN(进容器) | CLIPBOARD_OUT(出容器) | FILES_UPLOAD | FILES_DOWNLOAD | 适用与说明 |
|---|---|---|---|---|---|
| 上网查询 | 0 禁止 | 1 允许 | 0 | 0 | 查资料。允许外网文本复制回内网笔记;严禁反向粘贴 |
| 外网开发(人类) | 0 禁止 | 1 允许 | 0 | 0 | 开发非涉密项目;成果经 Git → 摆渡回内网,不走会话下载 |
| 无人值守容器 | --- | --- | 0 | 0 | 无人用桌面,剪贴板通道无意义;出网仅白名单代理 |
| 高保密模式 | 0 禁止 | 0 禁止 | 0 | 0 | 全通道关闭,任何跨界都走摆渡;用于审计最严的岗位 |
四个模板里有三个把上传下载全关了。这不是保守,是分工:会话内传文件天然缺少扫描和审批,那是摆渡的职责。两者不重叠------重叠就意味着有一条绕过审批的路。
参考实现:开关是怎么落到协议层的
开关可用任何底座实现。想要一个能直接对照着验的现成实现,可以用 chatop(GPLv2,镜像公开):
bash
docker pull cmdbird/chatop:latest
DLP 相关的 compose 片段:
yaml
services:
chatop:
image: cmdbird/chatop:latest
ports:
- "6901:7443"
environment:
LOGIN_USER: admin
VNC_PW: &pw "换成你自己的强密码"
FILES_PW: *pw
CLIPBOARD_IN: "false" # 进容器:泄密方向,涉密场景关死
CLIPBOARD_OUT: "true" # 出容器:引入方向,纯文本低危
FILES_UPLOAD: "false"
FILES_DOWNLOAD: "false"
关键问题:这四个开关是真的,还是 UI 层的假开关? "前端把按钮藏了"和"服务端拒绝执行"在威胁矩阵里是天壤之别------前者一个浏览器控制台就能绕过。
剪贴板:参数确实到了协议层,但生效值是另一回事
剪贴板方向控制落在 KasmVNC 的协议参数上:构建期给启动脚本打补丁,把开关翻译成上游的内部变量:
dockerfile
RUN sed -i '/^APP_NAME=/a if [ "${CLIPBOARD_OUT:-1}" = "0" ]; then \
export KASM_SVC_SEND_CUT_TEXT="-SendCutText=0"; \
else export KASM_SVC_SEND_CUT_TEXT="-SendCutText=1"; fi; \
if [ "${CLIPBOARD_IN:-1}" = "0" ]; then \
export KASM_SVC_ACCEPT_CUT_TEXT="-AcceptCutText=0"; \
else export KASM_SVC_ACCEPT_CUT_TEXT="-AcceptCutText=1"; fi' \
/dockerstartup/vnc_startup.sh
为什么要打补丁?上游脚本只消费、不赋值这两个变量------它把它们拼到 VNC 服务端启动命令上,却从没定义谁来设置:
bash
vncserver $DISPLAY -depth $VNC_COL_DEPTH ... $VNCOPTIONS \
$KASM_SVC_SEND_CUT_TEXT $KASM_SVC_ACCEPT_CUT_TEXT
-SendCutText=0 的含义是服务端不发送剪贴板内容,是 RFB 协议层的行为,不是界面上藏个按钮。这就是"换 Guacamole / noVNC 也能做到同效,但方向控制得自己改协议层"的具体含义------你得知道上游把这能力藏在哪个参数里,还得确认没人在后面把它改回去。
第一步实测:参数确实落到了协议层。 起一个 CLIPBOARD_OUT=0 的容器,读 VNC 服务进程自己的命令行(别用 docker exec 看 env,那拿的是 Docker 配置值,会误判):
bash
$ tr '\0' '\n' < /proc/<Xvnc-pid>/cmdline | grep CutText
-SendCutText=0 # ← 补丁注入的值,确实在命令行上
-AcceptCutText=0
到这里"不是假开关"成立。但"参数在命令行上"不等于"参数生效了"------把命令行拉到底,同一个参数出现了第二次:
bash
-SendCutText=0 ...(中间几十个参数)... -SendCutText 1
后者是 KasmVNC 的 vncserver 包装脚本从 YAML 默认配置(data_loss_prevention.clipboard.server_to_client.enabled: true)派生、追加在末尾的。哪个赢? 查运行中服务端的生效值:
bash
$ vncconfig -get SendCutText
1 # ← 补丁被覆盖,剪贴板"出容器"方向仍然是开的
为确认不是误读:同一条命令行里另有三对同名重复参数,全部后到者胜------既证明覆盖规则,也证明 vncconfig 读的是服务端真值:
| 参数 | 前面的值 | 后面的值 | 生效值 |
|---|---|---|---|
FrameRate |
24 | 60 | 60 |
BlacklistThreshold |
0 | 5 | 5 |
DynamicQualityMax |
7 | 9 | 9 |
结论:在我实测的这版公开镜像上,只设 CLIPBOARD_IN/OUT 关不掉剪贴板通道------补丁注入的参数位置靠前,被上游 YAML 派生的同名参数覆盖了。
根因在 vncserver 这个 perl 包装脚本里,一行就能定位:
perl
$cmd .= ConfigToCmd(); # 把 YAML 配置派生成 CLI 参数,追加在末尾
它把 YAML 里的剪贴板默认值翻译成同名参数接在你的参数后面 ,而 Xvnc 后到者胜。所以往命令行前面塞 -SendCutText=0 这个思路本身就是死路------只要真源还是 YAML,命令行永远赢不了。
读者可以立刻用的临时办法:既然 YAML 是真源,那就覆盖 YAML。挂载一份改过的进去,实测生效值变 0:
yaml
# /etc/kasmvnc/kasmvnc.yaml ------ 挂载覆盖后,生效值变为 0
data_loss_prevention:
clipboard:
server_to_client:
enabled: false
client_to_server:
enabled: false
bash
$ vncconfig -get SendCutText # → 0
$ vncconfig -get AcceptCutText # → 0
但挂文件不适合做产品开关 ------用户改的是环境变量,不该被要求额外挂一个文件。翻 KasmVNC 的 perl 模块能找到它自己的配置覆盖通道(ConfigEnvVars.pm):KVNC_ 前缀 + 配置路径点号换下划线大写,且需要 YAML 里 server.allow_environment_variables_to_override_config_settings: true。于是根治的写法是让开关改走这条官方通道:
bash
# 改的是配置真源本身,ConfigToCmd() 据此派生的就是唯一那组参数,不存在自相覆盖
export KVNC_DATA_LOSS_PREVENTION_CLIPBOARD_SERVER_TO_CLIENT_ENABLED=false # 对应 CLIPBOARD_OUT=0
export KVNC_DATA_LOSS_PREVENTION_CLIPBOARD_CLIENT_TO_SERVER_ENABLED=false # 对应 CLIPBOARD_IN=0
改完实测,三种情形都对得上------注意第三行,验证"能关"还不够,还得验证没把开关焊死:
| 配置 | SendCutText |
AcceptCutText |
|---|---|---|
修复前,CLIPBOARD_OUT=0 CLIPBOARD_IN=0 |
1 ❌ |
1 ❌ |
| 修复后,同样配置 | 0 ✅ |
0 ✅ |
修复后,只设 CLIPBOARD_OUT=0 |
0 ✅ |
1 ✅ 未受牵连 |
文件传输:后端 403 硬校验,实测通过
文件侧不走 VNC 协议,由后端直接校验:
python
if self.path.startswith("/apps/files/list"):
if not FILES_DOWNLOAD: return self._json(403, {"error":"download disabled"})
if self.path.startswith("/apps/files/upload"):
if not FILES_UPLOAD: return self._json(403, {"error":"upload disabled"})
绕过前端直接打后端(FILES_UPLOAD=false、FILES_DOWNLOAD=false):
text
config : {"upload": false, "download": false, "dir": "/home/admin/Desktop"}
list : {"error": "download disabled"} <HTTP 403>
download : {"error": "download disabled"} <HTTP 403>
upload : {"error": "upload disabled"} <HTTP 403>
四个接口全部 403,前端藏不藏按钮无关紧要。这一组才能放心写进矩阵的"封死"格。
这件事真正的教训
同样是"服务端强制",一组经得起验、一组没经住,差别在于剪贴板那条链路多了一层会回写同名参数的上游默认值。开关的"设置值"不等于"生效值"------矩阵里每格"封死"都欠着一条能在运行中系统上跑出来的验证命令。拿不出来的那格就是空头支票,而且最危险:你以为它堵上了,于是不再看它。
这里面有个更一般的规律:自己写的校验反而更让人放心,委托出去的那个更容易漏 。文件侧那两个开关是自己在接口里判的,return 403 就在眼前;剪贴板侧是把意图"传"给第三方组件,中间隔了一层配置派生逻辑------传出去之后发生了什么,不实测就不知道。凡是安全开关落在第三方组件上,验的必须是组件的终态 (/proc/<pid>/cmdline、组件自己的查询命令),而不是"我传了什么"。
最后交代状态,免得读者按图索骥踩空:上面这个缺陷在写作时才被发现,修复已提交,但公开镜像尚未重建 ------也就是说,此刻 docker pull 拿到的仍是"设了 CLIPBOARD_IN=0 也关不掉"的版本。在重建发布前,需要该能力的话请用上面的挂载 YAML 临时办法,并用 vncconfig -get 自检。这也正是本文的论点本身:别信文档(包括这篇)说什么,在你自己的运行中系统上跑一遍。
推广成三条:
- 验生效值,不验配置值:多层配置(env → 启动脚本 → 包装脚本 → 上游默认值)任何一层都可能覆盖上一层。
- 验证要打到实现层:在界面上点一点看"按钮没了",什么也证明不了。
- 写进验收清单,每次升级重跑:上游升个版本、改一个默认值,这类覆盖就会静默复活。
成果怎么进内网:摆渡三类对象
摆渡承载三类对象:代码(git bundle)、依赖快照(含 SBOM)、制品与文档。命令在运维篇,这里只讲为什么。
代码为什么用 git bundle 而不是压缩包:bundle 带完整提交历史,内网侧可以逐 commit 重放和审查。压缩包只能告诉你"最终长这样",回答不了"这行代码是谁、在哪次提交里加的"。审计要的是后者。
隔离区侧的产物为什么不作交付物 :隔离区默认会被攻破,在假设已失陷的环境里编译出的二进制没有理由信任。那侧编译只作自测,产物不出隔离区;进内网的是源码 + 依赖快照,交付物必须由内网可复现构建产生。这也是依赖快照单列一类的原因------没有它,断网的内网编译不动。
为什么批量而不是实时:实时同步等于打通网络连接,隔离名存实亡。批量窗口是用时效换取"每个文件都被人看过"。
反向摆渡为什么要更高审批 :正向运的是不可信内容,风险是"带毒进来",扫描能拦住大半;反向运的是内网材料,风险是"带密出去",而技术识别不了语义上的涉密------机器能扫出密钥串,扫不出"这段业务逻辑描述本身就是秘密"。
项目分级:什么放隔离区,什么留内网
前面所有机制都有一个前提:进入隔离区的材料本身不涉密。它靠分级保证:
| 级别 | 项目类型 | 开发位置 | 模型 | 成果回流 |
|---|---|---|---|---|
| 开放 | 工具类、开源组件、无业务数据的通用模块 | 隔离区容器(含无人值守 24×7) | 云端大模型 | 摆渡回内网归档 |
| 受限 | 业务相关但可脱敏(接口定义、UI、通用逻辑) | 隔离区容器,仅使用脱敏材料 | 云端大模型 | 摆渡 + 内网集成联调 |
| 涉密 | 核心业务逻辑、真实数据、加密与安全模块 | 全程内网 | 仅内网本地模型(私有化部署) | 不出内网 |
分级红线:判定在任务卡创建时完成、由授权人签认,出错方向"就高不就低"------拿不准就按涉密留在内网。
必须诚实指出:这是全架构唯一无法用技术兜底的口子。威胁矩阵每格都能论证,全靠"容器里没有内网数据"这个前提;而它不是任何开关保证的,是人的判定保证的。有人把涉密材料"顺手"带进容器,"结构性不存在"那格当场失效------不是被绕过,是前提没了。分级不是管理装饰,是技术论证的地基。
残余风险:技术根除不了的那部分
任何声称"零风险"的隔离方案都是在骗人。主动交出这张表,比被问出来强:
| 残余风险 | 为什么技术不能根除 | 管理措施 |
|---|---|---|
| 对屏抄写 / 拍照 | 像素最终要呈现给人眼 | 桌面身份水印(用户名 + 时间,可溯源);保密协议与教育;敏感岗位物理管控 |
| 剪贴板文本引入恶意内容 | 允许"出容器"方向时外网文本可进内网 | 剪贴板事件全量审计;高保密模板直接关闭;内网终端禁止执行来路文本 |
| 联网服务器单点 | 它天然是内外网的交汇点 | 最小化系统、及时补丁、完整性监控、管理全程录屏;冷备镜像,失陷即整机重装 |
| 分级误判(涉密材料流入隔离区) | 判定发生在人脑,技术看不见语义 | 任务卡分级签认制;容器内容定期抽查;发生即按泄密事件处置并复盘规则 |
| 审批疲劳(摆渡走过场) | 流程可以被敷衍 | 扫描报告作为审批前置(没有报告不能批);审批质量纳入考核;抽样复审 |
第一行是这套方案的理论上限:像素最终要呈现给人眼,只要人能看见,就能拍下来。技术能做的只有让它可溯源(水印),剩下是制度和审计的事。在这条上吹"彻底杜绝"的,要么没想清楚,要么在忽悠。
再加一条实测出来、原表里没有的风险------开关静默失效 :上游默认值可能覆盖你设的开关,且不报错。它不像前五条"技术不能根除",而是能根除但会复发:升一次级就可能回来。措施是把生效值验证固化进升级验收清单,而不是靠记性。
这套东西的价值不在某一条配置,而在于结构:不存在直接连接、通道穷举成三条、逐条论证、论证不了的交给制度。安全性于是从"靠识别和拦截"变成"结构上不存在那条路"------前者永远在追着堵,后者才睡得着觉。
但结构给你的是可论证性 ,不是已论证 。每格"封死"都得有人真去跑一遍验证命令。我这次只验了四个开关,就当场撞见两个和文档写的不一样------配置可以照抄,生效值请自己验。