【虎鲸养殖手册 B007】命令执行的安全细节:白名单不是防弹衣
位置:V0.1.0 · C02/C03|因子:授权(把"能执行"收窄成可枚举的动作)|台阶:可控执行 · 弧线:被壳调用之前
一、上一篇留下的问题
B006 的结尾是这个问题:
白名单按名字和真实路径放行,
cmd /c为什么还能把a.bat变成另一件事?字符白名单挡住了&,为什么挡不住语义?
上一篇把失败写成了"给下一轮的信",信里有一半内容其实是"我拿到的参数是什么"。这一篇顺着参数往下走:参数从页面进来,穿过类型判断、脚本白名单、字符白名单、解释器,最后变成子进程的 argv。每一道关都亮了绿灯,然后呢?
位置提醒:本篇是安全篇,按《虎鲸进化论》第 30 篇的写法定式------只讲治理、边界与监控思路,不提供任何绕过/对抗手法。文中所有"可能出问题"的例子都停留在机制层,不给出可直接使用的载荷。
1.1 一次"全部通过"的执行
以页面上最常见的脚本任务为例,从点击到进程启动,要穿过这些关:
POST /run收下type=SCRIPT&script=<名字>;TaskType.valueOf(type)把类型转成枚举;TaskService.prepare把参数写进task.json;TaskExecutor.scriptStep调ScriptWhitelist.resolve(name);ScriptWhitelist检查名字格式、文件存在、toRealPath后仍在固定脚本目录内;Args.safe按字符白名单过滤参数;Executor.runBatch判断操作系统,Windows 走cmd.exe /c;Executor.run用参数数组启动ProcessBuilder,起读线程、设超时。
每一步都有检查,且检查代码写得不算敷衍:名字有正则、路径有 realPath 前缀、参数有字符白名单、命令用数组而不是拼字符串。如果安全评审只看"有没有检查",这次执行能拿满分。
但"有没有检查"是个错误的问题。正确的问题是:这些检查各自在回答什么?它们合起来覆盖了哪些事实?
1.2 三个"通过检查、语义却不对"的位置
下面三处都不是实拍事故,而是按代码逐行推演出来的结构性缺口(标注:代码推演,未实跑):
第一处:buildId 没有形状校验。 页面上的部署表单允许填 buildId,ConsoleController 原样收下,DeployRunner.deploy(buildId) 直接用它拼出目录:
java
public ExecResult deploy(String buildId, Path outputTarget) {
ScriptWhitelist.Resolved script = whitelist.resolve(SCRIPT);
List<String> args = com.zhiyong.hujing.infra.exec.Args.safe(
buildId,
props.builds().resolve(buildId).toString(),
props.systemADeployTarget().toString());
return executor.runBatch(script.path(), args, null, outputTarget, Duration.ofMinutes(3));
}
Args.safe 只检查字符,而字符白名单允许 .、/、\、:------也就是说,一个包含上级目录或盘符的 buildId 可以完整通过校验 ,然后被拼进 builds().resolve(buildId) 和脚本参数。脚本是不是真的会"越界写",取决于脚本怎么用它;但防线这边已经先放弃了判断。
第二处:参数字符白名单不区分语义。 SAFE 允许的字符集是 [A-Za-z0-9_.:/\\-],它能挡住 & | > < ^ % " ' ; 空格 \``,这确实挡住了 cmd.exe的二次解析;但它挡不住"一个合法的字符序列表达了一个不该被接受的语义"------上级目录、盘符绝对路径、网络共享路径、带流名称的文件路径、以前导-` 开头的选项,全都由这些"安全字符"组成。
第三处:超时只杀直接子进程。 Executor 超时后调用 process.destroyForcibly()。在 Windows 上,process 是 cmd.exe;cmd.exe 启动的 .bat 里再启动的程序,是它的孙子。杀掉 cmd.exe 不等于杀掉脚本里的进程------孙子可能继续持有端口、继续写文件、继续跑。于是"超时"在账本上是一次终止,在操作系统里可能只是一次"父进程没了"。
三处缺口共享一个特征:检查的是"名字/字符",执行的是"语义/进程树"。 检查与执行之间,隔着一层没人负责的解释。
1.3 白名单到底回答了什么
把 ScriptWhitelist.resolve 真正回答的问题列出来:
| 它回答 | 它不回答 |
|---|---|
名字符合 [A-Za-z0-9_-]{1,64} 吗? |
这个名字背后的脚本内容可信吗? |
| 固定目录里存在同名文件吗? | 文件在检查之后会不会被替换? |
toRealPath 之后还在目录内吗? |
脚本运行时会用参数去碰哪里? |
| 平台对应的扩展名存在吗? | 扩展名与解释器真的匹配吗? |
一句话:白名单证明"这是名单里的那个东西",不证明"这次用它做的事是安全的"。 前者是身份认证,后者是行为授权;把前者当后者,就是"白名单不是防弹衣"的全部含义。
《虎鲸进化论》第 30 篇《安全护栏》有一句话,正好可以当本篇的判据:
安全设计的核心指标,不是"它多难犯错",而是"它犯错时,最坏能坏到哪"。
白名单降低的是"犯错的概率"(不让随便一个脚本跑起来),它几乎不改变"犯错的半径"(名单里的脚本照样可以用参数碰到沙箱外、可以生出杀不死的孙子、可以继承一整套环境变量)。概率与半径是两件事,安全设计必须分别回答。
白名单像小区门禁------它确实拦住了外卖员,但它不检查进了门的业主今天是不是来砸车的。门禁的 KPI 是"拦了多少外人",不是"楼里出了多少事"。
1.4 一个必须先承认的前提:这是本地演示工程
本工程从 B001 起就声明"一个 jar + 一块硬盘",默认跑在本机、单人使用、没有账号体系。这个前提决定了它的安全基线:
- 没有登录:能访问端口的人就能提交任务;
- 没有角色:任何请求都能触发构建、部署、脚本执行;
- 没有租户隔离:所有任务共用一个进程、一套目录、一份配置。
在本机当玩具,这些是"省略";一旦把 jar 放到一台有网卡的机器上,它们就变成"缺口"。这一篇不假装 v0.1.0 是个安全系统,而是把它当作一个可以被逐层加固的教学样本:先看清哪些事实没被检查,再决定补哪一层。
1.5 评审前先回答三个问题
安全评审最容易变成"检查项大合唱":有没有白名单、有没有转义、有没有超时------每一项都答"有",然后放行。但清单回答不了真正重要的问题。评审前必须先写清三件事:
- 资产是什么? 这台机器上有什么值得保护:源码、产物、部署目标、环境变量里的凭据、以及"能执行命令"这个能力本身。
- 攻击者能到哪? 只能在本机点页面,还是同网段可达,还是公网可达?他能不能改工作区文件、能不能改仓库配置、能不能提交任务?
- 最坏结果是什么? 数据被删、产物被换、命令被执行到别处、还是只是任务失败?前三种不可逆,最后一种可重试。
这三个问题的答案决定了防线的形状。如果攻击者只能在本机、且本机只有你一个用户,那么"无认证"是合理省略;如果端口在网卡上,同一个"无认证"就是最高优先级缺口。同一段代码,在不同的威胁模型下,安全等级可以相差一个数量级。
所以本工程的 README 里应该有一句话,而不是让读者猜:"本工程按本机单人使用设计,默认监听所有网卡但不应暴露到不可信网络。" 这句话不是免责声明,它是威胁模型的一部分;没有它,后面所有的"安全设计"都缺少前提。
二、七条不变式
把"命令执行应该守住什么"写成可检查的式子:
| 编号 | 不变式 | 一句话解释 | 本版状态 |
|---|---|---|---|
| S1 | 命令与数据分离 | 命令是代码,参数是数据,两者不得拼成同一个字符串交给解释器 | 部分(数组 + cmd /c) |
| S2 | 名字与路径双校验 | 允许的名字 + 解析后的真实路径都在白名单根内 | ✓(脚本名)/ ✗(buildId) |
| S3 | 数据形状校验 | 每个进入命令的字段有自己的语法(长度、字符集、结构),不是笼统的"安全字符" | ✗(buildId、args) |
| S4 | 检查后使用窗口最小化 | realPath 检查与执行之间的可替换窗口要尽量小 | ✗(无措施) |
| S5 | 收尸完整 | 终止一个任务,必须终止它的整棵进程树 | ✗(只杀直接子进程) |
| S6 | 最小权限 | 运行身份、工作目录、环境变量都只给必需的那一点 | ✗(同用户、全继承) |
| S7 | 可审计 | 谁触发、带什么参数、结果如何,可追溯 | 部分(有任务账本,无调用者身份) |
2.1 每条不变式对应一个真实缺口
| 不变式 | 缺口 | 证据位置 |
|---|---|---|
| S1 | runBatch 必须经 cmd.exe /c,参数数组在 .bat 场景下仍被 shell 重新解释 |
Executor.runBatch |
| S2 | 脚本名有 realPath 前缀检查,buildId 完全没有 |
ScriptWhitelist.resolve vs DeployRunner.deploy |
| S3 | Args.SAFE 只定义"允许哪些字符",不定义"什么形状才合法" |
Args.java |
| S4 | resolve 返回真实路径后,文件仍可被替换 |
ScriptWhitelist.resolve 的返回处 |
| S5 | destroyForcibly() 只作用于直接子进程 |
Executor.run 的超时分支 |
| S6 | 子进程继承父进程环境变量;部署脚本的 workDir 传 null |
DeployRunner.deploy |
| S7 | 账本记录任务与参数,但没有"谁提交的" | ConsoleController.run |
这张表最重要的不是缺口数量,而是缺口的分布 :S1/S2 是"名字层"的,恰好也是本工程检查得最认真的地方;S3--S6 是"语义层/进程层/权限层"的,几乎全空。人总是把检查加在自己看得懂的那一层。
2.2 "检查点"不等于"执行边界"
一个容易混淆的概念:检查点(checkpoint)是代码里的一次判断,执行边界(enforcement boundary)是操作系统真正执行约束的地方。
ScriptWhitelist是检查点:它在 Java 进程里判断一次;- 文件系统权限是执行边界:它由内核在每次打开文件时强制执行;
Args是检查点:它在字符串进数组前判断一次;cmd.exe是执行边界:它按自己的规则重新切分命令行;- 超时 kill 是检查点+执行边界的一半:
destroyForcibly发了信号,但只覆盖直接子进程。
检查点可以被绕过(代码路径、竞态、二次解析),执行边界不能。 安全设计的重心,应该放在"把判断下沉成执行边界":路径校验下沉为只读目录与文件句柄,参数校验下沉为脚本侧的强类型解析,进程收尸下沉为进程组/作业对象。只写检查点而不建边界,就是"在纸门上贴封条"。
2.3 一条看起来安全、其实反向的测试
ArgsTest 里有一个用例,名字叫 允许安全字符包括盘符路径:
java
@Test
void 允许安全字符包括盘符路径() {
// 路径为示意,非真实机器路径
String path = "C:\\data\\builds\\b20261015-173500";
assertThat(Args.isSafe(path)).isTrue();
assertThat(Args.safe(path)).containsExactly(path);
}
它把"盘符绝对路径能通过"当作正面断言。在"构建脚本需要接收仓库路径和产物目录"的语境下,这个用例是合理的------脚本确实需要绝对路径;但它同时把"绝对路径可以通过参数白名单"固化成了规格。于是未来有人想收紧白名单(比如禁止盘符),这条用例会第一个变红,而且它的名字听起来非常正当。
测试不撒谎,测试只忠实记录写它时的假设。当假设本身是缺口时,绿测试就是缺口的合格证。 这与 B006 那条"带上原因"的用例是同一类病:断言证明的是"代码做了我以为的事",不是"这件事是对的"。
2.4 安全不变式的验收方式:用"必须拒绝"的输入
功能测试用"合法输入 → 期望成功";安全测试要用"边界输入 → 期望拒绝",而且必须断言拒绝发生在哪一层 。只断言"抛异常"不够:Args 抛异常和 ScriptWhitelist 抛异常,说明的是两种不同的防线在起作用;如果异常来自一个更晚、更弱的层,等于前面那层没干活。
本版的现状是:Args 有 11 个"必须拒绝"的字符用例,ScriptWhitelist 有 9 个"必须拒绝"的名字用例,但没有一个用例覆盖"合法字符组成的非法语义" ------上级目录、盘符、选项前缀、网络路径,一个都没有。这不是测试写得少,是测试的威胁模型只到"shell 元字符"这一层。
2.5 信任边界应该画在哪
把一次任务提交按"可信程度"分区,可以得到一张很清楚的图:
#mermaid-svg-9wQzciEyEx5lGJqI{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-9wQzciEyEx5lGJqI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9wQzciEyEx5lGJqI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9wQzciEyEx5lGJqI .error-icon{fill:#552222;}#mermaid-svg-9wQzciEyEx5lGJqI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9wQzciEyEx5lGJqI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9wQzciEyEx5lGJqI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9wQzciEyEx5lGJqI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9wQzciEyEx5lGJqI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9wQzciEyEx5lGJqI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9wQzciEyEx5lGJqI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9wQzciEyEx5lGJqI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9wQzciEyEx5lGJqI .marker.cross{stroke:#333333;}#mermaid-svg-9wQzciEyEx5lGJqI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9wQzciEyEx5lGJqI p{margin:0;}#mermaid-svg-9wQzciEyEx5lGJqI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9wQzciEyEx5lGJqI .cluster-label text{fill:#333;}#mermaid-svg-9wQzciEyEx5lGJqI .cluster-label span{color:#333;}#mermaid-svg-9wQzciEyEx5lGJqI .cluster-label span p{background-color:transparent;}#mermaid-svg-9wQzciEyEx5lGJqI .label text,#mermaid-svg-9wQzciEyEx5lGJqI span{fill:#333;color:#333;}#mermaid-svg-9wQzciEyEx5lGJqI .node rect,#mermaid-svg-9wQzciEyEx5lGJqI .node circle,#mermaid-svg-9wQzciEyEx5lGJqI .node ellipse,#mermaid-svg-9wQzciEyEx5lGJqI .node polygon,#mermaid-svg-9wQzciEyEx5lGJqI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9wQzciEyEx5lGJqI .rough-node .label text,#mermaid-svg-9wQzciEyEx5lGJqI .node .label text,#mermaid-svg-9wQzciEyEx5lGJqI .image-shape .label,#mermaid-svg-9wQzciEyEx5lGJqI .icon-shape .label{text-anchor:middle;}#mermaid-svg-9wQzciEyEx5lGJqI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9wQzciEyEx5lGJqI .rough-node .label,#mermaid-svg-9wQzciEyEx5lGJqI .node .label,#mermaid-svg-9wQzciEyEx5lGJqI .image-shape .label,#mermaid-svg-9wQzciEyEx5lGJqI .icon-shape .label{text-align:center;}#mermaid-svg-9wQzciEyEx5lGJqI .node.clickable{cursor:pointer;}#mermaid-svg-9wQzciEyEx5lGJqI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9wQzciEyEx5lGJqI .arrowheadPath{fill:#333333;}#mermaid-svg-9wQzciEyEx5lGJqI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9wQzciEyEx5lGJqI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9wQzciEyEx5lGJqI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9wQzciEyEx5lGJqI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9wQzciEyEx5lGJqI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9wQzciEyEx5lGJqI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9wQzciEyEx5lGJqI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9wQzciEyEx5lGJqI .cluster text{fill:#333;}#mermaid-svg-9wQzciEyEx5lGJqI .cluster span{color:#333;}#mermaid-svg-9wQzciEyEx5lGJqI 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-9wQzciEyEx5lGJqI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9wQzciEyEx5lGJqI rect.text{fill:none;stroke-width:0;}#mermaid-svg-9wQzciEyEx5lGJqI .icon-shape,#mermaid-svg-9wQzciEyEx5lGJqI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9wQzciEyEx5lGJqI .icon-shape p,#mermaid-svg-9wQzciEyEx5lGJqI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9wQzciEyEx5lGJqI .icon-shape .label rect,#mermaid-svg-9wQzciEyEx5lGJqI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9wQzciEyEx5lGJqI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9wQzciEyEx5lGJqI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9wQzciEyEx5lGJqI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 执行边界区:操作系统
检查点区:Java 进程
不可信区:表单/接口
buildId 绕过检查点
直接进 resolve
孙进程不在作业对象内
type / script / buildId / args
类型枚举
名字正则
字符白名单
文件权限 / 只读目录
进程组 / 作业对象
运行账户
这张图有三个要点:
- 表单永远是"不可信区",不管它长得多像一个内部页面。只要端口可达,它就是外部输入。
- Java 里的判断只是"检查点区",它的结论只在那一次函数调用里有效;文件、进程、权限这些事实,最终由操作系统执行。
- 两条虚线是缺口的形状:一条是数据绕过检查点直接进路径,一条是进程逃出执行边界。看得见的实线都有人管,看不见的虚线才是事故发生的地方。
画这张图的好处是:它迫使你把每个字段标在某个区里。buildId 标不进去------它从不可信区出发,没有经过检查点,却直接影响执行边界。一个字段在图上找不到位置,通常就是防线上的洞。
三、方案空间:命令到底怎么发起
发起一条子进程,有四种常见做法:
| 方案 | 做法 | 安全性 | 代价 | 本版取舍 |
|---|---|---|---|---|
| A 拼 shell 字符串 | "cmd /c " + script + " " + args |
最低:字符串即代码 | 写法最短 | 否决 |
| B 参数数组直接 exec | new ProcessBuilder(cmdArray) |
高:没有 shell 参与 | 只能启动真实可执行文件 | 默认(非 .bat) |
C 数组 + cmd /c |
数组传给 cmd.exe,由它执行 .bat |
中:多一层解析 | 必须做字符/形状校验 | 选用(.bat) |
| D 远程执行器/沙箱 | 容器、作业对象、专用执行服务 | 最高:权限与生命周期隔离 | 重:部署与运维成本 | 暂不(V0.4 后再评估) |
3.1 为什么否决 A:字符串一旦拼起来,边界就消失了
java
// 被否决:拼一条命令字符串交给 shell
String line = "cmd /c " + scriptPath + " " + String.join(" ", args);
Process p = Runtime.getRuntime().exec(line);
三宗罪:
- 引号与空格没有唯一正确解。 路径带空格要不要加引号?参数本身带引号怎么办?不同 shell 的规则不同,拼字符串的人永远在猜。
- 检查与执行用的不是同一个解析器。 你在 Java 里按字符检查,
cmd.exe按自己的规则重新切分;两边对"什么是一个参数"的理解可能不一致,检查通过不代表执行时还是那个意思。 - 命令与数据混在一起。 一旦参数里出现能让 shell 改变解析状态的东西,参数就升级成了代码。这正是"命令注入"的定义,不需要例子。
否决 A 不是为了"更优雅",而是为了让检查点和执行边界使用同一套切分规则 。数组传给 ProcessBuilder 时,Java 与操作系统对 argv 的切分是一致的;这是 B 的核心价值。
3.2 为什么默认选 B:数组是"最小惊讶"
Executor.run 的入口是 List<String> cmd,直接交给 new ProcessBuilder(cmd)。没有 shell,没有引号规则,没有元字符。参数里有 &、|、空格,都只是一个普通字符------因为根本没有解释器去看它。
这条路径的代价是能力受限:只能启动真实的可执行文件,不能跑 shell 内建命令,不能依赖 PATH 之外的别名。对"构建/部署/只读 git"这类固定动作,这个限制恰好是优点。
3.3 为什么又必须选 C:Windows 的 .bat 没有别的入口
.bat 不是可执行文件,它是 cmd.exe 的脚本。要跑它,只能让 cmd.exe /c <路径> 来解释。于是数组方案在这一步退化:Java 把数组拼成命令行交给 cmd.exe,cmd.exe 再解析一遍。
这就是 Args 字符白名单存在的原因:既然无法避免二次解析,就尽量让参数里不出现能改变解析状态的字符。它挡住了 & | > < ^ % " ' ; 空格 这些"解析状态开关",这是必要的。
但它只是必要条件,不是充分条件 :一个完全由安全字符组成的参数,仍然可以是一个越界的路径、一个危险的选项、一个指向网络共享的位置。字符白名单防的是"解释器被欺骗",防不了"程序被误导"。
3.4 C 的隐式契约:Java 的引用规则要刚好合 cmd 的胃口
runBatch 把脚本的绝对路径作为一个数组元素传进去。Java 在 Windows 上会把含空格的参数用双引号包起来,再拼成命令行;cmd.exe 收到后按自己的规则去掉引号、切分参数。两边对"引号"的理解必须一致,这条链才成立。
这是一条没有写在任何地方的隐式契约 :它依赖 JDK 的参数引用实现、Windows 的 CreateProcess 规则、cmd.exe 的解析规则三者恰好兼容。它在大多数情况下成立,但它不是本工程显式保证的东西;一旦路径里出现引号、尾随反斜杠等边界情况,契约就可能破。安全设计里,依赖隐式契约等于把边界交给别人维护。
3.5 D 什么时候值得
如果未来要执行"自生成脚本"(V0.4 以后),脚本内容不再由人审核,D 就不再是过度设计:容器提供文件系统与网络的硬边界,作业对象提供进程树的硬收尸,低权限账户提供身份的硬隔离。判断标准不是"技术先不先进",而是被执行的代码是否可信。可信代码用 B/C 足够;不可信代码必须上 D。
四、机制:五道关与它们的盲区
一次脚本任务的完整路径:
#mermaid-svg-sVSMCGSY8zR98ZQX{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-sVSMCGSY8zR98ZQX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sVSMCGSY8zR98ZQX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sVSMCGSY8zR98ZQX .error-icon{fill:#552222;}#mermaid-svg-sVSMCGSY8zR98ZQX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sVSMCGSY8zR98ZQX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sVSMCGSY8zR98ZQX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sVSMCGSY8zR98ZQX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sVSMCGSY8zR98ZQX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sVSMCGSY8zR98ZQX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sVSMCGSY8zR98ZQX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sVSMCGSY8zR98ZQX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sVSMCGSY8zR98ZQX .marker.cross{stroke:#333333;}#mermaid-svg-sVSMCGSY8zR98ZQX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sVSMCGSY8zR98ZQX p{margin:0;}#mermaid-svg-sVSMCGSY8zR98ZQX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sVSMCGSY8zR98ZQX .cluster-label text{fill:#333;}#mermaid-svg-sVSMCGSY8zR98ZQX .cluster-label span{color:#333;}#mermaid-svg-sVSMCGSY8zR98ZQX .cluster-label span p{background-color:transparent;}#mermaid-svg-sVSMCGSY8zR98ZQX .label text,#mermaid-svg-sVSMCGSY8zR98ZQX span{fill:#333;color:#333;}#mermaid-svg-sVSMCGSY8zR98ZQX .node rect,#mermaid-svg-sVSMCGSY8zR98ZQX .node circle,#mermaid-svg-sVSMCGSY8zR98ZQX .node ellipse,#mermaid-svg-sVSMCGSY8zR98ZQX .node polygon,#mermaid-svg-sVSMCGSY8zR98ZQX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sVSMCGSY8zR98ZQX .rough-node .label text,#mermaid-svg-sVSMCGSY8zR98ZQX .node .label text,#mermaid-svg-sVSMCGSY8zR98ZQX .image-shape .label,#mermaid-svg-sVSMCGSY8zR98ZQX .icon-shape .label{text-anchor:middle;}#mermaid-svg-sVSMCGSY8zR98ZQX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sVSMCGSY8zR98ZQX .rough-node .label,#mermaid-svg-sVSMCGSY8zR98ZQX .node .label,#mermaid-svg-sVSMCGSY8zR98ZQX .image-shape .label,#mermaid-svg-sVSMCGSY8zR98ZQX .icon-shape .label{text-align:center;}#mermaid-svg-sVSMCGSY8zR98ZQX .node.clickable{cursor:pointer;}#mermaid-svg-sVSMCGSY8zR98ZQX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sVSMCGSY8zR98ZQX .arrowheadPath{fill:#333333;}#mermaid-svg-sVSMCGSY8zR98ZQX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sVSMCGSY8zR98ZQX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sVSMCGSY8zR98ZQX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sVSMCGSY8zR98ZQX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sVSMCGSY8zR98ZQX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sVSMCGSY8zR98ZQX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sVSMCGSY8zR98ZQX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sVSMCGSY8zR98ZQX .cluster text{fill:#333;}#mermaid-svg-sVSMCGSY8zR98ZQX .cluster span{color:#333;}#mermaid-svg-sVSMCGSY8zR98ZQX 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-sVSMCGSY8zR98ZQX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sVSMCGSY8zR98ZQX rect.text{fill:none;stroke-width:0;}#mermaid-svg-sVSMCGSY8zR98ZQX .icon-shape,#mermaid-svg-sVSMCGSY8zR98ZQX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sVSMCGSY8zR98ZQX .icon-shape p,#mermaid-svg-sVSMCGSY8zR98ZQX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sVSMCGSY8zR98ZQX .icon-shape .label rect,#mermaid-svg-sVSMCGSY8zR98ZQX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sVSMCGSY8zR98ZQX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sVSMCGSY8zR98ZQX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sVSMCGSY8zR98ZQX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 孙进程不受控
buildId 直接拼路径
表单/接口
type, script, buildId
TaskType.valueOf
params 落账本
ScriptWhitelist
名字 + realPath 前缀
Args.safe
字符白名单
Executor.runBatch
按 OS 选解释器
cmd.exe / bash
子进程树
超时后仍存活
builds().resolve(buildId)
图里两条虚线是本篇的重点:一条是"账本里的字段绕过白名单直接进路径",另一条是"进程树的下半截没人管"。 实线部分(名字、字符)恰恰是检查最密的地方。
4.1 代码段 1:三道真实防线
java
// 第一道:名字 + 真实路径
public Resolved resolve(String scriptName) {
if (scriptName == null || !NAME.matcher(scriptName).matches()) {
throw new NotWhitelisted("脚本名不合法或未登记:" + scriptName);
}
Path candidate = props.fixedScripts().resolve(scriptName + ext);
if (!Files.isRegularFile(candidate)) continue;
Path real = candidate.toRealPath();
Path realRoot = props.fixedScripts().toRealPath();
if (!real.startsWith(realRoot)) {
throw new NotWhitelisted("脚本解析到白名单目录之外:" + scriptName);
}
return new Resolved(real, ext.equals(".bat"));
}
// 第二道:字符白名单
private static final Pattern SAFE = Pattern.compile("[A-Za-z0-9_.:/\\\\-]{0,128}");
// 第三道:按 OS 选解释器,再走参数数组
if (os.contains("win")) {
cmd.add("cmd.exe"); cmd.add("/c");
cmd.add(script.toAbsolutePath().normalize().toString());
} else {
cmd.add("bash"); cmd.add(script.toAbsolutePath().normalize().toString());
}
cmd.addAll(safeArgs);
return run(cmd, workDir, outputFile, timeout);
三句注解:
- 第一道是三道里质量最高的 :名字正则收紧到字母数字下划线连字符,
toRealPath解析掉符号链接后再做前缀比较,这正好防住了"名字合法但真实路径跑出目录"的经典穿越。它的局限在 S4:检查完到执行前,文件仍可能被替换。 - 第二道只定义"允许的字符集",不定义"字段的语法"。它是一张网,网眼大小由字符决定,而不是由字段含义决定。
- 第三道里藏着一个死字段 :
Resolved带了windowsBatch,但runBatch完全没用它,而是按os.name重新判断。于是"白名单解析出的扩展名"和"实际使用的解释器"是两条独立逻辑,可能出现.sh被解析、却被cmd.exe执行(或反之)的错配。
4.2 buildId:一条完全绕过白名单的路
buildId 来自表单,进入 DeployRunner.deploy 后做了两件事:拼目录、进参数。两件事都没有形状校验。对比 ScriptWhitelist 对脚本名的严格程度,这是一处刺眼的不对称:
| 字段 | 来源 | 校验 | 后果 |
|---|---|---|---|
script |
表单 | 正则 + 文件存在 + realPath 前缀 | 只能在固定目录选脚本 |
subcommand |
表单 | 集合白名单 | 只能选 6 个 git 子命令 |
buildId |
表单 | 无(仅字符白名单) | 可影响路径与脚本参数 |
args |
内部/测试 | 字符白名单 | 可表达任意合法字符语义 |
不对称的根因不是疏忽,而是每个字段的"重要性"由谁定义:写脚本白名单的人知道脚本名危险,写 buildId 的人只把它当"一个编号"。安全评审最容易漏的,正是那些"看起来只是编号/名字/备注"的字段。
4.3 字符白名单的四个"合法但危险"的语义
不需要给出可执行载荷,只看字符构成就能明白:
| 语义 | 由哪些"安全字符"组成 | 为什么字符白名单挡不住 |
|---|---|---|
| 相对上级目录 | . 与路径分隔符 |
每个字符都合法,组合起来指向父目录 |
| 带盘符的绝对路径 | 字母、:、分隔符 |
盘符本身就是合法字符 |
| 网络共享路径 | 分隔符重复 | 没有非法字符,但目标不在本机 |
以前导 - 开头的选项 |
连字符 | 对脚本/工具来说,这是"选项"不是"数据" |
| 带流名称的文件 | 字母、.、: |
在某些文件系统上指向文件的附加流 |
这张表说明:"允许的字符集"是一个语法概念,"能不能碰某个位置"是一个语义/授权概念。 用语法去表达授权,永远会漏。
4.4 死字段 windowsBatch:一条没人读的"安全声明"
Resolved 记录里有一个 windowsBatch,看起来是"这个脚本该用哪个解释器"的权威答案。但 TaskExecutor 只取了 script.path(),Executor.runBatch 又按 os.name 重新判断。grep windowsBatch 的结果是:只有声明,没有读取。
这不是一个无害的冗余字段。它制造了一种虚假的确定性 :读代码的人会以为"解释器选择已经由白名单决定了",从而不去检查 runBatch 的平台分支;而真正的选择逻辑在一个完全不看 windowsBatch 的地方。安全代码里最危险的不是缺一个判断,而是有一个看起来已经判断过的字段。
4.5 "检查后使用":TOCTOU 不是理论问题
resolve 返回 real(真实路径)之后,Executor 才启动进程。两者之间有一个窗口:文件可以被替换、被删除、被改写成另一个内容。窗口有多长?包括参数构造、线程启动、ProcessBuilder.start() 的开销------在负载高的时候,足够完成一次替换。
这不是"有人一定会攻击"的问题,而是约束模型的问题:如果脚本目录对执行用户可写,那么"白名单"只约束了"路径",没有约束"内容"。要真正收紧,需要把判断下沉成执行边界:
- 脚本目录对运行账户只读(文件系统权限,而不是 Java 判断);
- 启动前校验文件哈希/大小,并与登记值比对(缩小窗口,但不能消除);
- 打开文件句柄后再执行(平台相关,复杂);
- 或者接受窗口,但把"脚本目录可写"列为显式的威胁模型假设。
本版一条都没做。这不是"忘了",而是 v0.1.0 的威胁模型只到"名字不能乱填",没到"文件内容不可变"。威胁模型写在哪,防线就停在哪。
4.6 git 子命令:名字安全,不等于动作安全
GitRunner 的白名单是 status / log / rev-parse / branch / pull / fetch,注释写着"只读为主"。但这六个名字背后的动作半径完全不同:
| 子命令 | 网络 | 改工作区 | 可能触发的额外输入 | 风险档 |
|---|---|---|---|---|
status / log / rev-parse / branch |
否 | 否 | 仓库配置里的外部监视程序 | 低(但非零) |
fetch |
是 | 否(改引用) | 远程地址与远程助手配置 | 中 |
pull |
是 | 是(合并进工作区) | 同上 + 合并策略 + 钩子 | 高 |
白名单把它们放在同一个集合里,等于说"这六个一样安全"。实际上 pull 是"联网 + 合并 + 可能执行钩子"的组合动作,它和 status 之间的距离,比白名单内外之间的距离还大。
更根本的一点:仓库本身是 git 的输入,而仓库配置可以声明"让谁来做某件事"。 子命令白名单只约束了"调哪个动作",没有约束"这个动作会调用什么"。如果仓库来自不可信来源(克隆、同步、别人给的压缩包),那么"允许 pull/fetch"就等于允许仓库配置参与决定执行路径。
这不是要恐吓读者去审计每一行 git 配置,而是给出一个判断方法:看一个命令的安全半径,不要看它的名字,要看它读哪些输入、写哪些位置、能调用谁。 按这个方法,pull 应该被单独分级,而不是和 status 共享一个 Set。
4.7 双扩展名回退:一条没人读的"平台判断"
ScriptWhitelist.resolve 还有一个容易被忽略的细节:它会把 .bat 和 .sh 都加进候选列表,先试平台对应的扩展名,再试另一个。也就是说,在 Windows 上,如果 .bat 不存在而 .sh 存在,它会解析成功,并返回 windowsBatch=false。
然后 Executor.runBatch 完全不看这个标志,而是按 os.name 决定用 cmd.exe 还是 bash。于是可能出现:
- Windows 上解析到
.sh,却用cmd.exe去执行它; - Linux 上解析到
.bat,却用bash去执行它; - 失败时的错误信息只会说"退出码非零"(B006 的债),不会说"解释器选错了"。
这不是一个高深的安全问题,但它是安全代码里最典型的"看起来判断过"的陷阱 :Resolved.windowsBatch 的存在让人以为平台已经判断完了,而真正的判断在另一个文件里,且与它无关。死字段比缺字段更危险,因为它会让人停止检查。
五、崩溃点与边界:失败时留下什么
5.1 七种失败,账本上各自留下什么
| 失败点 | 触发条件 | 账本/系统现状 | 真正的风险 |
|---|---|---|---|
| 类型非法 | type 不在枚举内 |
valueOf 抛异常,页面 500,无任务记录 |
无审计痕迹 |
buildId 越界 |
含上级目录/盘符 | 路径被拼出;脚本可能写到 builds 之外 |
产物污染、后续部署指向错误位置 |
| 脚本被替换 | 检查后、执行前 | 执行的是替换后的内容 | 白名单只保住了路径 |
| 参数语义漂移 | 合法字符表达越界/选项 | 参数通过校验,脚本自行解释 | 越权读写、危险选项 |
| 超时 | 脚本或其子进程长时间运行 | 只杀 cmd.exe;孙进程存活 |
端口/文件锁被占,任务"已结束"但系统仍在跑 |
| 读线程滞后 | 子进程被杀但输出未读完 | await(5s) 后继续收尾 |
任务已终态,日志仍在追加(B005 的顺序问题) |
| 部署工作目录 | runBatch(..., null, ...) |
子进程继承应用进程的 cwd | 脚本里的相对路径落在应用目录,而不是沙箱 |
最后一条最容易被忽视:BuildRunner 明确把工作目录设为仓库目录,DeployRunner 却传了 null。同一类动作,一个指定了 cwd,一个继承了父进程 cwd------脚本里任何一个相对路径,在构建和部署时的含义都不同。 这不是安全问题本身,而是安全问题的温床:你无法用一条规则描述"脚本能碰哪里",因为它的起点就不固定。
5.2 被否决的写法:黑名单过滤
另一种常见做法是先列"危险字符",命中就拒绝:
java
// 被否决:黑名单
private static final Pattern DANGEROUS =
Pattern.compile("[&|<>^%\"'`;\\s]");
static void check(String arg) {
if (DANGEROUS.matcher(arg).find()) {
throw new IllegalArgumentException("参数含危险字符:" + arg);
}
}
三句注解:
- 黑名单永远在追新方言。 换一个解释器、换一个平台、换一种编码,就多一批没列进去的写法;而白名单的默认值是拒绝,漏了也只是误杀,不会放行。
- 它检查的仍然是字符,不是语义。 把
&挡住,不代表"上级目录"被挡住;把空格挡住,不代表"选项"被挡住。 - 它给了人一种"已经防住了"的错觉。 黑名单的代码越短,越容易被当作安全边界;而它连"检查与执行用同一个解析器"这个前提都没解决。
本工程用的是字符白名单而不是黑名单,这一步是对的;但白名单只能解决"解释器被欺骗",解决不了"程序被误导"。两者不可互相替代。
5.3 建议形态:形状校验 + 路径收口 + 进程树收尸
下面三段是"下一版应该长什么样",v0.1.0 未实现,按本系列纪律明确标注为建议:
java
// 1) 字段形状:不是"允许哪些字符",而是"这个字段长什么样"
private static final Pattern BUILD_ID =
Pattern.compile("b\\d{8}-\\d{6}(?:-[A-Za-z0-9]{1,16})?");
static String requireBuildId(String raw) {
if (raw == null || !BUILD_ID.matcher(raw).matches()) {
throw new IllegalArgumentException("buildId 形状不合法");
}
return raw;
}
// 2) 路径收口:解析后必须仍在根内(与脚本白名单同一套判据)
static Path contained(Path root, String id) {
Path base = root.toAbsolutePath().normalize();
Path p = base.resolve(id).normalize();
if (!p.startsWith(base)) {
throw new IllegalArgumentException("路径越过根目录:" + id);
}
return p;
}
// 3) 进程树收尸:先快照后代,再杀父,再杀快照
static void killTree(Process p) {
var descendants = p.descendants().toList(); // 必须先快照
p.destroyForcibly();
descendants.forEach(ProcessHandle::destroyForcibly);
}
三个要点:
- 形状校验先于一切 :
buildId只允许"b + 8 位日期 + 6 位时间 + 可选后缀",越界路径在进入resolve之前就被拒绝,根本轮不到字符白名单。 - 路径收口复用同一套判据 :脚本用
toRealPath前缀,产物路径用normalize前缀;两者都不接受"看起来在根内"的相对路径。 - 后代必须先快照 :Linux 上父进程死亡后,子进程会被 reparent 到 init,再查
descendants()就查不到了;Windows 的进程树语义又不同。先快照后杀,是跨平台唯一稳妥的顺序。绝不要按镜像名批量杀进程------那会带走机器上别人的同类进程(这是本项目的一条硬纪律)。
5.4 超时不是隔离,是"尽力而为"
destroyForcibly() 的语义是"请求操作系统强制终止这个进程",不是"终止这个任务"。两者之间差着:孙进程、文件锁、端口绑定、共享内存、临时文件。
所以"超时"这个能力必须写明它的保证范围:
| 保证 | 是否成立 |
|---|---|
| 账本上任务会进入终态 | 成立 |
| 直接子进程会被杀死 | 成立(平台允许时) |
| 孙子进程会被杀死 | 不成立 |
| 端口/文件锁会被释放 | 不成立 |
| 输出不会在终态后继续写入 | 不成立(读线程可能还在) |
一个诚实的系统应该说"超时已标记,但资源释放需要进程树收尸";一个不诚实的系统会说"任务已终止",然后让下一个任务去撞那个还被占着的端口。
5.5 环境变量与工作目录:看不见的继承
ProcessBuilder 默认继承父进程的整个环境。对本工程来说,这意味着子进程能看见:应用启动时的 PATH、JAVA_HOME、可能存在的凭据类变量、以及任何运维顺手 export 的东西。脚本本身是白名单里的,但脚本会把这些变量传给谁、写进哪个日志,母进程一概不知。
最小权限在这一层的做法很朴素:
- 显式设置
environment(),只保留必需项(PATH的最小集、脚本需要的目录变量); - 清掉或覆盖可能含凭据的变量;
- 工作目录统一为沙箱内的固定目录,构建/部署都不例外;
- 子进程的输出按"可能含敏感信息"处理(B005 的日志纪律)。
本版一条都没做。于是"白名单里的脚本"拥有和主进程几乎相同的环境视野------白名单限制了它叫什么,没有限制它看见什么。
六、实测:名单、端口与测试的三重证据
6.1 图 1:白名单是配置,不是保证

这张图能证明两件事:
- 可执行脚本只有
build-systemA、deploy-systemA、fail-demo三个; - git 子命令只有
status / log / rev-parse / branch / pull / fetch,页面明确写着"其余一律拒绝"。
它不能证明的是:这三个脚本拿到参数后会做什么、pull/fetch 在仓库配置被改动后会不会触发额外行为、脚本文件在运行前有没有被替换。页面展示的是"名单",不是"行为"。 这也是所有白名单界面的共同局限:它把"允许"可视化得很清楚,把"允许之后能发生什么"完全隐藏。
6.2 图 2:一台没有锁的控制台

图里的四个路径值被涂黑了(脱敏纪律),留下的事实是:版本 0.1.0、端口 11111、不依赖数据库/Redis、状态全落在工作目录。
配合配置可以确认一个更关键的事实:application.yml 里只写了 server.port: 11111,没有写 server.address 。Spring Boot 在没有指定地址时绑定所有网卡,这意味着同一局域网内的任何机器都可能访问这个控制台;而控制台上没有任何登录、角色或令牌校验,POST /run 可以直接触发脚本、构建、部署。
这不是"演示工程的 bug",而是"本地玩具"与"联网服务"之间的边界没有被声明。一个没有锁的控制台,放在本机叫方便,放在网卡上叫入口。 本篇把它列为第一笔债,不是因为它复杂,而是因为它把前面所有白名单的努力一次性归零:攻击者不需要绕过白名单,他只需要按页面上的按钮。
我们在脚本名上装了指纹锁、虹膜锁、声纹锁,然后发现大门是敞开的。安全评审最怕的不是锁不够多,而是锁都装在门框上。
6.3 测试证据:绿的那些,恰好避开了缺口
| 测试 | 断言 | 它证明了什么 | 它没证明什么 |
|---|---|---|---|
允许安全字符包括盘符路径 |
绝对路径 isSafe 为真 |
脚本能收到绝对路径 | 绝对路径是否该被允许 |
拒绝cmd会二次解析的字符 |
11 个 shell 元字符被拒 | 字符白名单有效 | 合法字符组成的越界语义 |
目录穿越一律被拒 |
4 个名字形态被拒 | 名字正则有效 | 解析后文件被替换 |
白名单内的固定脚本可以解析 |
build-systemA 可解析 |
正常路径可用 | windowsBatch 是否被使用 |
| (无) | --- | --- | buildId 的任何校验 |
| (无) | --- | --- | 超时后的进程树 |
第五、六行是空行,比前四行更能说明问题:测试覆盖的是"实现者想到的攻击面",而不是"系统的攻击面"。 想到的那部分测得挺细(11 个元字符、4 种穿越写法),没想到的那部分一个用例都没有。
6.4 字段 × 防线普查
把进入命令的每个字段,与四类防线对照(✓=有,△=部分,✗=无):
| 字段 | 形状校验 | 白名单 | 路径收口 | realPath |
|---|---|---|---|---|
script |
✓(名字正则) | ✓(固定目录) | ✓ | ✓ |
subcommand |
✗ | ✓(集合) | --- | --- |
buildId |
✗ | ✗ | ✗ | ✗ |
args |
✗ | △(仅字符) | ✗ | ✗ |
| 仓库路径 | --- | --- | △(配置固定) | ✗ |
| 工作目录 | --- | --- | △(构建有、部署无) | ✗ |
| 环境变量 | --- | ✗ | ✗ | ✗ |
| 调用者身份 | --- | ✗ | --- | --- |
这张表有一个很清晰的形状:防线集中在"脚本名"这一列,越往右、越往下的字段,防线越稀。 这不是本工程独有的,而是绝大多数"命令执行型系统"的共性------大家把注意力放在"跑哪个程序",而不是"用什么身份、带什么数据、在哪个目录、留下什么进程"。
6.5 未实跑声明
本节的所有结论分两类:
- 实测/代码事实 :图 1、图 2 的字段值;
application.yml无server.address;ArgsTest/ScriptWhitelistTest的断言行;windowsBatch无读取点;buildId无校验代码。这些都可以用截图或grep复核。 - 推演 :
buildId越界后脚本会写到哪、超时后孙进程会存活多久、cmd.exe在特定参数下的切分结果。这些依赖脚本实现与平台行为,本篇没有实跑,因此只用于说明结构缺口,不作为"已发生的故障"。
把两类结论分开,是安全写作的底线:推演可以用来设计防线,不能用来指控系统。
6.6 可复用的安全普查方法
这套普查不依赖任何工具,五步就能在一个陌生工程上复现:
| 步骤 | 动作 | 本工程的产出 |
|---|---|---|
| 1 | 列出所有"能启动进程/读文件/发网络"的入口 | Executor、GitRunner、ScriptWhitelist、四个 Controller |
| 2 | 从入口反向找"哪些字段流进来" | type / script / subcommand / buildId / args |
| 3 | 对每个字段问:"它的形状有定义吗?" | script 有,buildId 没有 |
| 4 | 对每个字段问:"它的值最终到达哪个执行边界?" | buildId 到达文件路径与脚本参数 |
| 5 | 对每条边界问:"出界时系统能发现吗?" | 路径越界无检测;孙进程无检测;越权触发无记录 |
第 5 步是最容易被跳过的一步,也是最有价值的一步:安全不是"有没有检查",而是"出界时有没有人知道"。 一个系统可以检查得很少但观测得很好(出界立即告警),也可以检查得很多但完全不可观测(出界后一切正常)。后者的危险在于,它会一直被评为"安全",直到出事那天。
这套方法还有一个副产品:它能直接生成验收用例。第 5 步的每个"无检测",就是一条应当先红后绿的用例;第 3 步的每个"没有形状",就是一条应当被拒绝的输入。普查表天然就是测试清单,比拍脑袋想攻击场景可靠得多。
七、反例与欠账:十三笔,按爆炸半径排
按"出事时最坏能坏到哪"排序,而不是按"实现难度":
| # | 欠账 | 现状证据 | 最坏后果 |
|---|---|---|---|
| ① | 无认证 + 默认绑定所有网卡 | application.yml 只有端口,无 server.address;无登录 |
同网段任何人可触发脚本/部署(图 2) |
| ② | buildId 无形状/路径校验 |
DeployRunner.deploy 直接 resolve(buildId) |
产物写到 builds 之外,后续流程指向错误位置 |
| ③ | 超时不杀进程树 | 只 destroyForcibly() 直接子进程 |
孙进程占端口/文件锁,"已结束"是假的 |
| ④ | Args 只到字符层 |
SAFE 放行 . / \ : - |
越界路径、盘符、网络路径、危险选项通过 |
| ⑤ | 工作目录不一致 | 构建传仓库目录;部署传 null |
同一脚本在不同动作下相对路径含义不同 |
| ⑥ | 环境变量全继承 | ProcessBuilder 未设置 environment() |
凭据类变量暴露给子进程 |
| ⑦ | windowsBatch 是死字段 |
只声明、无读取;解释器按 OS 另判 | 扩展名与解释器可能错配,且给人"已判断"的错觉 |
| ⑧ | 脚本目录可写 | 无只读/哈希/句柄措施 | 检查后替换(TOCTOU),白名单只保住路径 |
| ⑨ | 参数落账本与日志 | params 持久化;日志打印命令行 |
敏感参数进入 task.json 与页面 |
| ⑩ | git 仓库配置是"可执行输入" | 子命令白名单不覆盖 config/hooks/remote | pull/fetch 可能触发仓库配置里的额外行为 |
| ⑪ | 测试把绝对路径当正面 | ArgsTest.允许安全字符包括盘符路径 |
收紧白名单时先红的是正确方向 |
| ⑫ | 类型非法 → 500、无任务记录 | TaskType.valueOf 未捕获 |
失败无审计痕迹 |
| ⑬ | 无速率/并发约束 | 单线程池可无限排队 | 请求堆积,队列成为内存与响应时间的问题 |
7.1 十三笔债,四种病
- 身份病(①⑬):不知道"谁在按按钮",只知道自己"能按"。这是 v0.1.0 最大的一类,因为它是全局性的。
- 数据病(②④⑨):字段只做字符检查,不做形状与语义检查;且把参数当普通文本到处落盘。
- 进程病(③⑤⑥⑦):只管"启动",不管"怎么活、在哪活、活下来的还有谁"。
- 时间病(⑧⑩):检查与使用之间、配置与执行之间存在窗口,窗口里的事情没人负责。
- 证据病(⑪⑫):测试记录的是旧假设,失败记录不完整。
修复顺序不应该按编号,而应该按"是否放大其他债":先修 ①(不然一切白修),再修 ②③(半径最大),再修 ④⑤⑥⑦(语义与进程),最后处理 ⑧⑩(窗口类,成本最高、收益最依赖威胁模型)。
7.2 为什么"先修 ①"不是一句口号
假设其余十二笔都修好了,但 ① 没修:一个同网段的请求仍然可以按页面按钮,触发一次合法、白名单内、参数形状正确、进程树收尸完整的部署------所有防线都正常工作,只是对着错误的人工作。
安全的顺序永远是:先确认"谁能做",再优化"怎么做"。 反过来做,就是把一间金库的门锁做到军工级,然后把钥匙挂在门上。
7.3 修复批次:按"是否放大其他债"排
| 批次 | 欠账 | 成本 | 前置条件 | 验证方式 |
|---|---|---|---|---|
| 第零批(上线前闸门) | ① 认证/绑定 | 小 | 无 | 从另一台机器访问必须失败 |
| 第一批 | ② buildId 形状 + ③ 进程树收尸 | 中 | 无 | 越界 buildId 必拒;超时后进程树为空 |
| 第二批 | ④ 参数形状 + ⑤ 显式 cwd + ⑥ 环境最小化 + ⑦ 用起 windowsBatch |
中 | 先定义每个脚本的参数契约 | 契约用例 + 工作目录断言 |
| 第三批 | ⑧ 只读脚本目录/哈希 + ⑩ git 子命令分级 | 大 | 威胁模型明确"仓库是否可信" | 换文件测试必须失败;pull 单独授权 |
| 第四批 | ⑪ 测试改写 + ⑫ 失败审计 + ⑬ 排队上限 | 小 | 无 | 用例名与断言一致;非法类型留痕 |
第零批之所以叫"闸门",是因为它不修也能跑,但它决定了其他修复有没有意义。第一批之所以排第二,是因为 ②③ 会直接放大 ①(一旦有人能触发,越界与残留进程就是现实后果)。
一个实用的排序口诀:先关大门,再收半径,再管参数,最后谈窗口。 窗口类问题(TOCTOU、配置注入)成本最高、收益最依赖威胁模型,适合在身份与半径都稳定之后再投入。
八、可迁移结论:白名单是能力表,不是防弹衣
8.1 三个问题,缺一不可
任何"命令执行"能力,都要能回答三个问题:
- 谁能触发?(身份与授权)
- 允许它做什么?(能力清单:脚本、子命令、参数形状)
- 做错了会坏到哪?(爆炸半径:路径、身份、进程树、资源)
本工程前两个问题做了一半(有名单、无身份),第三个问题基本没做(无收尸、无最小权限、无窗口控制)。这不是"安全没做好",而是安全的三个维度里只画了一个。
8.2 白名单的正确用法
白名单的本质是"把无限的能力空间收缩成有限枚举"。它适合回答"能不能用这个脚本/这个子命令",不适合回答"用了之后安不安全"。要让白名单真正有用,必须给它配三样东西:
| 配套 | 作用 | 本版 |
|---|---|---|
| 形状校验 | 让参数只能落在枚举允许的形态里 | 缺(buildId) |
| 路径收口 | 让任何路径都落在根目录内 | 缺(产物路径) |
| 执行边界 | 权限、只读目录、进程树收尸 | 缺 |
一句话:白名单负责"选工具",形状与边界负责"用工具"。 只选工具不管用法,等于给了一把刀,然后不规定它切什么。
8.3 可证伪条件
- 条件一(拒绝):用 100 个不在白名单内的脚本名提交,期望 100 次拒绝、0 次进程启动。这是本版唯一接近成立的一条。
- 条件二(越界) :提交含上级目录或盘符的
buildId,期望在进入resolve前被拒绝。当前会通过(§4.2),改造后必须变红为绿。 - 条件三(收尸) :让脚本启动一个长时间子进程并触发超时,5 秒后检查进程树,期望 0 个存活。当前不成立(§5.4),需要实测确认孙进程存活时间。
- 条件四(入口) :从另一台机器访问
11111端口,期望连接失败(只绑本机)或要求认证。当前预期成功 (无server.address、无登录),这是上线前必须关闭的闸门。 - 条件五(回归) :上述改造不得破坏正常构建/部署;
ArgsTest里那条"盘符路径"的正面用例应被改写为"仅在构建/部署内部构造的路径允许",而不是删除------允许加约束,不允许删事实。
8.4 一条反面提醒:安全不是"多加一层正则"
最容易发生的退化是:每出一次问题,就加一条正则;正则越加越多,代码越来越像咒语,最后没人敢改,也没人知道哪条还在生效。检查点的数量不等于安全强度;执行边界才是。
正确的增量是:每次发现缺口,问"这条判断能不能下沉成执行边界"------路径判断下沉为只读目录,参数判断下沉为脚本侧的强类型解析,进程收尸下沉为进程组/作业对象。下沉不了的部分,才留在 Java 里做检查点,并明确写下它的窗口与假设。
安全代码里最贵的一句话是"这里应该没问题"。"应该"两个字,通常意味着写的人也不知道边界在哪。
8.5 安全需要可观测:不知道边界有没有守住,就等于没有
只写防线不做观测,等于在黑暗里锁门------锁没锁上,全靠信仰。本工程已经有任务账本,这是很好的底子;在它之上,安全观测只需要四类读数:
| 读数 | 采集点 | 用途 | 现状 |
|---|---|---|---|
| 拒绝计数(按层) | 名字层 / 字符层 / 形状层 | 某层突然变多,说明有人在试;某层长期为 0,说明它可能没生效 | 无 |
| 超时后存活进程数 | killTree 之后枚举 |
收尸是否真的完成 | 无 |
| 脚本哈希变化 | 启动前比对 | 白名单内容是否被换 | 无 |
| 触发来源 | 请求地址 + 身份 | 谁在按按钮;非本机访问是否出现 | 无(连身份都没有) |
这四类读数有一个共同点:它们测的都是"边界",不是"业务"。业务指标(构建成功率、部署耗时)会掩盖安全问题------一次越权部署也可能"成功",而且在成功率上看起来很好看。
告警也应该围绕边界设:非本机来源的请求(在引入认证之前)、同一层拒绝计数突增、超时后仍有后代存活、脚本哈希与登记值不一致。安全告警的第一原则是"宁可误报,不可漏报边界";第二原则是"每条告警都能指向一个具体的执行边界",否则它会变成噪音,最后被静音。
九、下一篇的问题
B007 讨论的是"谁能让它动、它动的时候能碰什么"。但还有一个更基本的问题没被回答:它说自己动了,你凭什么信?
页面上写着"运行中",到底是真在跑,还是演示数据在演?右上角没有「演示数据」徽标,就能证明这是真的吗?
下一篇(B008)讲控制台三态:真实数据、演示兜底、空态。三种状态的判据是什么,为什么"没有徽标"不能作为"真实"的证据,以及一个演示数据如何在不撒谎的前提下仍然有用。
本篇新增术语:T99 语义白名单(约束字段含义而非字符集)、T100 参数形状校验(每个字段自己的语法)、T101 进程树收尸(先快照后代再终止)、T102 检查后使用(TOCTOU 窗口)、T103 爆炸半径分级(按可逆性而非难度)、T104 信任边界(检查点 vs 执行边界)。
卷Ⅰ 还剩一篇:B008 控制台三态。写完它,卷Ⅰ 的"地基"就闭合了:一块硬盘、一份依赖、一套目录、三段执行、一条日志、一句错误、一道边界、一张诚实的脸。
读法提示:本篇对应设计文档 10 §3.2(执行器与白名单)、02 §四(安全与边界);代码出处为
code/dev/v0.1.0/的infra/exec/Args.java、infra/exec/ScriptWhitelist.java、infra/exec/Executor.java、infra/git/GitRunner.java、infra/build/BuildRunner.java、infra/deploy/DeployRunner.java、web/ConsoleController.java、src/main/resources/application.yml,以及src/test/.../ArgsTest.java、ScriptWhitelistTest.java。图 1 为白名单行裁切,图 2 的运行路径值已涂黑。
附:本版执行防线速查
text
入口:POST /run(无认证、默认绑定所有网卡)
类型:TaskType.valueOf(非法 → 500,无任务记录)
脚本:NAME 正则 + 固定目录 + toRealPath 前缀 ✓
参数:字符白名单(放行 . / \ : -,不校验形状) △
buildId:无校验,直接拼路径与参数 ✗
解释器:Windows cmd /c,Linux bash(windowsBatch 死字段) △
进程:数组启动 + 读线程 + 超时 destroyForcibly(仅直接子进程) △
收尾:任务终态;孙进程、cwd、env 均不受控 ✗
边界声明
- 本篇为安全治理分析,遵循"只讲防线与边界、不提供绕过手法"的写法定式;文中未给出任何可直接使用的攻击载荷。
- 图 1、图 2 来自公开演示工程的系统信息页;图 2 中四个路径值已用色块覆盖,端口与版本为工程默认值。
buildId越界、超时孙进程、cmd.exe切分等结论标注为代码推演 ,未在本机实跑;截图与grep能证明的部分已单独标注为代码事实。- Spring Boot 未配置
server.address时绑定所有网卡,属于框架默认行为;若部署环境另有反向代理/防火墙约束,实际暴露面以环境为准,本篇不替环境背书。

转载请注明出处:https://lizhiyong.blog.csdn.net/article/details/169653636
(本文为《虎鲸养殖手册》第 007 篇;系列连载中)