Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

quoting_style: ! in double quotes allows command injection when pasted

Aperta Adatta ai principianti
#15,812 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
2/5
Tempo stimato
1-3 ore
Idoneità per principianti
76/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
bash, rust
Ambito
cli, security

Direzione di ricerca

Inizia da src/uucore/src/lib/features/quoting_style/shell_quoter.rs, in particolare da initial_quoting() e shell_escaped_char_set(), poi leggi i test di quoting in quoting_style/mod.rs. Usa il riproduttore fornito per verificare l'output incollato e aggiungi test di regressione mirati per i nomi che contengono sia ' sia !; il lavoro è completato quando l'output può essere incollato in sicurezza in un prompt bash interattivo e i test passano.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

good first issue I - Incorrect oss-scanner U - All (uucore) U - ls
============================================================================
ANT-2026-WV2L2BWK  [medium]  CWE-116  injection
quoting_style: ! in double quotes allows command injection when pasted
Location: src/uucore/src/lib/features/quoting_style/shell_quoter.rs:234  in initial_quoting()
Commit:   7bc1eb6d1d6fa2e1dc2c9d2277cf576c05f61e47
============================================================================

A file name chosen by one user can make uutils ls print a shell-quoted form that, when another user pastes it back at an interactive bash prompt, runs a command of the attacker's choosing as that user. GNU coreutils prints a harmless form for the same name. The attached reproducer has nobody create a file in a world-writable directory; root lists it with ls on a terminal and types rm -- <what ls printed> into bash -i, and a root-owned PWNED file appears.

The quote character is chosen once, in initial_quoting() in src/uucore/src/lib/features/quoting_style/shell_quoter.rs. A name containing an apostrophe is wrapped in "…" unless it also contains a byte from ESCAPED_CHARS (line 250). That set lacks !, which interactive bash still history-expands inside double quotes. So the name x!#;touch PWNED;#' is printed as "x!#;touch PWNED;#'". At the prompt, !# re-inserts the line typed so far, including its opening ", which closes the quoted string and leaves ;touch PWNED; to run as a command. GNU 9.7 prints 'x!#;touch PWNED;#'\''' instead.

The same check serves all four shell quoting styles and every caller. This covers ls on a terminal and with the shell* styles, printf %q, stat -c %N, and the locale_aware_shell_escape() headers and diagnostics of head, tail, wc, fold and the checksum utilities. The attack needs the victim to paste the printed name into an interactive shell with history expansion on, which is bash's default. Scripts, eval and other non-interactive uses are unaffected. The payload cannot contain / or any character from ESCAPED_CHARS, but it can run a script placed next to it. The behaviour dates from 2941dfd698d1 ("ls: quoting style (#1989)") and is in every release since 0.0.5.

Details

The shell quoting styles wrap a name that contains an apostrophe in "…" instead of '…'. The list of
characters that forbids this switch has no !, and an interactive bash still performs history expansion between
double quotes. A file name can use that to close the quotes and run a command when the quoted name is entered at
a prompt:

