Skip to content

[Security] Non-owner command-authorized senders can access owner-only gateway and cron tools in openclaw-cn #609

Description

@YLChen-007

Advisory Details

Title: Non-owner command-authorized senders can access owner-only gateway and cron tools in openclaw-cn

Description:

Summary

openclaw-cn exposes owner-only embedded-agent control-plane tools to chat senders who are allowed to enter the command flow but are not configured as owners. The authorization layer correctly distinguishes isAuthorizedSender=true from senderIsOwner=false, but the live embedded-agent tool builder returns the final tool surface without applying the repository's existing owner-only filter. As a result, a lower-privileged chat actor can reach tools that open operator-scoped Gateway RPC sessions and perform administrative actions such as configuration reads and cron mutation.

Details

The bug is an improper privilege management flaw in the embedded agent tool exposure path.

resolveCommandAuthorization() in src/auto-reply/command-auth.ts already computes the lower-privilege sender state:

const senderIsOwner = Boolean(matchedSender);
const isOwnerForCommands =
  !enforceOwner ||
  allowAll ||
  ownerCandidatesForCommands.length === 0 ||
  Boolean(matchedCommandOwner);
const isAuthorizedSender = commandAuthorized && isOwnerForCommands;

This means a deployment can intentionally allow a sender into the command surface while still treating that sender as a non-owner.

The reply/embedded runner preserves that distinction and forwards senderIsOwner into the embedded run context:

senderId: sessionCtx.SenderId?.trim() || undefined,
senderName: sessionCtx.SenderName?.trim() || undefined,
senderUsername: sessionCtx.SenderUsername?.trim() || undefined,
senderE164: sessionCtx.SenderE164?.trim() || undefined,
senderIsOwner: command.senderIsOwner,

The embedded runner then passes that value into the tool-builder call:

senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
sessionKey: params.sessionKey ?? params.sessionId,

However, createOpenClawCodingTools() does not declare a senderIsOwner option, assembles the complete tool list including createOpenClawTools(), and returns it directly:

export function createOpenClawCodingTools(options?: {
  exec?: ExecToolDefaults & ProcessToolDefaults;
  messageProvider?: string;
  agentAccountId?: string;
  ...
  senderId?: string | null;
  senderName?: string | null;
  senderUsername?: string | null;
  senderE164?: string | null;
  ...
}): AnyAgentTool[] {
const tools: AnyAgentTool[] = [
  ...base,
  ...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
  execTool as unknown as AnyAgentTool,
  processTool as unknown as AnyAgentTool,
  ...listChannelAgentTools({ cfg: options?.config }),
  ...createOpenClawTools({
    agentSessionKey: options?.sessionKey,
    agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
    ...
  }),
];
...
return withAbort;

The repository already contains the intended mitigation in src/agents/tool-policy.ts:

export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) {
  const withGuard = tools.map((tool) => {
    if (!isOwnerOnlyTool(tool)) {
      return tool;
    }
    return wrapOwnerOnlyToolExecution(tool, senderIsOwner);
  });
  if (senderIsOwner) {
    return withGuard;
  }
  return withGuard.filter((tool) => !isOwnerOnlyTool(tool));
}

That filter is not applied in the vulnerable path, so the owner-only gateway and cron tools remain visible to a non-owner tool set. Once selected, those tools invoke callGatewayTool() and reach callGateway():

return await callGateway<T>({
  url: gateway.url,
  token: gateway.token,
  method,
  params,
  timeoutMs: gateway.timeoutMs,
  expectFinal: extra?.expectFinal,
  clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
  clientDisplayName: "agent",
  mode: GATEWAY_CLIENT_MODES.BACKEND,
});
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],

This was reproduced with the live authorization helper, the live createOpenClawCodingTools() implementation, the real gateway and cron tool execute() methods, a real loopback gateway, and independent observation through the real cron store. The reproduction is integration-level rather than full chat-to-model end-to-end, but it still demonstrates a real privilege-boundary violation in production code.

PoC

Prerequisites

  • A checkout of the affected openclaw-cn repository.
  • pnpm, tsx, and python3 available locally.
  • A sender state where the chat actor is command-authorized but not an owner, for example:
    • commands.ownerAllowFrom = ["whatsapp:+15550000000"]
    • channels.whatsapp.allowFrom = ["*"]
  • No external network service is required. The PoC starts a localhost gateway and observes the actual cron store and authorization behavior.

