df: prints raw control characters in file and mount names, unlike GNU
Maintainer thường phản hồi trong vòng 1 ngày
@aj-codespy đang làm issue này rồi.
Từ ngày 25/10/2026.
Đánh giá
- Độ khó
- 2/5
- Thời gian dự kiến
- 1-3 giờ
- Mức phù hợp với người mới
- 86/100
Hướng nghiên cứu
Bắt đầu tại src/uu/df/src/table.rs ở Cell::from_string và Cell::from_os_string, sau đó theo dõi RowFormatter::get_cells và Table::write_to để xác nhận bốn cột bị ảnh hưởng. Kiểm tra các bài kiểm thử bảng hiện có, bao gồm test_table_column_width_non_unicode, và bổ sung kiểm thử cho ký tự điều khiển trong các ô chuỗi và chuỗi của hệ điều hành. Hoàn tất khi các ký tự điều khiển ASCII được in thành ? mà không làm hỏng bố cục hàng hoặc thay đổi cách xử lý tên không phải Unicode.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
============================================================================
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.
- Ngôn ngữ chính
- Rust
- Star
- 24.3k
- Fork
- 2.1k
- Merge trung bình
- 2 ngày 37 phút
- Pull request đã merge (30 ngày)
- 336
Chuẩn bị môi trường
Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.
- Không có Dockerfile hay tệp Docker Compose
- Không có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của uutils/coreutils
-
split: -b and -l ignore short writes, exit 0 with a truncated last chunkCó thể đã có người làm @aj-codespy đã nhận hôm nay. Đang mởgood first issue I - Incorrect oss-scanner U - split
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
uutils/coreutils#15820 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
good first issue I - Incorrect oss-scanner U - groups U - pinky
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 83/100
Maintainer thường phản hồi trong vòng 1 ngày
-
good first issue I - Incorrect oss-scanner U - All (uucore) U - ls
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
Maintainer thường phản hồi trong vòng 1 ngày
-
good first issue I - Incorrect oss-scanner U - All (uucore) U - rm
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
Maintainer thường phản hồi trong vòng 1 ngày
-
good first issue I - Incorrect oss-scanner U - All (uucore) U - ls
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 79/100
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của uutils/coreutils
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
manuscript-lang/manuscript#1364 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
`printf "%o"` prints decimalĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
fable-compiler/Fable#5157 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 73/100
Maintainer thường phản hồi trong vòng 1 ngày
-
C-bug needs-triage O-UEFI T-libs
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
移动端宽屏适配Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 64/100
Maintainer thường phản hồi trong vòng 1 ngày