df: prints raw control characters in file and mount names, unlike GNU
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
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
============================================================================
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%\nbgives 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:
Fileis 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,findor
xargs -0, the operand is a directory entry name, which may contain any byte except/and NUL.Filesystem,TypeandMounted onare fields of/proc/self/mountinfo, taken over byMountInfo::new()
(src/uucore/src/lib/features/fsext/mod.rs:205-211) asdev_name,fs_typeandmount_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, whichreplace_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:
- File name (attacker:
nobody). Asnobody(gidnogroup, no supplementary groups), the script touches one
file in a directory thatnobodyowns. The file is nameda\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%']
- 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 sourcesrc+ESC ]0;owned BELonto<tmpdir>/stick+
ESC ]0;owned BEL, where the appended sequence is OSC 0, "set window title". Then a plaindfruns 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 fromMounted 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
Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di uutils/coreutils
-
split: -b and -l ignore short writes, exit 0 with a truncated last chunkForse già presa @aj-codespy l’ha presa oggi. Apertagood first issue I - Incorrect oss-scanner U - split
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
uutils/coreutils#15820 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
good first issue I - Incorrect oss-scanner U - groups U - pinky
Difficoltà 2/5 1-3 ore Idoneità per principianti 83/100
I maintainer di solito rispondono entro 1 giorno
-
good first issue I - Incorrect oss-scanner U - All (uucore) U - ls
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
I maintainer di solito rispondono entro 1 giorno
-
good first issue I - Incorrect oss-scanner U - All (uucore) U - rm
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
I maintainer di solito rispondono entro 1 giorno
-
good first issue I - Incorrect oss-scanner U - All (uucore) U - ls
Difficoltà 2/5 1-3 ore Idoneità per principianti 79/100
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di uutils/coreutils
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 73/100
I maintainer di solito rispondono entro 1 giorno
-
C-bug needs-triage O-UEFI T-libs
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 1 giorno
-
bug priority: P3 ready-for-agent
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
shep-pm/shep-kelpie#1332 ·
I maintainer di solito rispondono entro 1 giorno
-
enhancement
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
ramensoftware/windhawk#1156 · 2 commenti ·
-
area/cli complexity:trivial good first issue Stellar Wave test
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
Externalize-Labs/externalize#23 ·
I maintainer di solito rispondono entro 1 giorno