Even Rust Isn't Safe......

summarized

TLDR

Safe Rust can still hand untrusted text to a shell because memory safety and application security answer different questions. GitHub's new CodeQL check (Rust/Commandline injection, added September 9th) detects these paths via taint analysis, but the real fix is design: convert untrusted text into a small trusted choice, keep arguments separate, and pair the borrow checker with taint analysis and narrow process authority.

Key points

GitHub added CodeQL 2.27 with Rust/Commandline injection on September 9th.

The query uses global taint tracking to find flows from sources to command execution sinks.

The query marks alerts with security severity 9.8, tied to CWE-78 and CWE-88.

The Rust extension treats numeric, boolean, and fieldless enum values as barriers.

The recommended fix is to map untrusted input to a fixed set of allowed commands and arguments.

The transcript notes that cmd.exe and batch files decode arguments differently on Windows, posing a risk.

Tools mentioned

Techniques

  • Taint analysis
  • Data flow analysis
  • Command injection detection
  • Barrier and sanitizer modeling
  • Process authority minimization
  • Canonicalization of paths
Transcript (captions)

0:00 This safe rust function can satisfy the compiler and still hand untrusted text to a shell. The borrow checker can protect every reference here while the operating system runs what the attacker

0:11 chose. GitHub added a code QL check for this yesterday. It follows that text from a source to a process sync. By the end, you'll know which safety wall rust builds, where it ends, and the review

0:23 that catches the gap. Here is GitHub's own bad sample. A request value enters safe rust becomes an argument to shei and reaches -ash c. There's no unsafe block raw pointer or foreign function in

0:36 that path. The dangerous call is a safe standard library API. The compiler checks whether user input is a valid borrowed string. It doesn't ask whether that string deserves authority. Then the

0:48 process boundary changes the problem. Rust hands a valid string to an interpreter that treats the bytes as instructions. On September 9th, CodeQL 2.27 added Rust/Commandline

1:00 injection to detect uncontrolled command lines. So yes, safe Rust can run an attacker's command. Memory safety and application security answer different questions. Start with the promise Rust

1:12 actually makes. In safe Rust, references must point to valid values for the life of the reference. Ownership gives each value one owner, and dropping that owner releases its resources. Those rules shut

1:24 down whole families of memory corruption. Suppose a web route accepts an action named rebuild index. The text is valid UTF8. Its borrow is valid and every owner is clear. None of those

1:37 facts proves the caller may rebuild anything. The compiler sees a sound string. The application still owes an authorization decision. A move also prevents two owners from freeing the

1:48 same allocation. The Rust book uses that exact double free case. After one string moves into another variable, the old binding isn't valid. That's compiler enforced memory safety, and it's

2:00 genuinely valuable. Borrowing adds another wall. Shared references can't mutate ordinary data, and mutable references exclude competing references while they're live. The compiler rejects

2:12 the aliasing patterns that would make a pointer race or dangle. The Rust reference lists data races, dangling or misaligned pointer access, broken aliasing rules, and invalid values among

2:23 undefined behaviors. Safe code is designed so you can't trigger those behaviors through safe interfaces. But the word safe has a scope. The Rust Anomicon says safe rust protects type

2:35 safety and memory safety. It doesn't promise that every file write, network request, database update, or child process is the one your user intended. That distinction is easy to miss because

2:47 both failures can end with a compromised machine. One route corrupts memory to seize control. The other asks the program to use authority it already has. Rust blocks the first route. Your design

2:59 must block the second. Think of the borrow checker as a guard at the memory boundary. It validates ownership, lifetimes, and reference use. Command injection crosses a different boundary.

3:10 Data becomes control when a shell or executable launcher interprets it. The tiny sample keeps every memory invariant intact. User input is a valid string slice. Command owns its builder state.

3:22 Output returns a result. Even the error path is typed. The program can be perfectly memory safe while its effect is catastrophically wrong. That result answers whether the child could be

3:33 launched and whether output could be collected. It can't tell you the command was authorized. A successful status only means the operating system did what the program requested, which is exactly the

3:44 problem when untrusted influence shaped the request. So that is memory safety. Now follow control. I dislike saying Rust makes software safe without a qualifier. It makes an extraordinary set

3:57 of bugs harder or impossible, but it doesn't review trust boundaries for you. GitHub's new query is a path problem. In the query source, CodeQL declares a source, a sync, and a barrier. Then asks

4:10 whether global taint tracking can find a flow path from the first to the second without crossing the third. Taint is a label attached to data with a suspicious origin. As the value is assigned,

4:22 borrowed, formatted, returned, or passed into another call. The analysis carries that label through its data flow graph. No exploit has to run. The source can be command line arguments, remote input,

4:35 environment data, files, or standard input when the active threat model marks them untrusted. The important question isn't what type the value has, it's who was allowed to choose it. The sync

4:47 represents command execution. GitHub's tests cover a user controlled program passed to standard command new remote text passed after ssh- c direct arguments and args array a formatted

