VYPR
High severityNVD Advisory· Published Mar 19, 2026· Updated Mar 25, 2026

OpenClaw < 2026.2.22 - Arbitrary Shell Execution via Unvalidated SHELL Environment Variable

CVE-2026-32032

Description

OpenClaw versions prior to 2026.2.22 contain an arbitrary shell execution vulnerability in shell environment fallback that trusts the unvalidated SHELL path from the host environment. An attacker with local environment access can inject a malicious SHELL variable to execute arbitrary commands with the privileges of the OpenClaw process.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.222026.2.22

Affected products

1

Patches

1
25e89cc86338

fix(security): harden shell env fallback

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
8 files changed · +129 13
  • apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift+1 0 modified
    @@ -14,6 +14,7 @@ enum HostEnvSanitizer {
             "RUBYOPT",
             "BASH_ENV",
             "ENV",
    +        "SHELL",
             "GCONV_PATH",
             "IFS",
             "SSLKEYLOGFILE",
    
  • CHANGELOG.md+1 0 modified
    @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes) and block `SHELL` in dangerous env override policy paths so untrusted shell-path injection falls back safely to `/bin/sh`. Thanks @athuljayaram for reporting.
     - Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
     - Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting.
     - Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting.
    
  • src/agents/skills.e2e.test.ts+10 1 modified
    @@ -360,15 +360,17 @@ describe("applySkillEnvOverrides", () => {
           dir: skillDir,
           name: "dangerous-env-skill",
           description: "Needs env",
    -      metadata: '{"openclaw":{"requires":{"env":["BASH_ENV"]}}}',
    +      metadata: '{"openclaw":{"requires":{"env":["BASH_ENV","SHELL"]}}}',
         });
     
         const entries = loadWorkspaceSkillEntries(workspaceDir, {
           managedSkillsDir: path.join(workspaceDir, ".managed"),
         });
     
         const originalBashEnv = process.env.BASH_ENV;
    +    const originalShell = process.env.SHELL;
         delete process.env.BASH_ENV;
    +    delete process.env.SHELL;
     
         const restore = applySkillEnvOverrides({
           skills: entries,
    @@ -378,6 +380,7 @@ describe("applySkillEnvOverrides", () => {
                 "dangerous-env-skill": {
                   env: {
                     BASH_ENV: "/tmp/pwn.sh",
    +                SHELL: "/tmp/evil-shell",
                   },
                 },
               },
    @@ -387,13 +390,19 @@ describe("applySkillEnvOverrides", () => {
     
         try {
           expect(process.env.BASH_ENV).toBeUndefined();
    +      expect(process.env.SHELL).toBeUndefined();
         } finally {
           restore();
           if (originalBashEnv === undefined) {
             expect(process.env.BASH_ENV).toBeUndefined();
           } else {
             expect(process.env.BASH_ENV).toBe(originalBashEnv);
           }
    +      if (originalShell === undefined) {
    +        expect(process.env.SHELL).toBeUndefined();
    +      } else {
    +        expect(process.env.SHELL).toBe(originalShell);
    +      }
         }
       });
     
    
  • src/config/config.env-vars.test.ts+22 11 modified
    @@ -30,18 +30,29 @@ describe("config env vars", () => {
       });
     
       it("blocks dangerous startup env vars from config env", async () => {
    -    await withEnvOverride({ BASH_ENV: undefined, OPENROUTER_API_KEY: undefined }, async () => {
    -      const config = {
    -        env: { vars: { BASH_ENV: "/tmp/pwn.sh", OPENROUTER_API_KEY: "config-key" } },
    -      };
    -      const entries = collectConfigRuntimeEnvVars(config as OpenClawConfig);
    -      expect(entries.BASH_ENV).toBeUndefined();
    -      expect(entries.OPENROUTER_API_KEY).toBe("config-key");
    +    await withEnvOverride(
    +      { BASH_ENV: undefined, SHELL: undefined, OPENROUTER_API_KEY: undefined },
    +      async () => {
    +        const config = {
    +          env: {
    +            vars: {
    +              BASH_ENV: "/tmp/pwn.sh",
    +              SHELL: "/tmp/evil-shell",
    +              OPENROUTER_API_KEY: "config-key",
    +            },
    +          },
    +        };
    +        const entries = collectConfigRuntimeEnvVars(config as OpenClawConfig);
    +        expect(entries.BASH_ENV).toBeUndefined();
    +        expect(entries.SHELL).toBeUndefined();
    +        expect(entries.OPENROUTER_API_KEY).toBe("config-key");
     
    -      applyConfigEnvVars(config as OpenClawConfig);
    -      expect(process.env.BASH_ENV).toBeUndefined();
    -      expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
    -    });
    +        applyConfigEnvVars(config as OpenClawConfig);
    +        expect(process.env.BASH_ENV).toBeUndefined();
    +        expect(process.env.SHELL).toBeUndefined();
    +        expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
    +      },
    +    );
       });
     
       it("drops non-portable env keys from config env", async () => {
    
  • src/infra/host-env-security-policy.json+1 0 modified
    @@ -10,6 +10,7 @@
         "RUBYOPT",
         "BASH_ENV",
         "ENV",
    +    "SHELL",
         "GCONV_PATH",
         "IFS",
         "SSLKEYLOGFILE"
    
  • src/infra/host-env-security.test.ts+1 0 modified
    @@ -9,6 +9,7 @@ describe("isDangerousHostEnvVarName", () => {
       it("matches dangerous keys and prefixes case-insensitively", () => {
         expect(isDangerousHostEnvVarName("BASH_ENV")).toBe(true);
         expect(isDangerousHostEnvVarName("bash_env")).toBe(true);
    +    expect(isDangerousHostEnvVarName("SHELL")).toBe(true);
         expect(isDangerousHostEnvVarName("DYLD_INSERT_LIBRARIES")).toBe(true);
         expect(isDangerousHostEnvVarName("ld_preload")).toBe(true);
         expect(isDangerousHostEnvVarName("BASH_FUNC_echo%%")).toBe(true);
    
  • src/infra/shell-env.test.ts+32 0 modified
    @@ -121,6 +121,38 @@ describe("shell env fallback", () => {
         expect(exec).toHaveBeenCalledOnce();
       });
     
    +  it("falls back to /bin/sh when SHELL is non-absolute", () => {
    +    const env: NodeJS.ProcessEnv = { SHELL: "zsh" };
    +    const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0"));
    +
    +    const res = loadShellEnvFallback({
    +      enabled: true,
    +      env,
    +      expectedKeys: ["OPENAI_API_KEY"],
    +      exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
    +    });
    +
    +    expect(res.ok).toBe(true);
    +    expect(exec).toHaveBeenCalledTimes(1);
    +    expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object));
    +  });
    +
    +  it("falls back to /bin/sh when SHELL points to an untrusted path", () => {
    +    const env: NodeJS.ProcessEnv = { SHELL: "/tmp/evil-shell" };
    +    const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0"));
    +
    +    const res = loadShellEnvFallback({
    +      enabled: true,
    +      env,
    +      expectedKeys: ["OPENAI_API_KEY"],
    +      exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
    +    });
    +
    +    expect(res.ok).toBe(true);
    +    expect(exec).toHaveBeenCalledTimes(1);
    +    expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object));
    +  });
    +
       it("returns null without invoking shell on win32", () => {
         resetShellPathCacheForTests();
         const exec = vi.fn(() => Buffer.from("PATH=/usr/local/bin:/usr/bin\0HOME=/tmp\0"));
    
  • src/infra/shell-env.ts+61 1 modified
    @@ -1,10 +1,21 @@
     import { execFileSync } from "node:child_process";
    +import fs from "node:fs";
    +import path from "node:path";
     import { isTruthyEnvValue } from "./env.js";
     
     const DEFAULT_TIMEOUT_MS = 15_000;
     const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
    +const DEFAULT_SHELL = "/bin/sh";
    +const TRUSTED_SHELL_PREFIXES = [
    +  "/bin/",
    +  "/usr/bin/",
    +  "/usr/local/bin/",
    +  "/opt/homebrew/bin/",
    +  "/run/current-system/sw/bin/",
    +];
     let lastAppliedKeys: string[] = [];
     let cachedShellPath: string | null | undefined;
    +let cachedEtcShells: Set<string> | null | undefined;
     
     function resolveTimeoutMs(timeoutMs: number | undefined): number {
       if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
    @@ -13,9 +24,57 @@ function resolveTimeoutMs(timeoutMs: number | undefined): number {
       return Math.max(0, timeoutMs);
     }
     
    +function readEtcShells(): Set<string> | null {
    +  if (cachedEtcShells !== undefined) {
    +    return cachedEtcShells;
    +  }
    +  try {
    +    const raw = fs.readFileSync("/etc/shells", "utf8");
    +    const entries = raw
    +      .split(/\r?\n/)
    +      .map((line) => line.trim())
    +      .filter((line) => line.length > 0 && !line.startsWith("#") && path.isAbsolute(line));
    +    cachedEtcShells = new Set(entries);
    +  } catch {
    +    cachedEtcShells = null;
    +  }
    +  return cachedEtcShells;
    +}
    +
    +function isTrustedShellPath(shell: string): boolean {
    +  if (!path.isAbsolute(shell)) {
    +    return false;
    +  }
    +  const normalized = path.normalize(shell);
    +  if (normalized !== shell) {
    +    return false;
    +  }
    +
    +  // Primary trust anchor: shell registered in /etc/shells.
    +  const registeredShells = readEtcShells();
    +  if (registeredShells?.has(shell)) {
    +    return true;
    +  }
    +
    +  // Fallback for environments where /etc/shells is incomplete/unavailable.
    +  if (!TRUSTED_SHELL_PREFIXES.some((prefix) => shell.startsWith(prefix))) {
    +    return false;
    +  }
    +
    +  try {
    +    fs.accessSync(shell, fs.constants.X_OK);
    +    return true;
    +  } catch {
    +    return false;
    +  }
    +}
    +
     function resolveShell(env: NodeJS.ProcessEnv): string {
       const shell = env.SHELL?.trim();
    -  return shell && shell.length > 0 ? shell : "/bin/sh";
    +  if (shell && isTrustedShellPath(shell)) {
    +    return shell;
    +  }
    +  return DEFAULT_SHELL;
     }
     
     function execLoginShellEnvZero(params: {
    @@ -171,6 +230,7 @@ export function getShellPathFromLoginShell(opts: {
     
     export function resetShellPathCacheForTests(): void {
       cachedShellPath = undefined;
    +  cachedEtcShells = undefined;
     }
     
     export function getShellEnvAppliedKeys(): string[] {
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.