VYPR
Moderate severityNVD Advisory· Published Mar 18, 2026· Updated Mar 18, 2026

OpenClaw < 2026.2.23 - Exec Approval Bypass via Unrecognized Multiplexer Shell Wrappers

CVE-2026-22175

Description

OpenClaw versions prior to 2026.2.23 contain an exec approval bypass vulnerability in allowlist mode where allow-always grants could be circumvented through unrecognized multiplexer shell wrappers like busybox and toybox sh -c commands. Attackers can exploit this by invoking arbitrary payloads under the same multiplexer wrapper to satisfy stored allowlist rules, bypassing intended execution restrictions.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.232026.2.23

Affected products

1

Patches

1
a67689a7e3ad

fix: harden allow-always shell multiplexer wrapper handling

https://github.com/openclaw/openclawPeter SteinbergerFeb 24, 2026via ghsa
8 files changed · +193 1
  • CHANGELOG.md+1 0 modified
    @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
     - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting.
     - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
     - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
    +- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting.
     - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting.
     - Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads.
     
    
  • docs/tools/exec-approvals.md+3 1 modified
    @@ -178,7 +178,9 @@ For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are
     small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
     For allow-always decisions in allowlist mode, known dispatch wrappers
     (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper
    -paths. If a wrapper cannot be safely unwrapped, no allowlist entry is persisted automatically.
    +paths. Shell multiplexers (`busybox`, `toybox`) are also unwrapped for shell applets (`sh`, `ash`,
    +etc.) so inner executables are persisted instead of multiplexer binaries. If a wrapper or
    +multiplexer cannot be safely unwrapped, no allowlist entry is persisted automatically.
     
     Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`.
     
    
  • src/infra/exec-approvals-allow-always.test.ts+100 0 modified
    @@ -153,6 +153,60 @@ describe("resolveAllowAlwaysPatterns", () => {
         expect(patterns).not.toContain("/usr/bin/nice");
       });
     
    +  it("unwraps busybox/toybox shell applets and persists inner executables", () => {
    +    if (process.platform === "win32") {
    +      return;
    +    }
    +    const dir = makeTempDir();
    +    const busybox = makeExecutable(dir, "busybox");
    +    makeExecutable(dir, "toybox");
    +    const whoami = makeExecutable(dir, "whoami");
    +    const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
    +    const patterns = resolveAllowAlwaysPatterns({
    +      segments: [
    +        {
    +          raw: `${busybox} sh -lc whoami`,
    +          argv: [busybox, "sh", "-lc", "whoami"],
    +          resolution: {
    +            rawExecutable: busybox,
    +            resolvedPath: busybox,
    +            executableName: "busybox",
    +          },
    +        },
    +      ],
    +      cwd: dir,
    +      env,
    +      platform: process.platform,
    +    });
    +    expect(patterns).toEqual([whoami]);
    +    expect(patterns).not.toContain(busybox);
    +  });
    +
    +  it("fails closed for unsupported busybox/toybox applets", () => {
    +    if (process.platform === "win32") {
    +      return;
    +    }
    +    const dir = makeTempDir();
    +    const busybox = makeExecutable(dir, "busybox");
    +    const patterns = resolveAllowAlwaysPatterns({
    +      segments: [
    +        {
    +          raw: `${busybox} sed -n 1p`,
    +          argv: [busybox, "sed", "-n", "1p"],
    +          resolution: {
    +            rawExecutable: busybox,
    +            resolvedPath: busybox,
    +            executableName: "busybox",
    +          },
    +        },
    +      ],
    +      cwd: dir,
    +      env: makePathEnv(dir),
    +      platform: process.platform,
    +    });
    +    expect(patterns).toEqual([]);
    +  });
    +
       it("fails closed for unresolved dispatch wrappers", () => {
         const patterns = resolveAllowAlwaysPatterns({
           segments: [
    @@ -171,6 +225,52 @@ describe("resolveAllowAlwaysPatterns", () => {
         expect(patterns).toEqual([]);
       });
     
    +  it("prevents allow-always bypass for busybox shell applets", () => {
    +    if (process.platform === "win32") {
    +      return;
    +    }
    +    const dir = makeTempDir();
    +    const busybox = makeExecutable(dir, "busybox");
    +    const echo = makeExecutable(dir, "echo");
    +    makeExecutable(dir, "id");
    +    const safeBins = resolveSafeBins(undefined);
    +    const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
    +
    +    const first = evaluateShellAllowlist({
    +      command: `${busybox} sh -c 'echo warmup-ok'`,
    +      allowlist: [],
    +      safeBins,
    +      cwd: dir,
    +      env,
    +      platform: process.platform,
    +    });
    +    const persisted = resolveAllowAlwaysPatterns({
    +      segments: first.segments,
    +      cwd: dir,
    +      env,
    +      platform: process.platform,
    +    });
    +    expect(persisted).toEqual([echo]);
    +
    +    const second = evaluateShellAllowlist({
    +      command: `${busybox} sh -c 'id > marker'`,
    +      allowlist: [{ pattern: echo }],
    +      safeBins,
    +      cwd: dir,
    +      env,
    +      platform: process.platform,
    +    });
    +    expect(second.allowlistSatisfied).toBe(false);
    +    expect(
    +      requiresExecApproval({
    +        ask: "on-miss",
    +        security: "allowlist",
    +        analysisOk: second.analysisOk,
    +        allowlistSatisfied: second.allowlistSatisfied,
    +      }),
    +    ).toBe(true);
    +  });
    +
       it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => {
         if (process.platform === "win32") {
           return;
    
  • src/infra/exec-approvals-allowlist.ts+25 0 modified
    @@ -21,6 +21,7 @@ import {
       extractShellWrapperInlineCommand,
       isDispatchWrapperExecutable,
       isShellWrapperExecutable,
    +  unwrapKnownShellMultiplexerInvocation,
       unwrapKnownDispatchWrapperInvocation,
     } from "./exec-wrapper-resolution.js";
     
    @@ -299,6 +300,30 @@ function collectAllowAlwaysPatterns(params: {
         return;
       }
     
    +  const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(params.segment.argv);
    +  if (shellMultiplexerUnwrap.kind === "blocked") {
    +    return;
    +  }
    +  if (shellMultiplexerUnwrap.kind === "unwrapped") {
    +    collectAllowAlwaysPatterns({
    +      segment: {
    +        raw: shellMultiplexerUnwrap.argv.join(" "),
    +        argv: shellMultiplexerUnwrap.argv,
    +        resolution: resolveCommandResolutionFromArgv(
    +          shellMultiplexerUnwrap.argv,
    +          params.cwd,
    +          params.env,
    +        ),
    +      },
    +      cwd: params.cwd,
    +      env: params.env,
    +      platform: params.platform,
    +      depth: params.depth + 1,
    +      out: params.out,
    +    });
    +    return;
    +  }
    +
       const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd);
       if (!candidatePath) {
         return;
    
  • src/infra/exec-safe-bin-runtime-policy.test.ts+2 0 modified
    @@ -15,6 +15,8 @@ describe("exec safe-bin runtime policy", () => {
         { bin: "node20", expected: true },
         { bin: "ruby3.2", expected: true },
         { bin: "bash", expected: true },
    +    { bin: "busybox", expected: true },
    +    { bin: "toybox", expected: true },
         { bin: "myfilter", expected: false },
         { bin: "jq", expected: false },
       ];
    
  • src/infra/exec-safe-bin-runtime-policy.ts+2 0 modified
    @@ -17,6 +17,7 @@ export type ExecSafeBinConfigScope = {
     const INTERPRETER_LIKE_SAFE_BINS = new Set([
       "ash",
       "bash",
    +  "busybox",
       "bun",
       "cmd",
       "cmd.exe",
    @@ -40,6 +41,7 @@ const INTERPRETER_LIKE_SAFE_BINS = new Set([
       "python3",
       "ruby",
       "sh",
    +  "toybox",
       "wscript",
       "zsh",
     ]);
    
  • src/infra/exec-wrapper-resolution.ts+55 0 modified
    @@ -7,6 +7,7 @@ const WINDOWS_EXE_SUFFIX = ".exe";
     const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const;
     const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const;
     const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const;
    +const SHELL_MULTIPLEXER_WRAPPER_NAMES = ["busybox", "toybox"] as const;
     const DISPATCH_WRAPPER_NAMES = [
       "chrt",
       "doas",
    @@ -42,6 +43,7 @@ export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPAT
     const POSIX_SHELL_WRAPPER_CANONICAL = new Set<string>(POSIX_SHELL_WRAPPER_NAMES);
     const WINDOWS_CMD_WRAPPER_CANONICAL = new Set<string>(WINDOWS_CMD_WRAPPER_NAMES);
     const POWERSHELL_WRAPPER_CANONICAL = new Set<string>(POWERSHELL_WRAPPER_NAMES);
    +const SHELL_MULTIPLEXER_WRAPPER_CANONICAL = new Set<string>(SHELL_MULTIPLEXER_WRAPPER_NAMES);
     const DISPATCH_WRAPPER_CANONICAL = new Set<string>(DISPATCH_WRAPPER_NAMES);
     const SHELL_WRAPPER_CANONICAL = new Set<string>([
       ...POSIX_SHELL_WRAPPER_NAMES,
    @@ -133,6 +135,39 @@ function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null {
       return null;
     }
     
    +export type ShellMultiplexerUnwrapResult =
    +  | { kind: "not-wrapper" }
    +  | { kind: "blocked"; wrapper: string }
    +  | { kind: "unwrapped"; wrapper: string; argv: string[] };
    +
    +export function unwrapKnownShellMultiplexerInvocation(
    +  argv: string[],
    +): ShellMultiplexerUnwrapResult {
    +  const token0 = argv[0]?.trim();
    +  if (!token0) {
    +    return { kind: "not-wrapper" };
    +  }
    +  const wrapper = normalizeExecutableToken(token0);
    +  if (!SHELL_MULTIPLEXER_WRAPPER_CANONICAL.has(wrapper)) {
    +    return { kind: "not-wrapper" };
    +  }
    +
    +  let appletIndex = 1;
    +  if (argv[appletIndex]?.trim() === "--") {
    +    appletIndex += 1;
    +  }
    +  const applet = argv[appletIndex]?.trim();
    +  if (!applet || !isShellWrapperExecutable(applet)) {
    +    return { kind: "blocked", wrapper };
    +  }
    +
    +  const unwrapped = argv.slice(appletIndex);
    +  if (unwrapped.length === 0) {
    +    return { kind: "blocked", wrapper };
    +  }
    +  return { kind: "unwrapped", wrapper, argv: unwrapped };
    +}
    +
     export function isEnvAssignment(token: string): boolean {
       return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
     }
    @@ -474,6 +509,18 @@ function hasEnvManipulationBeforeShellWrapperInternal(
         );
       }
     
    +  const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv);
    +  if (shellMultiplexerUnwrap.kind === "blocked") {
    +    return false;
    +  }
    +  if (shellMultiplexerUnwrap.kind === "unwrapped") {
    +    return hasEnvManipulationBeforeShellWrapperInternal(
    +      shellMultiplexerUnwrap.argv,
    +      depth + 1,
    +      envManipulationSeen,
    +    );
    +  }
    +
       const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0));
       if (!wrapper) {
         return false;
    @@ -577,6 +624,14 @@ function extractShellWrapperCommandInternal(
         return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1);
       }
     
    +  const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv);
    +  if (shellMultiplexerUnwrap.kind === "blocked") {
    +    return { isWrapper: false, command: null };
    +  }
    +  if (shellMultiplexerUnwrap.kind === "unwrapped") {
    +    return extractShellWrapperCommandInternal(shellMultiplexerUnwrap.argv, rawCommand, depth + 1);
    +  }
    +
       const base0 = normalizeExecutableToken(token0);
       const wrapper = findShellWrapperSpec(base0);
       if (!wrapper) {
    
  • src/infra/system-run-command.test.ts+5 0 modified
    @@ -57,6 +57,11 @@ describe("system run command helpers", () => {
         expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date");
       });
     
    +  test("extractShellCommandFromArgv unwraps busybox/toybox shell applets", () => {
    +    expect(extractShellCommandFromArgv(["busybox", "sh", "-c", "echo hi"])).toBe("echo hi");
    +    expect(extractShellCommandFromArgv(["toybox", "ash", "-lc", "echo hi"])).toBe("echo hi");
    +  });
    +
       test("extractShellCommandFromArgv ignores env wrappers when no shell wrapper follows", () => {
         expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"])).toBe(
           null,
    

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

4

News mentions

0

No linked articles in our index yet.