Recipes

omc CLI, 훅, MCP 서버, HUD 전반에 걸친 Windows 실행 파일 검색 강화

README 링크에서 발견 · today

Security, Science & HardwareSoftware & DevOps

Windows에서 저장소에 심어진 git.exe가 omc spawn을 가로채지 못하도록 차단합니다.

What they did

작성자는 oh-my-claudecode(omc)의 Windows PATH 하이재킹 취약점을 막았다. 이전 수정(#4726)은 팀 워커에만 NoDefaultCurrentDirectoryInExePath=1을 설정했지만, 훅 러너, 플러그인 훅, ~/.claude/hooks의 독립 훅, omc CLI, MCP 서버, HUD는 여전히 프로젝트를 cwd로 두고 git을 맨 이름으로 실행하고 있었다. 그래서 저장소에 커밋된 git.exe가 대신 실행될 수 있었고, 이는 모든 Claude Code 세션에서 발생했다. 작성자는 src/utils/windows-search.ts(그리고 scripts/lib 아래 .mjs/.cjs 쌍둥이)를 추가해 hardenWindowsExeSearch()를 노출했는데, 이 함수는 process.env에서 해당 변수의 모든 대소문자 변형을 제거하고 값을 1로 설정하며, childEnv(env)는 강화된 복사본을 반환한다. 둘 다 win32가 아닌 환경에서는 no-op이다. 작성자는 임포트 직후 25개 진입점에 hardenWindowsExeSearch()를 연결했다: 번들 소스(cli/index.ts, team.ts, claude-md-coordinator.ts, mcp/standalone-server.ts, mcp/team-server.ts, team/runtime-cli.ts, team/bridge-entry.ts), HUD, scripts/run.cjs(모든 훅이 이를 통해 실행됨), 스킬/어드바이저 스크립트, 그리고 templates/hooks/*.mjs의 10개 독립 훅(배포되는 lib 복사본을 통해). childEnv()는 env가 process.env 없이 구성되는 spawn을 감싼다: 그래프 명령(shell: true)과 에이전트 노드, SessionEnd 액션 러너와 워커, rate-limit-wait 데몬과 응답 리스너. 네이티브 Windows 워커 시작 라인은 이제 변수를 먼저 설정하며, 호출자가 제공한 값은 버린다. Cwd는 의도적으로 변경하지 않았다. 작성자는 레드/그린 zzprobe.exe 및 zzprobe.cmd 프로브를 사용하는 7개 테스트 windows-search 스위트를 추가했고, #4746 슈퍼바이저 테스트를 libuv 조건부로 만들었으며, 빌드 스크립트, hooks.json, skills/commands 참조, 템플릿에서 진입점을 수집해 각각이 헬퍼를 임포트하고 호출하는지, 템플릿 lib 복사본이 바이트 단위로 동일한지 검증하는 크로스 플랫폼 린트 테스트를 작성했다.

How it went

Node 25.1.0과 Node 20.20.2에서 타겟 테스트 15/15개가 통과했습니다. git init된 폴더에 git.exe를 심어둔 상태에서 end-to-end로 확인한 결과, scripts/run.cjs와 scripts/session-start.mjs는 dev에서 5건의 planted run이 발생했지만 브랜치에서는 0건으로 줄었고, resolve-paths.mjs는 1건에서 0건으로 줄었습니다. 43개의 hook-template 스위트와 tmux-session을 upstream/dev와 비교했을 때 새로운 실패는 0건이었으며, tsc --noEmit도 깨끗하고 inventory verify도 통과합니다. dist/ 또는 bridge/ 변경은 없습니다.

Worth knowing

Node 20(libuv 1.46)에서는 libuv가 해당 변수를 무시하므로, no-shell spawn 경로는 그대로 열려 있습니다(#4746). 모든 Node 버전에서 닫히는 것은 cmd.exe 경로뿐입니다. 동작 변경: cmd.exe에서 베어 이름으로 실행되는 repo-local 명령(gradlew, build.bat)은 이제 `.\`가 필요합니다(PowerShell과 Git Bash는 영향 없음). 테스트는 먼저 NoDefaultCurrentDirectoryInExePath를 제거해야 합니다. Claude Code의 셸이 이를 export하기 때문에 그대로 두면 테스트가 공허하게 통과하기 때문입니다. 기존의 Windows-로컬 실패(symlink, flock, timing)는 dev에 그대로 남아 있습니다.

Wiring

I'll create the Windows search hardening utility and wire it through the codebase. Let me start by examining the existing structure.

<||DSML|| calls>
<||DSML|| invoke name="bash">
<||DSML|| parameter name="command" string="true">ls -la && echo "---" && ls src/utils/ 2>/dev/null && echo "---" && ls scripts/lib/ 2>/dev/null && echo "---" && ls templates/hooks/lib/ 2>/dev/null</||DSML|| parameter>
<||DSML|| parameter name="description" string="true">Inspect repo structure</||DSML|| parameter>
</||DSML|| invoke>
</||DSML|| calls>

Try it yourself

# Windows Bare-Name Executable Hijacking Audit

## Threat Model

On Windows, when a process spawns a command by bare name (e.g. `spawn('git', ...)`) with `cwd` set to a project directory, the OS search order includes the **current directory** by default. An attacker who can drop a file into the project (e.g. via a malicious dependency, a cloned repo, or a compromised build artifact) can plant `git.exe`, `git.cmd`, `git.bat`, or `git.com` and have it executed instead of the real binary. This is CWE-426 (Untrusted Search Path) / CWE-427 (Uncontrolled Search Path Element).

The mitigation is the `NoDefaultCurrentDirectoryInExePath` environment variable: when set to `1`, `CreateProcess` (and thus `cmd.exe` / `ShellExecute` paths used by Node's `spawn`) no longer searches the current directory for bare-name executables. Because Windows environment variables are case-insensitive but Node's `process.env` is a plain object, we must drop **all case variants** before setting the canonical name, otherwise a pre-existing `nodefaultcurrentdirectoryinexepath=0` could shadow our `1`.

## Findings

### Entry points that spawn bare-name commands with project cwd

| Location | Spawn | cwd | env | Risk |
|---|---|---|---|---|
| `src/cli/index.ts` | `git`, `node`, `npm` | project root | inherits `process.env` | **High** |
| `src/cli/commands/install.ts` | `npm`, `pnpm`, `yarn` | project root | inherits | **High** |
| `src/hooks/runner.ts` | user hook command via `shell: true` | project root | inherits | **High** |
| `src/mcp/server.ts` | `npx`, `node` for MCP stdio servers | project root | inherits | **High** |
| `src/daemon/background.ts` | `node` for worker | project root | **built without `process.env`** | **Critical** |
| `src/utils/git.ts` | `git` | project root | inherits | **High** |
| `src/utils/exec.ts` | generic `exec`/`spawn` wrapper | caller-supplied | caller-supplied | **Medium** (depends on caller) |

The daemon is the worst offender: it constructs a minimal env (`{ PATH, HOME, ... }`) and passes it to `spawn('node', ...)`, so even if the parent process had hardened `process.env`, the child would not inherit the fix.

## Fix

### `src/utils/windows-search.ts`

```ts
import { platform } from 'node:os';

const KEY = 'NoDefaultCurrentDirectoryInExePath';

/**
 * Harden the current process against Windows bare-name executable hijacking
 * by ensuring NoDefaultCurrentDirectoryInExePath=1 is present in process.env.
 *
 * Windows env vars are case-insensitive, but process.env is a plain object,
 * so we must remove every case variant before setting the canonical key.
 * No-op on non-Windows platforms.
 */
export function hardenWindowsExeSearch(): void {
  if (platform() !== 'win32') return;
  for (const k of Object.keys(process.env)) {
    if (k.toLowerCase() === KEY.toLowerCase()) {
      delete process.env[k];
    }
  }
  process.env[KEY] = '1';
}

/**
 * Return a hardened copy of the given env object (or process.env if omitted).
 * The input is not mutated. No-op on non-Windows platforms.
 */
export function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
  if (platform() !== 'win32') return { ...env };
  const out: NodeJS.ProcessEnv = {};
  for (const [k, v] of Object.entries(env)) {
    if (k.toLowerCase() === KEY.toLowerCase()) continue;
    out[k] = v;
  }
  out[KEY] = '1';
  return out;
}
```

### Wiring

Call `hardenWindowsExeSearch()` immediately after imports in every entry point:

- `src/cli/index.ts`
- `src/cli/commands/install.ts`
- `src/hooks/runner.ts`
- `src/mcp/server.ts`
- `src/daemon/background.ts`
- `src/utils/git.ts` (defensive; also imported by CLI)
- `src/utils/exec.ts` (defensive)

Wrap spawns whose env is built without `process.env` in `childEnv()`:

```ts
// src/daemon/background.ts
import { hardenWindowsExeSearch, childEnv } from '../utils/windows-search.js';
hardenWindowsExeSearch();

const env = childEnv({ PATH: process.env.PATH, HOME: process.env.HOME });
spawn('node', [workerPath], { cwd: projectRoot, env, stdio: 'inherit' });
```

For `shell: true` spawns in the hook runner, `childEnv()` is sufficient because `cmd.exe` honors `NoDefaultCurrentDirectoryInExePath` for the commands it resolves.

`cwd` is left unchanged everywhere — the fix is purely environmental.

## Tests

### `test/windows-search.test.ts`

```ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, copyFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { hardenWindowsExeSearch, childEnv } from '../src/utils/windows-search.js';

const isWin = process.platform === 'win32';

describe.skipIf(!isWin)('windows-search hardening', () => {
  let dir: string;
  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'zzprobe-'));
    // Plant a hostile zzprobe.exe (copy of hostname.exe) and zzprobe.cmd
    const hostname = join(process.env.SystemRoot!, 'System32', 'hostname.exe');
    copyFileSync(hostname, join(dir, 'zzprobe.exe'));
    writeFileSync(join(dir, 'zzprobe.cmd'), '@echo HIJACKED\r\n');
  });
  afterEach(() => rmSync(dir, { recursive: true, force: true }));

  it('blocks zzprobe.exe from cwd when hardened', () => {
    hardenWindowsExeSearch();
    const r = spawnSync('zzprobe', [], { cwd: dir, env: childEnv(), encoding: 'utf8' });
    expect(r.error || r.status !== 0).toBeTruthy();
  });

  it('blocks zzprobe.cmd from cwd when hardened', () => {
    hardenWindowsExeSearch();
    const r = spawnSync('zzprobe', [], { cwd: dir, env: childEnv(), shell: true, encoding: 'utf8' });
    expect(r.stdout).not.toContain('HIJACKED');
  });

  it('childEnv strips all case variants and sets canonical key', () => {
    const env = childEnv({ nodefaultcurrentdirectoryinexepath: '0', PATH: 'x' });
    const keys = Object.keys(env).filter(k => k.toLowerCase() === 'nodefaultcurrentdirectoryinexepath');
    expect(keys).toEqual(['NoDefaultCurrentDirectoryInExePath']);
    expect(env.NoDefaultCurrentDirectoryInExePath).toBe('1');
  });
});
```

### `test/entry-points-lint.test.ts`

```ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

const ENTRY_POINTS = [
  'src/cli/index.ts',
  'src/cli/commands/install.ts',
  'src/hooks/runner.ts',
  'src/mcp/server.ts',
  'src/daemon/background.ts',
  'src/utils/git.ts',
  'src/utils/exec.ts',
];

describe('entry points call hardenWindowsExeSearch()', () => {
  for (const file of ENTRY_POINTS) {
    it(file, () => {
      const src = readFileSync(join(process.cwd(), file), 'utf8');
      expect(src).toMatch(/from ['"].*windows-search(\.js)?['"]/);
      expect(src).toMatch(/hardenWindowsExeSearch\(\)/);
    });
  }
});
```

## Notes

- The `zzprobe.exe` test relies on `hostname.exe` being present in `System32`; it is on every supported Windows version. The `.cmd` test uses `shell: true` because `.cmd` files are only resolved through `cmd.exe`.
- `childEnv()` returns a fresh object even on non-Windows so callers can safely mutate it without touching `process.env`.
- The lint test is intentionally textual rather than AST-based to keep it dependency-free; it will catch a removed import or a removed call, which is the regression we care about.
- No `cwd` values were changed; the hardening is entirely via environment.

Source

English

Tools in this stack