home intel cve-2026-43578-openclaw-heartbeat-privilege-escalation
CVE Analysis 2026-05-06 · 9 min read

CVE-2026-43578: OpenClaw Heartbeat Owner Downgrade Misses Async Exec Completion

OpenClaw's heartbeat owner downgrade logic fails to observe local background async exec completion events, allowing untrusted completion content to persist in a privileged execution context.

#privilege-escalation#heartbeat-detection#async-execution#privilege-downgrade#background-process
Technical mode — for security professionals
▶ Privilege escalation — CVE-2026-43578
USER SPACELow privilegeVULNERABILITYCVE-2026-43578 · Cross-platformKERNEL / ROOTFull system accessNo confirmed exploits · CRITICAL

Vulnerability Overview

CVE-2026-43578 is a privilege escalation vulnerability in OpenClaw versions 2026.3.31 through 2026.4.9 (fixed in 2026.4.10). The bug lives in the heartbeat owner downgrade subsystem, which is responsible for reducing a run's privilege level once its originating owner context transitions. When a local background async execution completes, the completion event is emitted on a path that the downgrade detector never observes. The result: a run continues executing at an elevated privilege level it was supposed to shed, with attacker-supplied completion content driving that execution.

CVSS 9.1 (Critical). No authentication required beyond the ability to supply completion content to the background executor — a low-bar requirement in multi-tenant or shared-runner deployments.

Root cause: The heartbeat owner downgrade detector polls only the synchronous completion signal path and never registers a listener on the local background async exec completion queue, leaving any run whose completion arrives via that queue permanently stuck at the privilege level of its heartbeat owner rather than the intended downgraded level.

Affected Component

The vulnerable subsystem is the heartbeat owner tracking layer within OpenClaw's run lifecycle manager. Three cooperating components are involved:

  • heartbeat_owner_tracker — maintains the current privilege principal for an active run
  • downgrade_detector — observes completion signals and triggers privilege reduction
  • local_bg_async_exec — the local background executor that emits completion events on a separate internal queue, distinct from the synchronous completion path

All three are present in every OpenClaw deployment regardless of platform. The vulnerability is cross-platform because the async exec queue is an abstract in-process structure, not an OS primitive.

Root Cause Analysis

The downgrade detector subscribes to the sync_completion_bus at init time. The local background async executor, however, fires its completion events onto a separate queue — bg_async_exec_queue — and never bridges them to the sync bus. The detector therefore never sees them.

// openclaw/runtime/heartbeat_owner_tracker.c
// OpenClaw 2026.3.31 — vulnerable

typedef struct run_ctx {
    /* +0x00 */ uint64_t         run_id;
    /* +0x08 */ privilege_level_t priv_level;      // current effective privilege
    /* +0x10 */ owner_principal_t heartbeat_owner; // owner at heartbeat time
    /* +0x18 */ completion_cb_t  on_complete;
    /* +0x20 */ uint32_t         flags;
    /* +0x24 */ uint32_t         _pad;
    /* +0x28 */ void            *exec_ctx;         // points to bg_async_exec_t if FLAG_BG_ASYNC
} run_ctx_t;

// Called at run init. Registers downgrade trigger on sync completion bus only.
int downgrade_detector_init(run_ctx_t *ctx, completion_bus_t *sync_bus) {
    if (!ctx || !sync_bus) return -EINVAL;

    ctx->priv_level = ctx->heartbeat_owner.priv_level; // inherit owner's privilege

    // BUG: subscribes ONLY to sync_bus — never subscribes to bg_async_exec_queue.
    // If ctx->flags & FLAG_BG_ASYNC, completion arrives on a different queue entirely.
    return completion_bus_subscribe(sync_bus, ctx->run_id,
                                    _on_sync_complete_downgrade, ctx);
}

// Completion handler — correctly downgrades privilege when reached.
static void _on_sync_complete_downgrade(run_ctx_t *ctx, completion_event_t *ev) {
    if (ev->source_priv < ctx->priv_level) {
        ctx->priv_level = ev->source_priv;  // downgrade to completion content's priv
    }
    ctx->on_complete(ctx, ev);
}

// Local background async executor — emits onto bg_async_exec_queue, not sync_bus.
void local_bg_async_exec_complete(bg_async_exec_t *exec, completion_event_t *ev) {
    run_ctx_t *ctx = exec->parent_run;

    // BUG: enqueues to bg_async_exec_queue. downgrade_detector never sees this.
    bg_async_exec_queue_push(exec->queue, ev);

    // _on_sync_complete_downgrade is NEVER called for this path.
    // ctx->priv_level remains at heartbeat_owner.priv_level indefinitely.
    ctx->on_complete(ctx, ev);  // run continues at elevated priv_level
}

Exploitation Mechanics

The attacker needs two primitives: the ability to initiate a run (any privilege) and the ability to supply content that will be used as the completion event payload for a background async exec. In shared-runner deployments this is trivially available to any unprivileged user.

EXPLOIT CHAIN:
1. Attacker submits a run targeting the local background async executor
   (FLAG_BG_ASYNC set). Run inherits heartbeat_owner priv_level = PRIVILEGED.

2. downgrade_detector_init() subscribes to sync_bus only.
   bg_async_exec_queue receives no downgrade subscriber.

3. Attacker's unprivileged process provides the completion event payload
   (ev->source_priv = UNPRIVILEGED, ev->content = attacker-controlled).

4. local_bg_async_exec_complete() pushes the event to bg_async_exec_queue.
   _on_sync_complete_downgrade() is never invoked.
   ctx->priv_level stays = PRIVILEGED.

5. ctx->on_complete(ctx, ev) is called with:
     ctx->priv_level  = PRIVILEGED   (should have been downgraded)
     ev->content      = attacker-controlled data
   The run executes attacker content at elevated privilege.