Reproduction Steps

  1. Download the TypeScript driver from: driver.ts
  2. Download the verification harness from: verification_test.py
  3. Download the control harness from: control-owner-only-policy.py
  4. From the repository root, run the experiment:
    python3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-2hm8-rqrm-xfjq-non-owner-agent-tool-scope-bypass-exp/verification_test.py
  5. Confirm that the reported authorization state is auth.isAuthorizedSender: True and auth.senderIsOwner: False, while the effective tool set still contains both gateway and cron.
  6. Confirm that the real gateway rejects a plain operator.write client for cron.add, but the non-owner tool path still succeeds with:
    • gateway tool config.get succeeded: True
    • cron tool cron.add succeeded: True
    • canary cron job listed after non-owner tool execution: True
  7. Run the control:
    python3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-2hm8-rqrm-xfjq-non-owner-agent-tool-scope-bypass-exp/control-owner-only-policy.py
  8. Confirm that explicitly applying applyOwnerOnlyToolPolicy() removes the tools and prevents the cron write:
    • effective tool list includes gateway: False
    • effective tool list includes cron: False
    • canary cron job listed after control execution: False

Log of Evidence

Verification run:

[Verification Mode] Integration-Test
Reachability path: non-owner command-authorized chat sender -> resolveCommandAuthorization(senderIsOwner=false, isAuthorizedSender=true) -> embedded agent createOpenClawCodingTools(...) -> gateway/cron tools visible -> tool.execute() -> real localhost gateway -> admin-only methods
gateway url: ws://127.0.0.1:43101
loaded config port: 43101
auth.isAuthorizedSender: True
auth.senderIsOwner: False
control blocked by operator.admin requirement: True
control error: missing scope: operator.admin
raw tool list includes gateway: True
raw tool list includes cron: True
effective tool list includes gateway: True
effective tool list includes cron: True
gateway tool config.get succeeded: True
cron tool cron.add succeeded: True
admin cron.list succeeded: True
canary cron job listed after non-owner tool execution: True
canary job name: ghsa-2hm8-non-owner-tool-scope-1783280881363
[DEFECT CONFIRMED WITH LIMITATIONS]

Control run:

[Control Mode] Integration-Test
Control action: applyOwnerOnlyToolPolicy(rawTools, senderIsOwner=false) before exposing the embedded tool surface.
raw tool list includes gateway: True
raw tool list includes cron: True
effective tool list includes gateway: False
effective tool list includes cron: False
gateway execution blocked: True
cron execution blocked: True
canary cron job listed after control execution: False
[CONTROL PROTECTED]

Impact

This is a privilege-boundary bypass in the chat control plane. It impacts deployments that intentionally allow a broader set of chat senders to use commands or embedded-agent flows while reserving gateway, cron, and similar operational tools for owners. A non-owner but command-authorized sender can be offered those tools, and if the model or driver selects them, the tool opens an operator-scoped gateway session and performs administrative RPC calls. That exposes configuration contents and enables cron mutation, with obvious knock-on risk for later message delivery, scheduled agent execution, and control-plane state manipulation.

Affected products

  • Ecosystem: npm
  • Package name: openclaw-cn
  • Affected versions: <= 0.2.1
  • Patched versions:

Severity

  • Severity: Medium
  • Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L

Weaknesses

  • CWE: CWE-269: Improper Privilege Management

Occurrences

