Enjoying this issue?
Get tomorrow's AI & engineering digest in your inbox — hand-picked, summarized, and always spam-free.
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
Stop scrolling. Start reading smarter.
Receive the day's most important AI & engineering updates in one concise email. No spam.
Transcript (captions)
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.
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.
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
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
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
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
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,
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,
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
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
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
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,
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.
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
live at several layers. The borrow checker protects memory. The API shape keeps arguments separate. Validation limits choices. Process configuration limits ambient influence. Operating
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
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
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
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
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
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
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
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
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
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
don't confuse a strong foundation with a finished security boundary.