quoting_style: ! in double quotes allows command injection when pasted
メンテナーはふだん 1 日以内に返信
評価
- 難易度
- 2/5
- 見積もり時間
- 1〜3時間
- 初心者へのやさしさ
- 76/100
調査の方向性
まず src/uucore/src/lib/features/quoting_style/shell_quoter.rs、特に initial_quoting() と shell_escaped_char_set() を確認し、続いて quoting_style/mod.rs の quoting テストを読みます。提供された再現手順を使って貼り付けた出力を確認し、' と ! の両方を含む名前を対象とした回帰テストを追加します。出力を対話型 bash プロンプトに安全に貼り付けられ、テストが通れば完了です。
索引モデルが issue の本文から書いたものです。
説明
============================================================================
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:
- Setup. It creates a temporary directory with mode
1777, which stands in for a shared directory such as/tmp. - Attacker. The user
nobodycreates one empty file there namedx!#;touch PWNED;#'. Making that
file is all the attacker does. - Victim (root). Root runs plain
lsin the directory with a pseudo-terminal as stdout, which is what makes
lschoose shell-escape quoting. It then typesrm -- <the text ls printed>intobash --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:lsshows'x!#;touch PWNED;#'\''',rmremoves the file and the directory ends up
empty ({}).OBSERVED uutils ls:lsshows"x!#;touch PWNED;#'". bash echoes the history-expanded line
rm -- "xrm -- "x;touch PWNED;#'", andrmfails onxrm -- x. The directory then holds
{'PWNED': 'root', "x!#;touch PWNED;#'": 'nobody'}, so the attacker'stouch PWNEDran 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.
- 主要言語
- Rust
- スター
- 24.3k
- フォーク
- 2.1k
- 平均マージ
- 1日 23時間
- マージ済み PR(30日)
- 337
環境構築
このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
uutils/coreutils のほかの issue
-
split: -b and -l ignore short writes, exit 0 with a truncated last chunk対応中かも @aj-codespy が 1 日前に担当しました。 オープンgood first issue I - Incorrect oss-scanner U - split
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
uutils/coreutils#15820 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
df: prints raw control characters in file and mount names, unlike GNU対応中かも @aj-codespy が 1 日前に担当しました。 オープンgood first issue I - Incorrect oss-scanner U - df
難易度 2/5 1〜3時間 初心者へのやさしさ 86/100
uutils/coreutils#15817 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
groups, pinky: unknown numeric user name looked up by uid, unlike GNU対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープンgood first issue I - Incorrect oss-scanner U - groups U - pinky
難易度 2/5 1〜3時間 初心者へのやさしさ 83/100
メンテナーはふだん 1 日以内に返信
-
rm: --preserve-root error prints path unescaped (11 other utils too)対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープンgood first issue I - Incorrect oss-scanner U - All (uucore) U - rm
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
メンテナーはふだん 1 日以内に返信
-
quoting_style: shell-escape misquotes C1 controls in names with an apostrophe対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープンgood first issue I - Incorrect oss-scanner U - All (uucore) U - ls
難易度 2/5 1〜3時間 初心者へのやさしさ 79/100
メンテナーはふだん 1 日以内に返信
uutils/coreutils の issue をすべて見る
似ている issue
-
nn::linear: weight initialization depends on `bias` because the bias is created before the weight対応中かも @monfortema が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
LaurentMazare/tch-rs#1067 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
FavioVazquez/mollify#62 ·
-
C-enhancement
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
obi1kenobi/cargo-semver-checks#1785 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 61/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
fendouai/awesome-deepseek-harness#125 ·
メンテナーはふだん 4 日以内に返信