5:00 command string and the Tokyo process builder y flag an argument when rust normally passes arguments literally because the call matters a normal executable receives an argument as data

5:12 but a shell's c option asks the shell to parse that argument as a program the standard library documentation confirms the safe case. Command arg doesn't pass text through a shell. So quoting,

5:24 globbing, variable expansion, and word splitting have no effect. Separate arguments preserve the data boundary. That protection depends on the receiving program treating the argument as data.

5:36 If you deliberately select a shell, an interpreter, or another tool with an evaluate this option, the separate argument crosses back into a language. The API kept its promise. The

5:48 application changed the meaning. The same documentation carries a Windows warning. Most programs follow the standard argument convention, but cmd.xe and batch files decode arguments

5:59 differently. A malicious argument can potentially run arbitrary shell commands there. Command new has another sharp edge. If the program path isn't absolute, the operating system searches

6:10 path. An attacker who can influence the chosen program, the search path or the working directory may redirect execution without touching memory at all. Environment inheritance matters for the

6:22 same reason. Child process inherit the parent environment by default. Rust offers m remove and mcle clear and its documentation recommends an absolute path or an explicitly controlled path

6:35 when you need predictable resolution. Now follow the codeql path through GitHub's test fixture. A value begins at standard environment args. It flows through nth unwrap or as sour and

6:47 finally into command new. The expected result names the source and the exact sync location. A second test starts with an HTTP response body. That string moves through result handling and lands as the

7:00 third argument after shesh and c. The query doesn't care that each method call is typed correct. It cares that remote influence survives to interpretation. At this point, we've traced the path and

7:12 the process boundary. Now, the barriers, the query metadata marks this alert high precision with security severity 9.8 tied to CWE78 and 88. That score describes potential

7:25 consequence, not instant exploitability. Barriers are where the analysis learns that a dangerous path has been constrained. The Rust extension treats numeric, boolean, and fieldless enum

7:37 values as barriers because those types can't carry an arbitrary command string. It also recognizes successful membership checks and equality checks as sanitizer guards. GitHub's tests show an allow

7:49 list containing cat, git, and ls. The branch inside the successful check is clean while the branch outside it still alerts. The branch matters. A validation call that computes true or false but

8:02 doesn't control the path to the sync is only decoration. CodeQL models the successful side of the guard because the command must be constrained on every route that can reach execution that

8:13 reveals the strongest fix. Don't clean a free form command and hope you covered every meta character. Convert untrusted text into a small trusted choice. Then map that choice to hard-coded programs

8:25 and arguments. Authority becomes explicit. GitHub's good sample uses three allowed fails and a fixed cat executable. Better yet, many applications can skip the process

8:36 entirely and use a Rust library for the operation. Removing the interpreter removes an entire class of parser confusion. If you must launch a process, keep the executable fixed. Pass each

8:48 argument separately. Use an absolute path where practical. Clear inherited environment entries you don't need. Set the working directory deliberately and run with the least operating system

8:58 privilege available. Take a report download endpoint. Instead of accepting a whole command, accept a report identifier and an output format enum. Map the enum to one fixed library

9:10 operation or one fixed executable. Reject unknown values and pass the report path as a literal argument after canonicalizing it inside an allowed directory. Notice how those defenses

9:22 live at several layers. The borrow checker protects memory. The API shape keeps arguments separate. Validation limits choices. Process configuration limits ambient influence. Operating

9:34 system permissions cap the damage if an assumption still fails. CodeQL belongs beside those layers, not above them. It can find a source to sync path across a large repository that a local code

9:47 review may miss. And GitHub code scanning automatically deploys new codeql versions on github.com. But static analysis also has a model boundary. A custom wrapper framework

9:59 sanitizer or command launcher may need models as data so code knows whether it's a source sync or barrier. An absent model can hide a path. A loose model can add noise. Two layers so far, the

10:13 compiler's wall and the analyzer's trace. Now the review joins them. Treat an alert as a path to inspect. Confirm the input origin process API shell parsing dominating validation and the

10:26 child's identity. A clean scan isn't proof that no command injection path exists. It means the analyzer found no path under its current extraction and models. Keep code review tests around

10:38 the trust boundary and operating system restrictions, especially for rappers the query may not understand yet. The release is timely because Rust is now used for network services, developer

10:50 tools, agents, and build infrastructure where launching other programs is normal. The more authority a Rust binary has, the more important that second safety review becomes. Here's the

11:01 practical rule I'd put in a review checklist. Every command new gets an authority question. Who chooses the executable? Who chooses each argument? Can any value reach a shell parser? And

11:12 what can the child process access? The verdict is simple. Safe Rust protects the integrity of your program's memory. It can't decide whether a valid string should become an operating system

11:23 action. Pair the borrow checker with taint analysis and narrow process authority. Which command call in your codebase would fail that review today? Rust is still the safer foundation. Just

11:35 don't confuse a strong foundation with a finished security boundary.

Frontier News · by Hyperjump Technology