quoting_style: shell-escape misquotes C1 controls in names with an apostrophe
Los mantenedores suelen responder en 1 día
Evaluación
- Dificultad
- 2/5
- Tiempo estimado
- 1-3 horas
- Aptitud para principiantes
- 79/100
Línea de trabajo
Empieza por src/uucore/src/lib/features/quoting_style/shell_quoter.rs, especialmente por EscapedShellQuoter::new() y su comprobación de codificación; compáralo con push_char() y el tratamiento de controles C1 en escaped_char.rs. Añade o ejecuta una prueba de regresión para un apóstrofo junto con un control C1 UTF-8 en un nombre de archivo, comprobando que la salida escapada para el shell permite recuperar el nombre original. El objetivo se cumple cuando la palabra entrecomillada se lee de nuevo como el nombre original y los casos existentes de controles ASCII y locale C siguen funcionando.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
============================================================================
ANT-2026-W58FOB4J [NOT SECURITY: ordinary bug]
quoting_style: shell-escape misquotes C1 controls in names with an apostrophe
Location: src/uucore/src/lib/features/quoting_style/shell_quoter.rs:139 in EscapedShellQuoter::new()
Commit: 7bc1eb6d1d6fa2e1dc2c9d2277cf576c05f61e47
============================================================================
Not a security issue. This is a real, reproducible bug that is NOT a security issue: by your project's own policy nobody gains anything from it that they did not already have. It is not part of the vulnerability report, is in none of its counts, needs no embargo and no advisory, and can be filed and fixed in the open like any bug.
In a UTF-8 locale, uutils quotes file names that contain both an apostrophe and a C1 control character (U+0080–U+009F) incorrectly in the shell-escape styles. The printed word looks shell-quoted, but a shell reads it back as a different name. For the name it's<U+009B>here, printf %q prints "it's'$'\302\233''here", which bash reads as the 21 literal bytes it's'$'\302\233''here. GNU coreutils 9.7 prints 'it'\''s'$'\302\233''here', which round-trips. The same wrong word comes from ls --quoting-style=shell-escape[-always], plain ls on a terminal, and stat -c %N.
The cause is in EscapedShellQuoter::new() in src/uucore/src/lib/features/quoting_style/shell_quoter.rs. A name with an apostrophe is wrapped in double quotes unless it will need a $'…' segment. That check only catches ASCII control bytes (in initial_quoting()) and invalid UTF-8 (the from_utf8(..).is_err() guard at line 139). However, push_char() also writes every char::is_control() character as a $'\ooo' segment via EscapedChar::new_shell(), and that includes the valid two-byte C1 characters. The segment then lands inside double quotes, where it is ordinary text. This is the remaining case of the same guard whose ASCII-control and invalid-byte siblings were fixed in a9136fb86 and 098c61b62. It was introduced by 2331600f4c72 ("quoting_style: fix multi-byte control characters", 2024-11-22) and is in every release since 0.0.29.
The word never splits and never runs anything, and LC_ALL=C output is correct. bash's builtin printf is also unaffected. The problem appears when a script or user feeds the external printf/ls/stat output back to a shell: the command silently applies to the other name instead of the file. The attached reproducer shows this with a find … -exec printf 'rm -f -- %q\n' {} + | bash cleanup script. It removes everything with GNU printf, but with uutils printf it leaves the affected file behind and still exits 0.
This is an ordinary bug, not a security issue: the quoted word is wrong but never splits or runs a command, and it
only does harm when a script feeds the output of the external coreutils printf/ls/stat back to a shell (bash's
own builtin printf is not affected), and then only for file names that rarely occur.
Details
In a UTF-8 locale the shell-escape quoting style produces a word that a shell does not read back as the original
name, when the name contains an apostrophe and a C1 control character (U+0080–U+009F). For the 10-byte name
it's<U+009B>here (bytes 69 74 27 73 c2 9b 68 65 72 65):
$ LC_ALL=C.UTF-8 coreutils printf %q "$name"
"it's'$'\302\233''here"
$ LC_ALL=C.UTF-8 /usr/bin/printf %q "$name" # GNU coreutils 9.7
'it'\''s'$'\302\233''here'
The uutils word is one double-quoted string. Inside double quotes ', $' and \3 are ordinary text, so bash reads
it as the 21 bytes it's'$'\302\233''here, not as the name. ls --quoting-style=shell-escape,
ls --quoting-style=shell-escape-always, plain ls on a terminal and stat -c %N print the same word for a file
with that name.
The outer quotes are chosen once, before the name is walked, in EscapedShellQuoter::new():
// src/uucore/src/lib/features/quoting_style/shell_quoter.rs:134
let (mut quotes, must_quote) = initial_quoting(reference, dirname, always_quote, true);
// Dollar-quoted byte escapes cannot be nested in double quotes.
if quotes == ShellQuotes::Double {
let invalid = match encoding {
UEncoding::Ascii => !reference.is_ascii(),
UEncoding::Utf8 => std::str::from_utf8(reference).is_err(),
};
if invalid {
quotes = ShellQuotes::Single;
}
}
initial_quoting() (shell_quoter.rs:221) returns Double for a name with an apostrophe, unless a byte of the name
is in "`$\^\n\t\r= or is an ASCII control character:
// 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())
});
The block at line 136 then falls back to single quotes when the name has bytes that are invalid in the locale's
encoding, because push_invalid() writes those as a $'\ooo' segment. The comment states the invariant: a $'…'
segment must never end up inside double quotes. One producer of such segments is not covered. Every valid character
goes through push_char(), which asks EscapedChar::new_shell() how to write it:
// src/uucore/src/lib/features/quoting_style/escaped_char.rs:158
_ if c.is_control() => Octal(EscapeOctal::from_char(c)),
char::is_control() is true for U+0080–U+009F as well as for the ASCII controls. These characters are valid
two-byte UTF-8 and are not ASCII, so neither c.is_ascii_control() at line 228 nor from_utf8(reference).is_err() at
line 139 sees them, and quotes stays Double. push_char() then takes its last arm (shell_quoter.rs:190):
enter_dollar() appends '$', the character is written as \302\233, the next ordinary character makes
exit_dollar() append '', and finalize_shell_quoter() (shell_quoter.rs:258) wraps everything in ".
Two neighbouring inputs take the correct path. With an ASCII control character in the same place (it's<ESC>here),
initial_quoting() returns Single and the word is 'it'\''s'$'\033''here'. With LC_ALL=C, the Ascii arm at
line 138 switches to single quotes and the word for the C1 name is the same as GNU's.
The affected names are therefore: valid UTF-8, at least one ', at least one character in U+0080–U+009F, and none of
" ` $ \ ^ =, tab, newline, CR or an ASCII control character. Entry points are printf %q
(src/uucore/src/lib/features/format/spec.rs:405), ls with --quoting-style=shell-escape[-always] or with a
terminal on stdout (src/uu/ls/src/config.rs:545), and stat %N (src/uu/stat/src/stat.rs:1082).
The bug was introduced in 2331600f4c72 ("quoting_style: fix multi-byte control characters", 2024-11-22), which
added the c.is_control() octal arm in EscapedChar::new_shell(); before it, C1 characters were written raw and the
word round-tripped. It is in every release since 0.0.29.
Impact
For a name with an apostrophe and a character in U+0080–U+009F, in a UTF-8 locale, printf %q, ls in the
shell-escape styles (including plain ls on a terminal) and stat -c %N print a word that looks shell-quoted but
does not read back as the name. Pasting that word from ls into a command line, or handing the quoted words to a
shell (eval, | bash, sh -c, ssh), gives the command it's'$'\302\233''here instead of the file. The file is
skipped; with rm -f, or any command that tolerates a missing operand, nothing is reported and the exit status is 0.
If an entry with that other name exists in the same directory, the command acts on it instead, and if that entry is
a symlink, on its target: find dir -type f -exec printf 'chmod 644 -- %q\n' {} + | bash then changes the mode of a
file that the -type f test never looked at.
Users will rarely meet this. C1 control characters almost never occur in real file names, and inside a bash script
printf %q is bash's builtin, which is not affected. Only the external coreutils printf (find -exec printf,
xargs printf, env printf, /usr/bin/printf) and ls --quoting-style=shell-escape / stat -c %N output read
back by a shell are exposed.
This is a skipped-file / wrong-name problem, not command injection. The word stays a single balanced double-quoted
word: every character that is active inside double quotes (", `, $, \) forces single quotes in
initial_quoting(), and the inserted text is only ', $', \ followed by octal digits. In a round trip of 4000
random names built from quotes, shell metacharacters, C0/C1 controls and invalid bytes, bash read 4000 words back;
105 of them differed from the name, all with an apostrophe and a C1 character. No raw control character reaches the
terminal either. It needs a UTF-8 locale; in the C locale the output is correct.
Related findings: a separate report sent alongside this one covers the same double-quote fallback for names with an
apostrophe, where the missing check is ! in the set initial_quoting() tests (all four shell styles, any locale,
bash history expansion). The two fixes are independent; their patches do not change each other's output and apply
together.
Reproducing
repro is a standard-library Python script. It runs the prebuilt /src/target/debug/coreutils under
LC_ALL=C.UTF-8; the bug needs a UTF-8 locale. The image's GNU coreutils 9.7 in /usr/bin is the control. The
script gives the multicall binary the name printf through a symlink in a temporary directory, so it runs as that
utility. GNU and uutils are then both called as a printf binary, in exactly the same way.
The parts:
- Name: the file name
it's\u009bhere(bytes69 74 27 73 c2 9b 68 65 72 65, an apostrophe and the C1
control U+009B). It is the constantATTACKER_NAMEat the top ofrepro. - Script:
repro.d/purge_uploads.shis an ordinary cleanup script. It empties a directory by writing one
rm -f -- <word>line per file withfind … -exec printf 'rm -f -- %q\n' {} +and piping those lines tobash.
findruns whicheverprintfcomes first inPATH. - Setup: everything else in
repro. That is the symlink, and one fresh temporary upload directory per run that
holds a file with that name and an ordinaryreport.pdf.
For each printf, GNU first and then uutils, the script runs two steps:
- It runs
printf %q <name>and hands the word tobash -c "printf %s <word>", to show what a shell reads back. - It runs
purge_uploads.shon a new upload directory, with theprintfunder test first inPATH, and lists what
is left in the directory.
The GNU lines are labelled CONTROL. If GNU's word does not read back as the name, or if the GNU purge leaves
anything behind, the script stops with control failed. That failure comes from the setup and is not the bug. The
uutils lines are labelled OBSERVED. Expected output (about 0.1 s):
name: b"it's\xc2\x9bhere"
CONTROL GNU printf %q: 'it'\''s'$'\302\233''here'
CONTROL bash reads it as: b"it's\xc2\x9bhere"
CONTROL left after purge: []
OBSERVED uutils printf %q: "it's'$'\302\233''here"
OBSERVED bash reads it as: b"it's'$'\\302\\233''here"
OBSERVED left after purge: [b"it's\xc2\x9bhere"]
BUG
The last line is BUG when bash reads the uutils word back as a different name and the purge with uutils printf,
which exits 0, leaves exactly the file with that name. report.pdf is removed, so the script does work for an ordinary
name. If the same script runs with LC_ALL=C, uutils prints GNU's word and the verdict is OK.
Suggested fix
The attached patch extends the guard in EscapedShellQuoter::new() (shell_quoter.rs:136) to cover everything that
push_char() writes as a $'…' segment, not only invalid bytes. In the Utf8 arm, a valid name that contains any
char::is_control() character now also switches the outer quotes to single quotes:
UEncoding::Utf8 => std::str::from_utf8(reference)
.map_or(true, |name| name.chars().any(char::is_control)),
The Ascii arm already covers these bytes, since they are not ASCII. The patch also renames the flag from invalid
to needs_dollar and adds a comment explaining why non-ASCII control characters are handled here and not in
initial_quoting(). A unit test next to the existing ones in quoting_style/mod.rs,
test_non_ascii_control_chars_with_single_quote, pins it's\u{9B}here to GNU's word
'it'\''s'$'\302\233''here' for shell-escape and shell-escape-always.
With the patch applied to a clean checkout and rebuilt, the reproducer prints OK. printf %q,
ls --quoting-style=shell-escape[-always] and stat %N print GNU's word for the affected names in both C.UTF-8
and C, and random names round-trip through bash. Names without an apostrophe, or with an ASCII control character,
never reach the changed branch, so their output is unchanged.
Running the reproducer
bugs-not-security/ANT-2026-W58FOB4J/repro, next to this file, with its companion files in bugs-not-security/ANT-2026-W58FOB4J/repro.d/ (purge_uploads.sh) — the script reads them from /tmp/repro.d/. 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/bugs-not-security/ANT-2026-W58FOB4J/repro:/tmp/repro:ro" -v "$PWD/bugs-not-security/ANT-2026-W58FOB4J/repro.d:/tmp/repro.d:ro" oss-scan-7bc1eb6d1d6f /tmp/repro
It prints its evidence; the last line is BUG if the issue triggered, OK otherwise.
Related: #9638
- Lenguaje dominante
- Rust
- Estrellas
- 24.3k
- Forks
- 2.1k
- Merge medio
- 1 d 20 h
- PR fusionados (30 d)
- 347
Preparar el entorno
Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de uutils/coreutils
-
chmod -v: missing 'could not be accessed' line for operands that can't be stat'edPosiblemente ocupada @emirmrkaljevic la tomó hace 1 día. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día
-
chown, chgrp: 'cannot dereference' instead of 'cannot access' for a missing operandPosiblemente ocupada @emirmrkaljevic la tomó hace 1 día. AbiertoU - chgrp U - chown
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
Los mantenedores suelen responder en 1 día
-
split: -b and -l ignore short writes, exit 0 with a truncated last chunkPosiblemente ocupada @binhvq la tomó hace 1 día. Abiertogood first issue I - Incorrect oss-scanner U - split
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
uutils/coreutils#15820 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
df: prints raw control characters in file and mount names, unlike GNUPosiblemente ocupada @aj-codespy la tomó hace 2 días. Abiertogood first issue I - Incorrect oss-scanner U - df
Dificultad 2/5 1-3 horas Aptitud para principiantes 86/100
uutils/coreutils#15817 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
quoting_style: ! in double quotes allows command injection when pastedPosiblemente ocupada @fixfon la tomó hace 1 día. Abiertogood first issue I - Incorrect oss-scanner U - All (uucore) U - ls
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
Los mantenedores suelen responder en 1 día
Todos los issues de uutils/coreutils
Issues similares
-
QuartoHub: have the Document name before the filename in the browser tabPosiblemente ocupada @cscheid la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
quarto-dev/q2#930 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
muse P3
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
HerringtonDarkholme/hd-lang#923 · 1 comentario ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
longbridge/gpui-kit#3712 ·
Los mantenedores suelen responder en 1 día
-
documentation enhancement SIEM
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
ADORSYS-GIS/wazuh-agent-status#265 ·
Los mantenedores suelen responder en 2 días