Permalink Description
const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands);
const senderIsOwner = Boolean(matchedSender);
const isOwnerForCommands =
!enforceOwner ||
allowAll ||
ownerCandidatesForCommands.length === 0 ||
Boolean(matchedCommandOwner);
const isAuthorizedSender = commandAuthorized && isOwnerForCommands;
resolveCommandAuthorization() explicitly distinguishes senderIsOwner from isAuthorizedSender, creating the lower-privileged but still accepted sender state required for exploitation.
senderId: sessionCtx.SenderId?.trim() || undefined,
senderName: sessionCtx.SenderName?.trim() || undefined,
senderUsername: sessionCtx.SenderUsername?.trim() || undefined,
senderE164: sessionCtx.SenderE164?.trim() || undefined,
senderIsOwner: command.senderIsOwner,
The reply runner preserves and forwards senderIsOwner into the embedded agent run context instead of collapsing it into generic command authorization.
export function createOpenClawCodingTools(options?: {
exec?: ExecToolDefaults & ProcessToolDefaults;
messageProvider?: string;
agentAccountId?: string;
messageTo?: string;
messageThreadId?: string | number;
sandbox?: SandboxContext | null;
sessionKey?: string;
agentDir?: string;
workspaceDir?: string;
config?: ClawdbotConfig;
abortSignal?: AbortSignal;
/**
* Provider of the currently selected model (used for provider-specific tool quirks).
* Example: "anthropic", "openai", "google", "openai-codex".
*/
modelProvider?: string;
/** Model id for the current provider (used for model-specific tool gating). */
modelId?: string;
/**
* Auth mode for the current provider. We only need this for Anthropic OAuth
* tool-name blocking quirks.
*/
modelAuthMode?: ModelAuthMode;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent group policy inheritance. */
spawnedBy?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
/** Reply-to mode for Slack auto-threading. */
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
hasRepliedRef?: { value: boolean };
/** If true, the model has native vision capability */
modelHasVision?: boolean;
/** Require explicit message targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
}): AnyAgentTool[] {
The tool-builder contract omits senderIsOwner, so the owner/non-owner distinction is not modeled in the function that decides which tools the embedded agent may see.
const tools: AnyAgentTool[] = [
...base,
...(sandboxRoot
? allowWorkspaceWrites
? [createSandboxedEditTool(sandboxRoot), createSandboxedWriteTool(sandboxRoot)]
: []
: []),
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
execTool as unknown as AnyAgentTool,
// @ts-ignore -- cherry-pick upstream type mismatch
processTool as unknown as AnyAgentTool,
// Channel docking: include channel-defined agent tools (login, etc.).
...listChannelAgentTools({ cfg: options?.config }),
...createOpenClawTools({
// @ts-ignore -- cherry-pick upstream type mismatch
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
agentSessionKey: options?.sessionKey,
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
agentAccountId: options?.agentAccountId,
agentTo: options?.messageTo,
agentThreadId: options?.messageThreadId,
agentGroupId: options?.groupId ?? null,
agentGroupChannel: options?.groupChannel ?? null,
agentGroupSpace: options?.groupSpace ?? null,
agentDir: options?.agentDir,
sandboxRoot,
workspaceDir: options?.workspaceDir,
sandboxed: !!sandbox,
config: options?.config,
pluginToolAllowlist: collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
subagentPolicy,
]),
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
modelHasVision: options?.modelHasVision,
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
disableMessageTool: options?.disableMessageTool,
requesterAgentIdOverride: agentId,
}),
createOpenClawCodingTools() assembles the final tool list and includes createOpenClawTools(), which contributes the owner-only gateway and cron tools to the embedded agent surface.
const withAbort = options?.abortSignal
? withHooks.map((tool) => wrapToolWithAbortSignal(tool, options.abortSignal))
: withHooks;
// NOTE: Keep canonical (lowercase) tool names here.
// pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names
// on the wire and maps them back for tool dispatch.
return withAbort;
The tool builder returns withAbort directly without applying applyOwnerOnlyToolPolicy(), which is the core omission that leaves owner-only tools exposed to non-owner senders.
export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) {
const withGuard = tools.map((tool) => {
if (!isOwnerOnlyTool(tool)) {
return tool;
}
return wrapOwnerOnlyToolExecution(tool, senderIsOwner);
});
if (senderIsOwner) {
return withGuard;
}
return withGuard.filter((tool) => !isOwnerOnlyTool(tool));
The repository already contains the intended mitigation. Applying this helper removes gateway and cron for non-owner senders and serves as the validated control condition.
const gateway = resolveGatewayOptions(opts);
return await callGateway<T>({
url: gateway.url,
token: gateway.token,
method,
params,
timeoutMs: gateway.timeoutMs,
expectFinal: extra?.expectFinal,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "agent",
mode: GATEWAY_CLIENT_MODES.BACKEND,
callGatewayTool() forwards agent-selected gateway methods without attaching least-privilege method-specific scopes, so any exposed owner-only tool inherits the broader default connection privileges.
clientName: opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: opts.clientDisplayName,
clientVersion: opts.clientVersion ?? "dev",
platform: opts.platform,
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
deviceIdentity: loadOrCreateDeviceIdentity(),
callGateway() opens the WebSocket session as role operator with operator.admin, operator.approvals, and operator.pairing, which turns accidental tool exposure into a real control-plane privilege problem.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions