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

df: prints raw control characters in file and mount names, unlike GNU

Aperta Adatta ai principianti
#15,817 1 commento 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@aj-codespy ci sta già lavorando.

Dal 25/10/2026.

Valutazione

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

Direzione di ricerca

Inizia in src/uu/df/src/table.rs, da Cell::from_string e Cell::from_os_string, poi segui RowFormatter::get_cells e Table::write_to per confermare le quattro colonne interessate. Controlla i test esistenti della tabella, incluso test_table_column_width_non_unicode, e aggiungi test per i caratteri di controllo nelle celle con stringhe e stringhe del sistema operativo. Il lavoro è completato quando i caratteri di controllo ASCII vengono stampati come ? senza compromettere la disposizione delle righe né modificare la gestione dei nomi non Unicode.

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

Descrizione

good first issue I - Incorrect oss-scanner U - df
============================================================================
ANT-2026-YCISLQSR  [NOT SECURITY: ordinary bug]
df: prints raw control characters in file and mount names, unlike GNU
Location: src/uu/df/src/table.rs:269  in Cell::from_os_string()
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.

uutils df prints file names and mount names exactly as it receives them, including control characters, where GNU df 9.7 prints ? for each one. As a result, df --output=file,... on a file whose name contains newlines prints several rows for that one operand. A plain df on a terminal also passes escape sequences in a mount point, mount source or file system type straight to the terminal, which can retitle the window, clear the screen or move the cursor.

The cause is in Cell::from_string and Cell::from_os_string in src/uu/df/src/table.rs, which build the File, Mounted on, Filesystem and Type cells by copying the bytes of the command-line operand or the /proc/self/mountinfo field unchanged. Nothing between those constructors and Table::write_to() inspects the content. The attached reproducer shows both cases:

  • A file named a\ndatabase.img 100%\nb gives three rows under uutils and one row (a?database.img 100%?b 10%) under GNU.
  • A tmpfs that root mounted with an OSC "set window title" sequence in both its source and its mount point sends the raw ESC/BEL bytes to the terminal twice, where GNU prints ?.

The File case exists since ab717ce37024 ("df: implement the File column", release 0.0.13). The mount-name columns have been copied verbatim since the first df implementation.

A patch is attached that replaces ASCII control characters (0x00–0x1F, 0x7F) with ? when a cell is built. On a pipe or in the C locale, this makes the output byte-identical to GNU. It does not yet match GNU's extra replacement of C1 controls and invalid UTF-8 on a terminal under a UTF-8 locale.

This is an ordinary bug, not a security issue: df prints control characters in names where GNU df prints ?, which
gives wrong output but no access or privilege to anyone, and the terminal case was only shown with root naming the mount.

Details

df copies the text of its File, Mounted on, Filesystem and Type columns into the table byte for byte, so a
control character in a file name or in a mount table entry is written to stdout as is. With one file whose name is
a\ndatabase.img 100%\nb, one operand produces three rows:

$ df --output=file,pcent -- *          # uutils: 1 operand, 3 rows
File                  Use%
a
database.img 100%
b  10%
$ /usr/bin/df --output=file,pcent -- * # GNU 9.7
File                  Use%
a?database.img 100%?b  10%

The middle line is written entirely by whoever named the file. The same holds for names that come from the mount
table: with a tmpfs mounted from source src\033]0;owned\007 on …/stick\033]0;owned\007, a plain df on a terminal
writes the OSC "set window title" sequence twice, once in Filesystem and once in Mounted on. GNU df prints
src?]0;owned? and stick?]0;owned?.

All four text columns are built by two constructors that copy their input:

// src/uu/df/src/table.rs:260
    fn from_string<T: AsRef<str>>(s: T) -> Self {
        let s = s.as_ref();
        Self {
            bytes: s.as_bytes().into(),
            width: UnicodeWidthStr::width(s),
        }
    }

    /// Create a cell from an `OsString`
    fn from_os_string(os: &OsString) -> Self {
        Self {
            bytes: uucore::os_str_as_bytes(os).unwrap().to_vec(),
            width: UnicodeWidthStr::width(os.to_string_lossy().as_ref()),
        }
    }

