Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

quoting_style: ! in double quotes allows command injection when pasted

オープン 初心者向け
#15,812 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@fixfon がすでに取り組んでいます。

2026年10月26日 から。

  • #15879 @fixfon による — オープン

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
76/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
bash, rust
領域
cli, security

調査の方向性

まず src/uucore/src/lib/features/quoting_style/shell_quoter.rs、特に initial_quoting() と shell_escaped_char_set() を確認し、続いて quoting_style/mod.rs の quoting テストを読みます。提供された再現手順を使って貼り付けた出力を確認し、' と ! の両方を含む名前を対象とした回帰テストを追加します。出力を対話型 bash プロンプトに安全に貼り付けられ、テストが通れば完了です。

索引モデルが issue の本文から書いたものです。

説明

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.

主要言語
Rust
スター
24.3k
フォーク
2.1k
平均マージ
1日 23時間
マージ済み PR(30日)
337

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

uutils/coreutils のほかの issue

uutils/coreutils の issue をすべて見る

似ている issue

Rust の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。