The archive/CVE-2020-16846
saltstack/saltDisclosed 2020

CVE-2020-16846:salt: OS command injection

A reconstruction of the flaw, and the evidence path Lachesis follows to make it visible.

Historical recordv3002 (affected: SaltStack Salt through 3002)
WeaknessCWE-78 · OS command injection
ImpactA os command injection in salt, reachable from attacker-controlled input.
Read the reconstruction
01What happened

Unauthenticated salt-api with salt-ssh: shell metacharacters in ssh/roster params reach a shell.

That is the shape of the failure. The rest of this case file follows the evidence behind it.

Separate the record from the run

What was known.
What Lachesis established.

01Historical record

Known before Lachesis ran

The CVE and vulnerable release were selected from public history. We are not claiming novel discovery.

CVE
CVE-2020-16846
Vulnerable target
v3002 (affected: SaltStack Salt through 3002) · vulnerable
Prior knowledge
CVE-2020-16846 · v3002 (affected: SaltStack Salt through 3002) is vulnerable
then
02Lachesis run
Verified captured run

Independent re-detection

The experiment graph was built around the vulnerable netapi surface salt/netapi (release v3002), rather than starting from arbitrary Salt source. We did not point the tool at the CVE. Enumeration ran over the whole candidate registry (31 constructors, every family) with the pass-3 guard-differential ranker ordering leads; 616 candidates surfaced across six families (lifecycle null-deref/use-after-free, navigation fetch, object-integrity deserialize, filesystem path, and injection.exec). The single injection.exec.escaping obligation came out of that blind sweep and ranked top (0.8125). Every field under sast_output is the enumerator's own emission (candidate capsule plus sources_of value-flow cone); everything under adjudication is my reading of the source and fix and is labelled as such.

Seeded inputs
None
Run timestamp
2026-09-02T13:30:30Z
Evidence artifact
salt_netapi.kuzu

The commands and outputs below come from this recorded Lachesis run.

The reconstruction, in one frame

What Lachesis reconstructed.

One historical repository. Four captured queries. A complete source-to-sink argument.

Lachesis Casefiles · Unbound ComputeVerified runCVE-2020-16846
Unauthenticated salt-api with salt-ssh: shell metacharacters in ssh/roster params reach a shell.
01Sourcelowstate = cherrypy.request.lowstate
02Sinkret = self.api.run(chunk)
03Missing guardsubprocess/NonBlockingPopen(cmd, shell=True, ...)
$ lachesis sources_of → reaches → guard_dominance
saltstack/salt · v3002 (affected: SaltStack Salt through 3002) · vulnerableunboundcompute.com
This was known history. Your code is not.

Lachesis follows the same evidence path through unfamiliar repositories.

The Lachesis reconstruction

Here is how the path becomes visible.

These are the recorded questions Lachesis asked of the historical vulnerable code, followed by the raw result and source location each query returned.

Repositorysaltstack/salt
Revisionv3002 (affected: SaltStack Salt through 3002) · vulnerable
EngineLachesis

Captured runThis is a real replay of Lachesis over the graph we built for this case (salt/netapi at release v3002). Every command below was run against that graph and every result is the output captured on that run (elapsed 9.7s). We did not point the tool at a file or a family. The hunt enumerated the whole taxonomy first (616 candidates across six families) and the injection.exec command-injection sink came out of that as the top-ranked lead. No request or payload was seeded; the graph was built from the source alone and the finding was discovered from graph structure, so the mode is independent-redetection.

01 · load_graph01 / 05
Lachesis ran
load_graph --path salt_netapi.kuzu --profile all && candidate_census

Load the graph and list every bug family

First we load the graph and ask the tool to list every bug family it knows, with nothing chosen ahead of time. It reports all 8 domains and 31 sink constructors. injection is one row here, not something we aimed at.

Captured resultCaptured · load_graph
raw captured outputload_graph
[lachesis-mcp] loaded the graph (published 6750 nodes and 17253 edges); overlay: on-demand dataflow per cone

CANDIDATE_CENSUS
move: candidate_census
taxonomy (8): lifecycle, memory, injection, navigation, object-integrity, filesystem, crypto-config, resource
constructors (31): complete_for_observable_graph=True
salt/netapi/rest_cherrypy/app.pyline 0
Code location returned for this evidence step.
The repair

A small line with a large consequence.

request-derived fields are interpolated into a shell command string and run through /bin/sh with no escaping, so shell metacharacters execute; the netapi layer forwards them unescaped.

Before
subprocess/NonBlockingPopen(cmd, shell=True, ...)
After the patch
// remove shell=True and pass an argv list (fixed in 3002.1 / 3001.2 / 3000.3, commit 023528b3).
Sources & further reading

The original record.

Read the historical advisory and vulnerability record behind this reconstruction.

Open the Lachesis quickstart