RowFormatter::get_cells() calls them for Column::Source (fs_device, table.rs:359), Column::Target
(fs_mount, :371), Column::File (:378-382) and Column::Fstype (:384). Table::write_to() then does
writer.write_all(&elem.bytes) (:548, :557) into a BufWriter around stdout (df.rs:508). Nothing between the
constructor and the write looks at the content, and nothing depends on whether stdout is a terminal.

Where the bytes come from:

  • File is the operand as typed. Filesystem::from_path() keeps it
    (let file = path.as_ref().as_os_str().to_owned();, src/uu/df/src/filesystem.rs:142) and
    Row::from_filesystem() passes it on (file: fs.file, table.rs:165). When the caller uses a glob, find or
    xargs -0, the operand is a directory entry name, which may contain any byte except / and NUL.
  • Filesystem, Type and Mounted on are fields of /proc/self/mountinfo, taken over by MountInfo::new()
    (src/uucore/src/lib/features/fsext/mod.rs:205-211) as dev_name, fs_type and mount_dir. On the test kernel
    (6.1) the mountinfo line for the mount above contains ESC and BEL unescaped; a CR in a mount source is also passed
    through, and a tab is written as \011, which replace_special_chars() (mod.rs:172-184) turns back into a real tab
    for the mount point.

GNU df 9.7 prints ? for every ASCII control character (newline, tab, CR, ESC, BEL and DEL were checked) in these
columns, on a pipe and on a terminal alike.

The File half was introduced in ab717ce37024 ("df: implement the File column", 2022-03-21), first in release
0.0.13; the mount-name columns have been copied unchanged since the first df implementation.

Impact

The effect is wrong text in df's output, in two situations. Both need a name with a control character in it, which
is uncommon, so most users never meet this.

With --output including the File column (or bare --output, which includes it), an operand whose name contains a
newline gives several rows instead of one. Plain df FILE never prints the operand, and error messages for bad operands
are already shell-quoted, so only --output is affected. A script that matches rows to operands then reads rows for
names that do not exist, and the figures of the real file system are attached to the last fragment of the name; GNU df
keeps one row per operand for the same input. This mostly matters when df runs over directory entries it did not
name itself (a glob, find or xargs -0 over a shared directory). Splitting inside a row is not new: a name with a
space, such as x 100%, gives the identical row x 100% 10% under GNU and uutils. What uutils adds is extra lines.

When df prints to a terminal and a mount point, mount source or file system type contains escape sequences, the
terminal emulator interprets them: they can retitle the window, clear the screen, or move the cursor and overwrite
other rows. Such names are unusual; the reproducer makes one with root's mount(8). Mount helpers that let a user
choose the mount point or source string (FUSE with allow_other, removable media mounted under their volume label) have
not been tried. A FUSE mount made with user_id of another user reports 0 blocks to root and is listed, with the raw
name, by df -a but not by plain df.

No file is touched and nothing crashes; only the text df writes is affected.

Reproducing

repro is a single Python script with no companion files. It runs as root, uses the prebuilt
/src/target/debug/coreutils df, and runs GNU df 9.7 (/usr/bin/df) on the same input as the control. Both are run
with LC_ALL=C. All its work happens in a fresh mkdtemp directory under /tmp. It mounts a 1 MiB tmpfs there and
then unmounts it and removes the directory. It takes about 0.1 s and gives the same result every run.

The two attacker inputs are each one line, so they are built inline and labelled attack 1 and attack 2:

  1. File name (attacker: nobody). As nobody (gid nogroup, no supplementary groups), the script touches one
    file in a directory that nobody owns. The file is named a\ndatabase.img 100%\nb. Then, as root, it does what
    df --output=file,pcent -- * would do in that directory, with stdout on a pipe: it passes the directory's entries
    as operands. Output lines:
    • CONTROL GNU df : 1 operand, 1 row(s): ['a?database.img 100%?b 10%']
    • OBSERVED uutils df : 1 operand, 3 row(s): ['a', 'database.img 100%', 'b 10%']
  2. Mount name (attacker: whoever names a mount). No unprivileged mount helper is available, so root's mount(8)
    stands in for one. It mounts a tmpfs from source src + ESC ]0;owned BEL onto <tmpdir>/stick +
    ESC ]0;owned BEL, where the appended sequence is OSC 0, "set window title". Then a plain df runs with stdout
    on a pseudo-terminal, and the script prints the bytes the terminal received on the row for that mount:
    • CONTROL GNU df on a terminal: [b'src?]0;owned? … /tmp/df-control-chars-…/stick?]0;owned?']
    • OBSERVED uutils df on a terminal: [b'src\x1b]0;owned\x07 … /tmp/df-control-chars-…/stick\x1b]0;owned\x07']

