0%

第 14 章:Bash 安全分析

第 14 章:Bash 安全分析

"在 AI 获得 shell 的那一刻,安全就不再是可选配置,而是系统设计的核心约束。"

Bash 工具是 Claude Code 中最强大也最危险的能力——它赋予 AI 直接在用户系统上执行任意 shell 命令的权力。这种能力的安全边界完全依赖于本章所述的多层安全分析机制。我们将从底层的命令解析开始,逐层上升到沙箱隔离,完整剖析这一安全体系。


14.1 命令解析

在进行任何安全判断之前,系统必须首先理解命令的语义结构。这一过程涉及 shell 引号处理、命令分割、环境变量剥离等多个步骤。

14.1.1 引号内容提取

bashSecurity.ts 中的 extractQuotedContent 函数实现了一个精确的 shell 引号状态机:

// src/tools/BashTool/bashSecurity.ts
function extractQuotedContent(command: string, isJq = false): QuoteExtraction {
  let withDoubleQuotes = ''
  let fullyUnquoted = ''
  let unquotedKeepQuoteChars = ''
  let inSingleQuote = false
  let inDoubleQuote = false
  let escaped = false

  for (let i = 0; i < command.length; i++) {
    const char = command[i]

    if (escaped) {
      escaped = false
      if (!inSingleQuote) withDoubleQuotes += char
      if (!inSingleQuote && !inDoubleQuote) fullyUnquoted += char
      if (!inSingleQuote && !inDoubleQuote) unquotedKeepQuoteChars += char
      continue
    }

    if (char === '\\' && !inSingleQuote) {
      escaped = true
      // ...
      continue
    }
    // ...
  }
  return { withDoubleQuotes, fullyUnquoted, unquotedKeepQuoteChars }
}