$ touch -- 'x!#;touch PWNED;#'\'''     # the name is  x!#;touch PWNED;#'
$ ls                                   # uutils, stdout is a terminal
"x!#;touch PWNED;#'"
$ rm -- "x!#;touch PWNED;#'"           # pasted from the line above
rm -- "xrm -- "x;touch PWNED;#'"       # bash echoes the line after history expansion
rm: cannot remove 'xrm -- x': No such file or directory

Afterwards the directory holds a new file PWNED and the original file is still there. !# expands to the
command line typed so far, here rm -- "x. That text brings a second " with it, which closes the quoted
string, so ;touch PWNED; is parsed as a command and # comments out the rest of the name.

GNU coreutils 9.7 prints 'x!#;touch PWNED;#'\''' for the same file; pasting that removes the file and does
nothing else.

The quote character is chosen once, before any character is emitted, in initial_quoting
(src/uucore/src/lib/features/quoting_style/shell_quoter.rs:227):

let has_special_chars = input.iter().any(|c| {
    shell_escaped_char_set(dirname).contains(c) || (check_control_chars && c.is_ascii_control())
});

if has_special_chars {
    (ShellQuotes::Single, true)
} else if input.contains(&b'\'') {
    (ShellQuotes::Double, true)          // line 234

and the set it consults is (shell_quoter.rs:250):

const ESCAPED_CHARS: &[u8] = b":\"`$\\^\n\t\r=";

The set has ", `, $ and \, the characters that keep a meaning between double quotes in any shell.
! keeps one too in an interactive bash, and it is missing.

The module does know ! as a special character: it is in SPECIAL_SHELL_CHARS (escaped_char.rs:12), for
which EscapedChar::new_shell returns ForceQuote (escaped_char.rs:159). But ForceQuote only sets
must_quote in push_char (shell_quoter.rs:83 and :177); the quotes value picked by initial_quoting
is never revisited. In Double mode the ! is copied to the buffer as is and finalize_shell_quoter
(shell_quoter.rs:258) wraps the buffer in ".

Without the apostrophe the same name falls through to Single, ! forces quoting, and the output
'x!#;touch PWNED;#' is inert. The apostrophe is what moves the name into double quotes.

EscapedShellQuoter::new and NonEscapedShellQuoter::new both call initial_quoting, so all four shell styles
and every utility that uses them print the same text for this name:

command uutils GNU 9.7
ls on a terminal (ls/src/config.rs:544), ls --quoting-style=shell, shell-always, shell-escape, shell-escape-always "x!#;touch PWNED;#'" 'x!#;touch PWNED;#'\'''
printf %q NAME (uucore/src/lib/features/format/spec.rs:405) "x!#;touch PWNED;#'" 'x!#;touch PWNED;#'\'''
stat -c %N NAME (stat/src/stat.rs:1082) "x!#;touch PWNED;#'" 'x!#;touch PWNED;#'\'''

The same double-quoted form also appears in the ls -R directory headings ("./d'!x":) and in text built with
locale_aware_shell_escape() (quoting_style/mod.rs:321): the ==> name <== headers of head
(head/src/head.rs:503) and tail, and the file-name diagnostics of wc, fold and the checksum utilities
(wc: "nope'!x": No such file or directory). All of them go through the one initial_quoting check.

The injected command cannot contain a byte from ESCAPED_CHARS or, in the shell-escape styles, an ASCII
control character (either one selects single quotes), nor /. Space, ;, &, |, < and > are available,
which is enough to start a second file from the same directory: the name x!#;sh p;#' next to a script p
runs sh p. The apostrophe has to come after the payload (or appear in pairs before it): with a single
apostrophe before !#, as in it's!#;touch PWNED;#, bash stops at a continuation prompt and runs nothing.

The double-quote fallback dates from 2941dfd698d1 ("ls: quoting style (#1989)", 2021-04-01) and is present
in every release since 0.0.5.

Related finding: 133-r1h15-src_uu_ls_src_display.rs--003-shell-escape-c1-control-in-double-quotes concerns the
same double-quote fallback for names with an apostrophe, but for C1 control characters (U+0080–U+009F) in the
shell-escape styles; the two patches are independent and apply together.

Impact

ls uses the shell-escape style on a terminal so that a displayed name can be copied back into the shell.
Someone who can choose a file name that another user will list (an upload, an archive or repository, a shared or
world-writable directory) gets a command executed as that user once the user pastes the quoted name after rm,
cat, file or any other command at a bash prompt; if an administrator does this as root, the command runs as
root. The same applies to anything that types printf %q or stat %N output into an interactive shell. It
requires that step from the victim and a shell with history expansion on, which is bash's default for
interactive shells. Scripts, eval "$(printf %q …)" and other non-interactive uses do not perform history
expansion; there the double-quoted form reads back as the original name.

Short of a crafted payload, a name with both ' and ! does not read back as itself at a prompt: "a'!b"
gives bash: !b: event not found.

Reproducing

repro is a single Python script with no companion files. It uses the prebuilt /src/target/debug/coreutils
and GNU ls 9.7 from /usr/bin as the reference.

It runs the same scenario twice, first with GNU ls (control) and then with uutils ls:

  1. Setup. It creates a temporary directory with mode 1777, which stands in for a shared directory such as /tmp.
  2. Attacker. The user nobody creates one empty file there named x!#;touch PWNED;#'. Making that
    file is all the attacker does.
  3. Victim (root). Root runs plain ls in the directory with a pseudo-terminal as stdout, which is what makes
    ls choose shell-escape quoting. It then types rm -- <the text ls printed> into bash --norc --noprofile -i
    in the same directory. History expansion is on by default in an interactive bash. bash reads the line from a
    pipe so the script can supply it, so it prints two job-control warnings at startup. Those warnings are expected.

For each run the script prints what ls showed, the line root typed, everything bash printed, and every file left
in the directory with its owner. Expected output:

  • CONTROL GNU ls: ls shows 'x!#;touch PWNED;#'\''', rm removes the file and the directory ends up
    empty ({}).
  • OBSERVED uutils ls: ls shows "x!#;touch PWNED;#'". bash echoes the history-expanded line
    rm -- "xrm -- "x;touch PWNED;#'", and rm fails on xrm -- x. The directory then holds
    {'PWNED': 'root', "x!#;touch PWNED;#'": 'nobody'}, so the attacker's touch PWNED ran as root and the
    file is still there.

The last line is BUG only if the GNU directory is empty, which shows that pasting a correctly quoted name
removes the file and runs nothing else, and the uutils directory contains a PWNED owned by root. Otherwise it is
OK. The temporary directories are removed when the script finishes.

The pipe is only a convenience of the script. Typing the same line at a bash -i prompt on a pseudo-terminal
(readline, histexpand at its default) gives the same result: bash echoes the expanded line and the embedded
command runs as the user at the prompt.

Suggested fix

The attached patch adds ! to ESCAPED_CHARS, so a name that contains it always gets single quotes:

--- a/src/uucore/src/lib/features/quoting_style/shell_quoter.rs
+++ b/src/uucore/src/lib/features/quoting_style/shell_quoter.rs
@@ -250 +250 @@ fn shell_escaped_char_set(is_dirname: bool) -> &'static [u8] {
-    const ESCAPED_CHARS: &[u8] = b":\"`$\\^\n\t\r=";
+    const ESCAPED_CHARS: &[u8] = b":\"`$\\^\n\t\r=!";

! has to go at the end of the list: shell_escaped_char_set drops the first byte (:) for non-directory
names by slicing.

Membership in this set also sets must_quote, which ! already does through ForceQuote, so output changes
only for names that contain both ' and !, and it becomes what GNU prints. Since the quote character is
chosen in this one place, the change covers all four shell styles and every caller: ls, printf %q and
stat -c %N print 'x!#;touch PWNED;#'\''', and the reproducer prints OK on a rebuilt clean checkout.
a'!b becomes 'a'\''!b', while can't ("can't") and a!b ('a!b') are printed as before; all match
GNU 9.7.

Besides the one-byte change, the patch adds tests: a'!b assertions for initial_quoting, a can'!t case
in test_special_chars_in_double_quotes (quoting_style/mod.rs), and one integration test each in
tests/by-util/test_ls.rs and tests/by-util/test_printf.rs.

GNU also keeps single quotes when any of # & ( ) * ; < > ? [ { | } ~ appears next to the apostrophe
("a';b" here, 'a'\'';b' in GNU). Those characters are inert between double quotes, so that is a
compatibility difference only. #, ~, { and } do not force quoting when they appear alone inside a name
(a#b is printed bare by both), so they cannot be added to the same set without changing that.

Diagnostics built with quote() from the third-party os_display crate make the same choice
(rm: cannot remove "nope'!x": No such file or directory, where GNU prints 'nope'\''!x'). That decision is
made in the external crate, whose DOUBLE_UNSAFE list also lacks !, and is not changed by this patch; it
needs a separate fix in os_display.

Running the reproducer

ANT-2026-WV2L2BWK/repro, next to this file. It was written by an automated agent and our check ran it as root in a privileged container: run it INSIDE A THROWAWAY VM, with the image oss-scan-7bc1eb6d1d6f built by bash build/build-image.sh, from the archive's top directory:

docker run --rm --network=none --privileged -u 0 -w /src -v "$PWD/ANT-2026-WV2L2BWK/repro:/tmp/repro:ro" oss-scan-7bc1eb6d1d6f /tmp/repro

It prints its evidence; the last line is BUG if the issue triggered, OK otherwise.

Lingua principale
Rust
Stelle
24.3k
Fork
2.1k
Merge medio
2g 37m
PR unite (30g)
336

Preparare l'ambiente

Apri in Codespaces

Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di uutils/coreutils

Tutte le issue di uutils/coreutils

Issue simili

Altre issue su Rust

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.