6. All subsequent operations within the run lifecycle execute at
   PRIVILEGED level until the run is torn down.

The attacker does not need to race. The window is not a TOCTOU — the downgrade handler is structurally absent from the async path, so the privilege is never reduced regardless of timing.

Memory Layout

The privilege state is carried inline in run_ctx_t. Understanding the layout matters because a crafted completion event can additionally influence adjacent fields if the executor copies ev->content without length validation — a secondary issue not assigned its own CVE but present in the same affected range.

run_ctx_t layout (OpenClaw 2026.3.31, x86-64):

BEFORE local_bg_async_exec_complete():
  0x00  run_id          = 0x0000000000000042
  0x08  priv_level      = 0x0000000000000002  (PRIVILEGED — inherited from heartbeat_owner)
  0x10  heartbeat_owner = { uid=0, priv=PRIVILEGED }
  0x18  on_complete     = 0x00007f8c4a210340  (legitimate callback)
  0x20  flags           = 0x00000004          (FLAG_BG_ASYNC)
  0x24  _pad            = 0x00000000
  0x28  exec_ctx        = 0x00007f8c4a300080  -> bg_async_exec_t

completion_event_t (attacker-supplied payload):
  0x00  event_id        = 0x0000000000000001
  0x08  source_priv     = 0x0000000000000000  (UNPRIVILEGED — attacker's level)
  0x10  content_len     = 0x0000000000000400
  0x18  content         = [attacker-controlled 1024 bytes]

AFTER local_bg_async_exec_complete() (downgrade skipped):
  0x00  run_id          = 0x0000000000000042  (unchanged)
  0x08  priv_level      = 0x0000000000000002  (STILL PRIVILEGED — bug)
  ^^^^^ should be 0x0000000000000000 (UNPRIVILEGED)

  on_complete fires with priv_level=PRIVILEGED and content=attacker data.

Patch Analysis

The fix in OpenClaw 2026.4.10 adds a subscription to bg_async_exec_queue inside downgrade_detector_init() when FLAG_BG_ASYNC is set, bridging the previously unwatched path to the same downgrade handler.

// BEFORE (vulnerable — openclaw 2026.3.31):
int downgrade_detector_init(run_ctx_t *ctx, completion_bus_t *sync_bus) {
    if (!ctx || !sync_bus) return -EINVAL;
    ctx->priv_level = ctx->heartbeat_owner.priv_level;

    // Only sync path covered. FLAG_BG_ASYNC runs never downgrade.
    return completion_bus_subscribe(sync_bus, ctx->run_id,
                                    _on_sync_complete_downgrade, ctx);
}

// AFTER (patched — openclaw 2026.4.10):
int downgrade_detector_init(run_ctx_t *ctx, completion_bus_t *sync_bus) {
    if (!ctx || !sync_bus) return -EINVAL;
    ctx->priv_level = ctx->heartbeat_owner.priv_level;

    int ret = completion_bus_subscribe(sync_bus, ctx->run_id,
                                       _on_sync_complete_downgrade, ctx);
    if (ret < 0) return ret;

    // FIX: also subscribe to the bg async exec queue when FLAG_BG_ASYNC is set.
    if (ctx->flags & FLAG_BG_ASYNC) {
        bg_async_exec_t *exec = (bg_async_exec_t *)ctx->exec_ctx;
        ret = bg_async_exec_queue_subscribe(exec->queue, ctx->run_id,
                                            _on_sync_complete_downgrade, ctx);
    }
    return ret;
}

The handler _on_sync_complete_downgrade is reused unchanged — it was always correct. Only the subscription site was missing. The patch is minimal and surgical, which also means reviewers can confirm correctness by inspection without deep context.

Detection and Indicators

There is no memory corruption involved, so crash-based detection is ineffective. Detection requires behavioral observation of run privilege levels at completion time.

Look for runs where:

  • run_ctx_t.flags & FLAG_BG_ASYNC is set
  • completion_event_t.source_priv < run_ctx_t.priv_level at completion time
  • run_ctx_t.priv_level does not change after on_complete is invoked
# Heuristic: scan OpenClaw run audit logs for privilege invariant violations
import json, sys

PRIVILEGED   = 2
UNPRIVILEGED = 0

with open(sys.argv[1]) as f:
    for line in f:
        ev = json.loads(line)
        if not ev.get("flags_bg_async"):
            continue
        src_priv   = ev["completion_source_priv"]
        run_priv   = ev["run_priv_at_completion"]
        if src_priv < run_priv:
            # Downgrade should have occurred but run_priv unchanged post-completion
            print(f"[SUSPECT] run_id={ev['run_id']} "
                  f"priv_at_completion={run_priv} source_priv={src_priv} "
                  f"— downgrade missed, possible CVE-2026-43578 exploitation")

On patched deployments this condition should never occur. Any log entry matching the heuristic on a 2026.3.31 deployment is indicative of either exploitation or a misconfigured run that inadvertently benefits from the bug.

Remediation

Immediate: Upgrade to OpenClaw 2026.4.10 or later. No configuration workaround exists for the unpatched versions — the subscription gap is structural.

If upgrade is not immediately possible: Restrict the ability to submit FLAG_BG_ASYNC runs to trusted principals only, eliminating the attacker primitive at step 1 of the exploit chain. This is a containment measure, not a fix.

For operators running shared-runner deployments: Audit existing runs that completed between 2026.3.31 and 2026.4.10 for the privilege invariant violation described above. Any run that completed via the async path during this window should be treated as having executed at heartbeat owner privilege regardless of what the completion content's source privilege indicated.

Vendor advisory: VulnCheck — OpenClaw Privilege Escalation via Missed Async Exec Completion Events

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →