groups, pinky: unknown numeric user name looked up by uid, unlike GNU
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
调研方向
从 src/uucore/src/lib/features/entries.rs 中的 Passwd::locate,以及 src/uu/groups/src/groups.rs 和 src/uu/pinky/src/platform/unix.rs 中仅使用名称的调用点开始;将它们与需要不同查找行为的 id 和 chown 进行比较。Issue 建议添加仅按名称查找的方式,并在这三个调用点使用它。使用附带的脚本复现问题,然后确认没有匹配账户的数字名称会导致 groups 失败,并且不会在 pinky 中泄露 uid 详情,而 id 仍会解析数字 ID。
由索引模型根据 Issue 内容生成。
描述
============================================================================
ANT-2026-X3DMHZDB [NOT SECURITY: ordinary bug]
groups, pinky: unknown numeric user name looked up by uid, unlike GNU
Location: src/uucore/src/lib/features/entries.rs:293 in Locate<&str>::locate (macro f!)()
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.
groups NAME and pinky -l NAME give an answer for a name that no account has, as long as the name is a number: the operand is quietly looked up again as a uid. On a system where uid 1000 is opsadmin and no account is named 1000, coreutils groups 1000 prints 1000 : opsadmin operators and exits 0. GNU coreutils 9.7 prints groups: '1000': no such user and exits 1. 0, +0 and 00 all resolve to root the same way.
The cause is Passwd::locate(&str) in src/uucore/src/lib/features/entries.rs (the f! macro, around line 293). It tries getpwnam first and, if that fails and the string parses as a uid_t, falls back to getpwuid. u32::from_str accepts a leading + and leading zeros. uucore::entries has no name-only lookup, so callers holding a name get the fallback whether they want it or not:
groupscalls it for each operand (src/uu/groups/src/groups.rs:64).pinkycalls it for long-format operands and for utmp user names (src/uu/pinky/src/platform/unix.rs:326and:231). As a result,pinky -l 1000prints uid 1000's real name, home, shell and plan files where GNU printsIn real life: ???.
chown already guards against this by checking passwd.name == user after the lookup. id relies on the fallback and matches GNU there, so the fallback can't simply be removed. The group list printed is always correct for the uid it lands on, and an account whose name really is all digits is still found by name first. The difference only shows up for a numeric operand that is nobody's login name but is an assigned uid, for example a uid passed where a name was expected.
The string lookup dates from 799804e455f5 ("Add uucore::entries", 2016) and has been present since 0.0.1. An attached script compares GNU and uutils groups on such an operand, and a patch adds a name-only locate_name for these three call sites.
This is an ordinary bug, not a security issue: groups and pinky -l differ from GNU only for an
operand that is no account's name, the group list they print is the correct one for the uid they fall
back to, and a script that trusts such a caller-chosen name is already open, under GNU, to anyone who
gives a real member's name.
Details
groups NAME and pinky -l NAME answer for a name that no account has when the name is a number: the
operand is silently looked up again as a uid. On a system where uid 1000 is opsadmin, a member of
operators, and no account is called 1000:
$ /usr/bin/groups 1000 # GNU coreutils 9.7
groups: '1000': no such user (exit 1)
$ coreutils groups 1000
1000 : opsadmin operators (exit 0)
$ coreutils groups +0
+0 : root (exit 0)
coreutils pinky -l 1000 likewise prints the real name, home directory, shell, ~/.project and ~/.plan
of uid 1000, where GNU prints In real life: ??? and nothing else.
Both utilities call Passwd::locate(&str). Its string form is not a lookup by name; it is "name, and
if that fails and the string is a number, ID":
// src/uucore/src/lib/features/entries.rs:289 (macro f!, instantiated for Passwd and Group at :312-313)
let data = $fnam(cstring.as_ptr()); // getpwnam / getgrnam
if !data.is_null() {
return Ok($st::from_raw(ptr::read(data.cast_const())));
}
if let Ok(id) = k.parse::<$t>() { // uid_t / gid_t
let data = $fid(id); // getpwuid / getgrgid
if !data.is_null() {
Ok($st::from_raw(ptr::read(data.cast_const())))
u32::from_str accepts a leading + and leading zeros, so 0, +0, 00 and 0033 all reach
getpwuid. The returned Passwd does not record which of the two lookups produced it, and
uucore::entries has no name-only entry point (usr2uid, usr2gid and grp2gid at :326-338 are thin
wrappers over the same function), so a caller that holds a name gets the fallback whether it wants it
or not.
groups passes each operand straight in and prints the operand, not the name of the account it found,
in front of the group list; the groups themselves are those of the account getpwuid returned:
// src/uu/groups/src/groups.rs:63
for user in users {
match Passwd::locate(user.as_str()) {
Ok(p) => {
let groups: Vec<String> =
p.belongs_to().into_iter().map(infallible_gid2grp).collect();
writeln!(stdout(), "{user} : {}", groups.join(" "))?;
With groups 1000, getpwnam("1000") returns NULL, the parse succeeds and getpwuid(1000) returns
opsadmin: the line is 1000 : opsadmin operators, exit status 0. With groups nosuchuser the parse
fails and the Err arm reports no such user, exit 1, as GNU does. An existing account whose name
really is all digits is still found by name, because getpwnam is tried first.
pinky makes the same call in two places: src/uu/pinky/src/platform/unix.rs:326 for each operand of
the long format, and :231 for the user field of each utmp record in the short format.
chown met the same problem for the OWNER: form and guards against it locally by comparing the name
afterwards:
// src/uu/chown/src/chown.rs:225
return match Passwd::locate(user) {
// Lookup must occur through name, not ID, to be valid here.
Ok(passwd) if passwd.name == user => Ok((Some(passwd.uid), Some(passwd.gid))),
groups and pinky have no such comparison. The fallback itself cannot simply be removed: id relies
on it (src/uu/id/src/id.rs:227), and there it matches GNU, whose id 33, id +33 and id 0033 all
print the entry of uid 33.
The name-or-ID string lookup was introduced with Locate<&str> in 799804e455f5 ("Add uucore::entries",
2016-08-14) and is present in every release since the first, 0.0.1.
Impact
Anyone who runs groups N or pinky -l N with an all-digit operand (also +N or with leading zeros)
that is not an account name but is an assigned uid gets an answer for the account with that uid, exit
status 0, where GNU reports no such user (exit 1) or In real life: ???. groups documents its
operand as USERNAME, so a shell script that uses groups -- "$name" to ask whether an account named
$name exists, or whether it is in a group, gets "yes" for such a string where GNU says no. Users meet
this rarely: the operand has to be entirely digits, be nobody's login name, and match an assigned uid
(0, +0 and 00 all match root); typically it happens when a uid is passed by mistake where a name
was expected. An account whose name really is all digits is still found by name first.
Nothing is misreported for a real account: the group list printed is correct for that uid, and
pinky -l N shows only what the passwd database and the home directory already expose. Neighbouring
tools behave the same way on GNU systems: GNU id 1000 and id -nG 1000, and glibc
getent passwd 1000 (and getent passwd +33), also resolve a number as a uid, so only groups and
pinky diverge from GNU here. The login hook in the reproducer is written only to show how a script's
decision changes; no existing script that relies on this pattern is known.
Reproducing
repro is a Python script (standard library only). It runs as root against the prebuilt
/src/target/debug/coreutils and the image's GNU coreutils 9.7 in /usr/bin. It comes with one file:
repro.d/operators_only.shis the administrator's part: a login hook that is handed the name a client
typed and printsGRANTEDifgroups -- "$1"listsoperators,DENIEDotherwise. It calls plain
groups, so the same hook runs with GNU or with uutils depending only onPATH.
repro holds the setup, the input and the verdict:
- Set up. It puts a
groupssymlink to the multicall binary in a temporary directory (the usual
multicall install), so that prepending that directory toPATHselects uutils. It creates group
operatorsand accountopsadminin it withgroupaddanduseradd(default options), and removes
both, and the temporary directory, before printing. - Input. The input is one string:
opsadmin's uid in decimal (1000in this image).
The script counts the accounts in the passwd database that have that string as their name (0). - Observe. It runs
groups -- 1000with GNU and with uutils, and the login hook with1000under
eachPATH.
Expected output:
CONTROL accounts named '1000': 0
CONTROL GNU groups 1000 -> exit 1, "groups: '1000': no such user\n"
OBSERVED uutils groups 1000 -> exit 0, '1000 : opsadmin operators\n'
CONTROL login hook with GNU groups -> DENIED
OBSERVED login hook with uutils groups -> GRANTED
BUG
BUG is printed only when no account is named 1000, GNU groups exits 1 and the hook denies with it,
while uutils groups exits 0 with the groups of uid 1000 (1000 : opsadmin operators) and the same hook
grants.
pinky is not part of the script; the same fallback shows there with
coreutils pinky -l 1000 (home directory and shell of uid 1000) against GNU pinky -l 1000
(In real life: ???).
Suggested fix
The attached patch gives uucore::entries a lookup that is by name only and uses it where the operand
is a name. In the f! macro it splits the existing string lookup in two:
// src/uucore/src/lib/features/entries.rs, replacing the Locate<&str> impl at :280-308
impl $st {
/// Fetch the entry whose name is `k`.
///
/// Unlike `locate`, this never retries a numeric `k` as an ID.
pub fn locate_name(k: &str) -> IOResult<Self> {
let _guard = PW_LOCK.lock();
// SAFETY: We're holding PW_LOCK.
unsafe {
let cstring = CString::new(k)?;
let data = $fnam(cstring.as_ptr());
if !data.is_null() {
Ok($st::from_raw(ptr::read(data.cast_const())))
} else {
Err(IOError::new(ErrorKind::NotFound, format!("Not found: {k}")))
}
}
}
}
impl<'a> Locate<&'a str> for $st {
fn locate(k: &'a str) -> IOResult<Self> {
Self::locate_name(k).or_else(|e| match k.parse::<$t>() {
Ok(id) => Self::locate(id),
Err(_) => Err(e),
})
}
}
and switches the three name-only call sites (dropping the then unused Locate import in both files):
--- a/src/uu/groups/src/groups.rs
+++ b/src/uu/groups/src/groups.rs
@@ -64 @@
- match Passwd::locate(user.as_str()) {
+ match Passwd::locate_name(user.as_str()) {
--- a/src/uu/pinky/src/platform/unix.rs
+++ b/src/uu/pinky/src/platform/unix.rs
@@ -231 @@
- let name = Passwd::locate(ut.user().as_ref())
+ let name = Passwd::locate_name(ut.user().as_ref())
@@ -326 @@
- let Ok(pw) = Passwd::locate(name.as_str()) else {
+ let Ok(pw) = Passwd::locate_name(name.as_str()) else {
Locate<&str>::locate keeps its name-then-ID behaviour and its error messages, so id, chown,
chgrp, install -o/-g and chroot --userspec/--groups are not affected. The patch also documents
both lookups in the module doctest of entries.rs and adds test_groups_operand_is_not_a_uid
(tests/by-util/test_groups.rs: 0, +0, 00 give groups: '<name>': no such user, exit 1) and
test_long_format_operand_is_not_a_uid (tests/by-util/test_pinky.rs: pinky -l 0 prints ???).
With the patch, groups 0, +0, 00 and 33 report
no such user with exit status 1, pinky -l 0 prints ???, id 33, id +33, chown 33:33,
chgrp 33, install -o 33 -g +33 and chroot --userspec=65534:65534 behave as before, and the
reproducer prints OK.
Running the reproducer
bugs-not-security/ANT-2026-X3DMHZDB/repro, next to this file, with its companion files in bugs-not-security/ANT-2026-X3DMHZDB/repro.d/ (operators_only.sh) — the script reads them from /tmp/repro.d/. 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-X3DMHZDB/repro:/tmp/repro:ro" -v "$PWD/bugs-not-security/ANT-2026-X3DMHZDB/repro.d:/tmp/repro.d: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 天 21 小时
- 30 天内合并 PR
- 362
环境准备
在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。
- 没有 Dockerfile 或 Docker Compose 文件
- 没有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
uutils/coreutils 的其他 Issue
-
ls: `--block-size=0` is accepted when `--si` comes later可能已有人在做 @Logic769 于 1 天前认领。 未关闭good first issue U - ls
难度 2/5 1-3 小时 新手友好度 68/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 68/100
uutils/coreutils#15962 · 3 条评论 ·
维护者通常 1 天内回复
-
B - high priority U - tr
难度 2/5 1-3 小时 新手友好度 67/100
维护者通常 1 天内回复
-
U - df
难度 2/5 1-3 小时 新手友好度 65/100
维护者通常 1 天内回复
-
mv: a failed cross-device move always reports "Permission denied"可能已有人在做 @afnan-mojumder 今天认领。 未关闭U - mv
难度 2/5 1-3 小时 新手友好度 68/100
维护者通常 1 天内回复
相似的 Issue
-
bug
难度 2/5 1-3 小时 新手友好度 84/100
dekobon/big-code-analysis#1729 ·
维护者通常 1 天内回复
-
bug help wanted
难度 2/5 1-3 小时 新手友好度 78/100
维护者通常 3 天内回复
-
难度 2/5 1-3 小时 新手友好度 88/100
akitaonrails/ai-memory#1888 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
peteonrails/voxtype#941 ·
维护者通常 4 天内回复
-
bug priority:medium triage:maintainable
难度 2/5 1-3 小时 新手友好度 82/100
ccusage/ccusage#2000 · 3 条评论 ·
维护者通常 1 天内回复