The last line is BUG only when all three of these hold:

  • uutils prints more rows than there are operands, and the attacker's database.img 100% is one of those rows.
  • The terminal receives the exact escape sequence twice from uutils: once from Filesystem, once from Mounted on.
  • GNU, on the same input, prints one row per operand and sends no ESC byte.

If any of these fails, the last line is OK. A failure in any setup step (touch, mount, either df exiting
non-zero) stops the script with its own traceback.

Suggested fix

The attached patch replaces control characters with ? where a text cell is built, so that all four columns are
covered regardless of where the name came from. In src/uu/df/src/table.rs, Cell::from_string and
Cell::from_os_string both go through a new Cell::from_bytes(&[u8]), which maps every byte for which
u8::is_ascii_control holds (0x00-0x1F, 0x7F) to ? and computes the width from the result:

--- a/src/uu/df/src/table.rs
+++ b/src/uu/df/src/table.rs
@@ -258,18 +258,28 @@ impl Cell {
     /// Create a cell from an unknown origin string that may contain
     /// wide characters.
     fn from_string<T: AsRef<str>>(s: T) -> Self {
-        let s = s.as_ref();
-        Self {
-            bytes: s.as_bytes().into(),
-            width: UnicodeWidthStr::width(s),
-        }
+        Self::from_bytes(s.as_ref().as_bytes())
     }
 
     /// Create a cell from an `OsString`
     fn from_os_string(os: &OsString) -> Self {
+        Self::from_bytes(uucore::os_str_as_bytes(os).unwrap())
+    }
+
+    /// Create a cell from the bytes of a name (file, device, mount point or
+    /// fs type). ASCII control characters are replaced with `?`, like GNU df
+    /// does, so that a name can neither break the table layout (e.g. with a
+    /// newline) nor send escape sequences to the terminal. These bytes never
+    /// occur inside a multi-byte UTF-8 sequence, so non-Unicode names are
+    /// otherwise kept as they are.
+    fn from_bytes(name: &[u8]) -> Self {
+        let bytes: Vec<u8> = name
+            .iter()
+            .map(|&b| if b.is_ascii_control() { b'?' } else { b })
+            .collect();
         Self {
-            bytes: uucore::os_str_as_bytes(os).unwrap().to_vec(),
-            width: UnicodeWidthStr::width(os.to_string_lossy().as_ref()),
+            width: UnicodeWidthStr::width(String::from_utf8_lossy(&bytes).as_ref()),
+            bytes,
         }
     }
 }

The bytes 0x00-0x1F and 0x7F never occur inside a multi-byte UTF-8 sequence, so the replacement can be done on raw
bytes and non-UTF-8 names are otherwise kept as they are today (test_table_column_width_non_unicode relies on that).
The patch also adds a unit test in table.rs (test_table_control_chars_are_replaced, covering all four columns) and
an integration test in tests/by-util/test_df.rs (test_file_column_replaces_control_chars) that touches a file
named a\nb 100%\n\x1b[2Jc and expects df --output=file on it to print File\na?b 100%??[2Jc\n. Applied to a clean
checkout and rebuilt, the reproducer prints OK and the rows are identical to GNU's in both parts, and the uu_df unit
tests and the test_df:: integration tests pass.

On a pipe, and in the C locale, the output is then byte-identical to GNU df 9.7, which leaves C1 controls
(U+0080-U+009F, e.g. U+009B CSI) and bytes that are not valid UTF-8 unchanged there. On a terminal under
en_US.UTF-8, GNU df 9.7 also prints ? for both; the patch does not, so those still reach a terminal. Matching GNU
there is a possible follow-up: when stdout is a terminal, also replace C1 controls and invalid byte sequences. Doing
that unconditionally (char::is_control over the valid UTF-8 chunks) would make piped output differ from GNU for valid
names containing U+0080-U+009F.

Running the reproducer

bugs-not-security/ANT-2026-YCISLQSR/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/bugs-not-security/ANT-2026-YCISLQSR/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.