Summary
A non-admin user (or a scoped API key) holding only create_events/run_events can add
an extra top-level uid (and gid) field to an event. The event-data validator passes
unknown keys through, the value is stored verbatim, and at job launch the (root) daemon
setuids the spawned child to that uid. Setting the run-as-user is an admin-only control
everywhere else (the Plugin "Run as User" Advanced option is admin/requireAdmin gated).
Result: a low-privilege user runs jobs as ANY user, including root, on the typical
root-daemon install.
Affected: v0.9.120 (current), HEAD f5a5388. Severity: privilege escalation; on the default
root daemon, low-priv → root code execution on the Cronicle host.
Root cause (file:line)
- lib/api.js:160-232 requireValidEventData → validateOptionalParams is a PARTIAL allowlist
that only checks keys present in its rules object and silently passes unknown keys.
uid/gid are NOT in the rules, so they are stored verbatim on the event.
- lib/job.js:192 launchJob copies the whole event into the job.
- lib/job.js:220
if (plugin.uid) job.uid = plugin.uid; — the plugin uid overrides the
event uid ONLY when the plugin defines one. The shipped default plugins (Shell Script
shellplug, HTTP Request urlplug) have no uid, so the attacker's event-level uid survives.
- lib/job.js:462
child_opts.uid = job.uid || process.getuid();
- lib/job.js:490-499 getpwnam(child_opts.uid) → child_opts.uid/gid set → cp.spawn setuid by
the root daemon.
This is a distinct sibling of the patched CVE-2026-39401 / GHSA-5j3v-cq96-xw6v, which covered
uid/gid injected via JOB OUTPUT and is gated at lib/job.js:716-718. The API-side event
uid/gid injection is not covered by that fix and, to our knowledge, is unpublished.
Reproduction (live, via the API)
Daemon runs as root (default per docs/Setup.md). As admin, create a NON-ADMIN API key with
only create/run-event privileges. Then, using that key:
POST /api/app/create_event
{"api_key":"","title":"x","enabled":0,"category":"general","plugin":"shellplug",
"target":"","timing":{},"params":{"script":"#!/bin/sh\nid\n"}, "uid":0}
POST /api/app/run_event {"api_key":"","id":"<event_id>"}
Observed (script id output): the job runs as uid=0(root). Injecting "uid":65534
instead runs it as uid=65534(nobody) — proving the non-admin's injected uid is honored
(it differs from the default daemon uid). get_event shows the uid field stored from the
non-admin key (validator passthrough). Full transcript in LIVE_VERIFICATION.md; a scripted
repro is in exploit.sh.
Tested only on a local container with a harmless id payload; no third-party system accessed.
Remediation
In launchJob, unconditionally delete event-level uid/gid unless they come from the plugin
(mirror the lib/job.js:220 override logic with an explicit strip). Alternatively, add uid/gid
to the validator allowlist guarded by an admin/privilege check, so non-admins cannot set them.
Summary
A non-admin user (or a scoped API key) holding only
create_events/run_eventscan addan extra top-level
uid(andgid) field to an event. The event-data validator passesunknown keys through, the value is stored verbatim, and at job launch the (root) daemon
setuids the spawned child to that uid. Setting the run-as-user is an admin-only controleverywhere else (the Plugin "Run as User" Advanced option is admin/requireAdmin gated).
Result: a low-privilege user runs jobs as ANY user, including root, on the typical
root-daemon install.
Affected: v0.9.120 (current), HEAD f5a5388. Severity: privilege escalation; on the default
root daemon, low-priv → root code execution on the Cronicle host.
Root cause (file:line)
that only checks keys present in its
rulesobject and silently passes unknown keys.uid/gidare NOT in the rules, so they are stored verbatim on the event.if (plugin.uid) job.uid = plugin.uid;— the plugin uid overrides theevent uid ONLY when the plugin defines one. The shipped default plugins (Shell Script
shellplug, HTTP Requesturlplug) have no uid, so the attacker's event-level uid survives.child_opts.uid = job.uid || process.getuid();the root daemon.
This is a distinct sibling of the patched CVE-2026-39401 / GHSA-5j3v-cq96-xw6v, which covered
uid/gid injected via JOB OUTPUT and is gated at lib/job.js:716-718. The API-side event
uid/gid injection is not covered by that fix and, to our knowledge, is unpublished.
Reproduction (live, via the API)
Daemon runs as root (default per docs/Setup.md). As admin, create a NON-ADMIN API key with
only create/run-event privileges. Then, using that key:
POST /api/app/create_event
{"api_key":"","title":"x","enabled":0,"category":"general","plugin":"shellplug",
"target":"","timing":{},"params":{"script":"#!/bin/sh\nid\n"}, "uid":0}
POST /api/app/run_event {"api_key":"","id":"<event_id>"}
Observed (script
idoutput): the job runs as uid=0(root). Injecting"uid":65534instead runs it as uid=65534(nobody) — proving the non-admin's injected uid is honored
(it differs from the default daemon uid).
get_eventshows theuidfield stored from thenon-admin key (validator passthrough). Full transcript in LIVE_VERIFICATION.md; a scripted
repro is in exploit.sh.
Tested only on a local container with a harmless
idpayload; no third-party system accessed.Remediation
In launchJob, unconditionally delete event-level
uid/gidunless they come from the plugin(mirror the lib/job.js:220 override logic with an explicit strip). Alternatively, add
uid/gidto the validator allowlist guarded by an admin/privilege check, so non-admins cannot set them.