这个函数产生三种不同的"去引号"视图:

  • withDoubleQuotes:仅剥离单引号内容,保留双引号内容(因为双引号内仍可能有变量展开)
  • fullyUnquoted:剥离所有引号内容,仅保留裸露的 shell 语法
  • unquotedKeepQuoteChars:剥离引号内容但保留引号字符本身,用于检测引号相邻的特殊模式(如 'x'#

这三个视图服务于不同的安全验证器。例如,命令替换检测($(...))需要在 fullyUnquoted 上进行,因为单引号内的 $( 不会被 shell 展开;而 mid-word hash 检测需要 unquotedKeepQuoteChars,因为 echo 'x'#comment 中引号的位置关系对 shell 解析有影响。

14.1.2 命令替换模式检测

系统维护了一个完整的命令替换模式列表:

const COMMAND_SUBSTITUTION_PATTERNS = [
  { pattern: /<\(/, message: 'process substitution <()' },
  { pattern: />\(/, message: 'process substitution >()' },
  { pattern: /=\(/, message: 'Zsh process substitution =()' },
  { pattern: /(?:^|[\s;&|])=[a-zA-Z_]/, message: 'Zsh equals expansion (=cmd)' },
  { pattern: /\$\(/, message: '$() command substitution' },
  { pattern: /\$\{/, message: '${} parameter substitution' },
  { pattern: /\$\[/, message: '$[] legacy arithmetic expansion' },
  { pattern: /~\[/, message: 'Zsh-style parameter expansion' },
  { pattern: /\(e:/, message: 'Zsh-style glob qualifiers' },
  { pattern: /\(\+/, message: 'Zsh glob qualifier with command execution' },
  { pattern: /\}\s*always\s*\{/, message: 'Zsh always block' },
  { pattern: /<#/, message: 'PowerShell comment syntax' },
]

这份列表涵盖了 Bash 和 Zsh 两种 shell 的命令替换语法。特别值得注意的是 Zsh 的等号展开(=cmd)——=curl evil.com 在 Zsh 中等价于 /usr/bin/curl evil.com,可以绕过 Bash(curl:*) 的 deny 规则。

14.1.3 Zsh 危险命令集

系统额外维护了一个 Zsh 特有的危险命令列表:

const ZSH_DANGEROUS_COMMANDS = new Set([
  'zmodload',    // 模块加载 - 可以启用文件 I/O、网络、伪终端等
  'emulate',     // 带 -c 标志时是 eval 等价物
  'sysopen', 'sysread', 'syswrite', 'sysseek',  // zsh/system 模块
  'zpty',        // 伪终端命令执行
  'ztcp', 'zsocket',  // 网络连接
  'zf_rm', 'zf_mv', 'zf_ln', 'zf_chmod',  // zsh/files 内建命令
  // ...
])

zmodload 是 Zsh 安全的关键威胁——它可以加载 zsh/mapfile(通过数组赋值实现隐形文件 I/O)、zsh/net/tcp(通过 ztcp 进行网络外泄)等模块,使得许多"安全"的命令变成攻击向量。

14.1.4 安全检查编号系统

为了日志和分析,每个安全检查都有一个数值 ID:

const BASH_SECURITY_CHECK_IDS = {
  INCOMPLETE_COMMANDS: 1,
  JQ_SYSTEM_FUNCTION: 2,
  JQ_FILE_ARGUMENTS: 3,
  OBFUSCATED_FLAGS: 4,
  SHELL_METACHARACTERS: 5,
  DANGEROUS_VARIABLES: 6,
  NEWLINES: 7,
  DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION: 8,
  // ...
  COMMENT_QUOTE_DESYNC: 22,
  QUOTED_NEWLINE: 23,
} as const

使用数值而非字符串避免了在遥测数据中泄露安全检查的具体逻辑。

思考笔记

  • 命令解析是 Bash 安全的第一道防线——不做解析,后面的所有安全检查都是在黑暗里摸索。
  • 引号状态机(quote state machine)跟踪命令中每一个字符是在单引号内、双引号内还是普通状态——这是防止命令注入的基础。
  • 三层去引号策略(保留双引号、全剥离、保留引号字符)服务于不同的验证器——每种验证器需要不同程度的引用信息。
  • 解析器的一个关键设计:即使解析失败(如引号不匹配),系统也会拒绝执行而不是尝试修复——fail-closed 原则。

14.2 危险命令检测

14.2.1 危险模式列表

dangerousPatterns.ts 维护了一组跨平台的代码执行入口点:

// src/utils/permissions/dangerousPatterns.ts
export const CROSS_PLATFORM_CODE_EXEC = [
  'python', 'python3', 'python2',
  'node', 'deno', 'tsx',
  'ruby', 'perl', 'php', 'lua',
  'npx', 'bunx',
  'npm run', 'yarn run', 'pnpm run', 'bun run',
  'bash', 'sh',
  'ssh',
] as const

export const DANGEROUS_BASH_PATTERNS: readonly string[] = [
  ...CROSS_PLATFORM_CODE_EXEC,
  'zsh', 'fish', 'eval', 'exec', 'env', 'xargs', 'sudo',
  ...(process.env.USER_TYPE === 'ant' ? [
    'fa run', 'coo',
    'gh', 'gh api', 'curl', 'wget',
    'git', 'kubectl', 'aws', 'gcloud', 'gsutil',
  ] : []),
]

这些模式被用于 permissionSetup.ts 中的 isDangerousBashPermission——当用户进入 auto 模式时,类似 Bash(python:*) 这种过于宽泛的 allow 规则会被自动剥离,因为它们本质上等于允许执行任意代码。

注意 Anthropic 内部用户(USER_TYPE === 'ant')有更严格的限制,额外禁止了 gh(GitHub CLI)、curl/wget(网络请求)、git(可通过 hooks 执行代码)等工具的宽泛授权。

14.2.2 验证器流水线

bashSecurity.ts 实现了一系列验证函数,构成了安全验证流水线:

%%{init: {"theme": "base", "themeVariables":{"primaryColor":"#3b82f6","primaryTextColor":"#1e293b","primaryBorderColor":"#60a5fa","lineColor":"#94a3b8","secondaryColor":"#f1f5f9","tertiaryColor":"#ffffff","fontFamily":"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif"}}}%% flowchart TD A[原始命令] --> B[命令解析] B --> C[空/不完整检查] C --> D[注入模式检测] D --> E[特殊字符分析] E --> F[Zsh 威胁检测] F --> G{全部通过?} G -->|是| H[返回 allow] G -->|任一失败| I[返回对应结果] subgraph P1["① 命令解析"] direction LR P1A["extractQuotedContent"] --> P1B["ValidationContext"] end subgraph P2["② 基础检查"] direction LR P2A["validateEmpty"] --> P2B["validateIncompleteCommands"] end subgraph P3["③ 注入检测"] direction LR P3A["jq/obfuscatedFlags"] --> P3B["shellMetacharacters"] P3B --> P3C["dangerousVariables"] P3C --> P3D["newlines/patterns/IFS"] P3D --> P3E["gitCommit/procEnviron"] end subgraph P4["④ 字符分析"] direction LR P4A["malformedTokens/backslash"] --> P4B["braceExpansion"] P4B --> P4C["controlChars/unicode"] P4C --> P4D["midWordHash"] end subgraph P5["⑤ Zsh 专项"] direction LR P5A["zshDangerousCmds"] --> P5B["backslashOperators"] P5B --> P5C["commentQuoteDesync"] P5C --> P5D["quotedNewline"] end B -.-> P1 C -.-> P2 D -.-> P3 E -.-> P4 F -.-> P5

每个验证器返回 PermissionResult,其 behavior 可以是 allow(确认安全)、ask(需要人工确认)或 passthrough(此验证器无法判断,交给下一个)。流水线在遇到第一个非 passthrough 结果时停止。

14.2.3 反转义后门检测

hasUnescapedChar 是一个关键的底层函数,用于在引号剥离后的内容中检测未转义的危险字符:

function hasUnescapedChar(content: string, char: string): boolean {
  if (char.length !== 1) {
    throw new Error('hasUnescapedChar only works with single characters')
  }
  let i = 0
  while (i < content.length) {
    if (content[i] === '\\' && i + 1 < content.length) {
      i += 2  // 跳过转义序列
      continue
    }
    if (content[i] === char) {
      return true
    }
    i++
  }
  return false
}

函数注释中的安全警告值得深思:它故意限制只接受单字符,因为多字符字符串匹配需要处理 ANSI-C 引号(如 {{CONTENT}}#39;\n'{{CONTENT}}#39;\x41')的编码问题,错误处理可能引入绕过漏洞。

思考笔记

  • 23 个编号的安全检查器串成验证流水线——每个检查器负责一种具体的攻击模式,遇到第一个非 passthrough 结果就停止。
  • 检查覆盖了常见的注入向量:命令替换($(cmd) 和反引号)、重定向(> /etc/passwd)、编码绕过(base64 decode + sh)。
  • Zsh 特有的威胁(zmodload、=cmd 等号展开)被单独处理——这说明安全检查不是做一次通用方案就够了,而是要了解目标 shell 的所有特性。
  • 固定点迭代算法识别嵌套命令——timeout 300 FOO=bar bazel run 经过多轮剥离找到真正的命令 bazel run。

14.3 路径边界校验

pathValidation.ts 实现了文件系统路径的安全校验,确保工具操作不会超出授权范围。

14.3.1 路径分层检查

isPathAllowed 函数按优先级执行五层检查:

// src/utils/permissions/pathValidation.ts
export function isPathAllowed(
  resolvedPath: string,
  context: ToolPermissionContext,
  operationType: FileOperationType,
): PathCheckResult {
  // 1. Deny 规则优先
  const denyRule = matchingRuleForInput(resolvedPath, context, permissionType, 'deny')
  if (denyRule !== null) {
    return { allowed: false, decisionReason: { type: 'rule', rule: denyRule } }
  }

  // 2. 内部可编辑路径(计划文件、暂存区、代理内存)
  if (operationType !== 'read') {
    const internalEditResult = checkEditableInternalPath(resolvedPath, {})
    if (internalEditResult.behavior === 'allow') {
      return { allowed: true }
    }
  }

  // 2.5. 安全性检查(Windows 模式、Claude 配置文件、危险文件)
  if (operationType !== 'read') {
    const safetyCheck = checkPathSafetyForAutoEdit(resolvedPath)
    if (!safetyCheck.safe) {
      return { allowed: false, decisionReason: { type: 'safetyCheck', ... } }
    }
  }

  // 3. 工作目录检查
  const isInWorkingDir = pathInAllowedWorkingPath(resolvedPath, context)
  if (isInWorkingDir) {
    if (operationType === 'read' || context.mode === 'acceptEdits') {
      return { allowed: true }
    }
  }

  // 3.7. 沙箱写入白名单
  if (operationType !== 'read' && !isInWorkingDir &&
      isPathInSandboxWriteAllowlist(resolvedPath)) {
    return { allowed: true }
  }

  // 4. Allow 规则
  const allowRule = matchingRuleForInput(resolvedPath, context, permissionType, 'allow')
  if (allowRule !== null) {
    return { allowed: true, decisionReason: { type: 'rule', rule: allowRule } }
  }

  // 5. 默认不允许
  return { allowed: false }
}

步骤 2 必须在步骤 2.5 之前——因为内部可编辑路径位于 ~/.claude/ 目录下,而该目录在安全性检查中被标记为危险目录。如果顺序颠倒,计划文件等内部功能将无法写入。

14.3.2 TOCTOU 防护

validatePath 函数包含了多层 TOCTOU(Time-of-Check-Time-of-Use)防护:

export function validatePath(path: string, cwd: string, ...): ResolvedPathCheckResult {
  const cleanPath = expandTilde(path.replace(/^['"]|['"]$/g, ''))

  // 安全:阻止可泄露凭据的 UNC 路径
  if (containsVulnerableUncPath(cleanPath)) {
    return { allowed: false, resolvedPath: cleanPath,
      decisionReason: { type: 'other', reason: 'UNC network paths require manual approval' } }
  }

  // 安全:拒绝 expandTilde 未处理的波浪号变体
  // ~root, ~+, ~- 会被保留为字面文本并解析为相对路径
  // 但 shell 会不同地展开它们,造成 TOCTOU 漏洞
  if (cleanPath.startsWith('~')) {
    return { allowed: false, ... }
  }

  // 安全:拒绝包含 Shell 展开语法的路径
  // $VAR, ${VAR}, $(cmd), %VAR%, =cmd
  if (cleanPath.includes('