groups, pinky: unknown numeric user name looked up by uid, unlike GNU
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 2/5
- Tiempo estimado
- 1-3 horas
- Aptitud para principiantes
- 83/100
Línea de trabajo
Empieza con Passwd::locate en src/uucore/src/lib/features/entries.rs y los puntos de llamada que solo usan nombres en src/uu/groups/src/groups.rs y src/uu/pinky/src/platform/unix.rs; compáralos con id y chown, que necesitan un comportamiento de búsqueda distinto. El issue sugiere añadir una búsqueda solo por nombre y usarla en esos tres puntos de llamada. Reproduce el problema con el script adjunto y confirma después que los nombres numéricos sin cuentas coincidentes fallan en groups y no revelan detalles de uid en pinky, mientras que id sigue resolviendo IDs numéricos.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
============================================================================
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.
- Lenguaje dominante
- Rust
- Estrellas
- 24.3k
- Forks
- 2.1k
- Merge medio
- 1 d 21 h
- PR fusionados (30 d)
- 357
Preparar el entorno
Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de uutils/coreutils
-
fmt: stops at an unreadable file operand and says only 'read error' for a directoryPosiblemente ocupada @emirmrkaljevic la tomó hace 1 día. AbiertoU - fmt
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
shuf: exhausted random source destroys the input during an in-place shufflePosiblemente ocupada @limadog9 la tomó hace 1 día. AbiertoU - shuf
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
paste: serial mode exhausts file descriptors by opening every input simultaneouslyPosiblemente ocupada @binhvq la tomó hace 1 día. AbiertoU - paste
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día
-
unexpand: final newline prevents conversion of leading Unicode blanksPosiblemente ocupada @Ybucaille la tomó hace 1 día. AbiertoU - unexpand
Dificultad 2/5 1-3 horas Aptitud para principiantes 86/100
Los mantenedores suelen responder en 1 día
-
chmod -v: missing 'could not be accessed' line for operands that can't be stat'edPosiblemente ocupada @emirmrkaljevic la tomó hace 1 día. AbiertoU - chmod
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día
Todos los issues de uutils/coreutils
Issues similares
-
desktop documentation
Dificultad 1/5 Menos de una hora Aptitud para principiantes 90/100
vfarcic/dot-agent-deck#2008 ·
Los mantenedores suelen responder en 1 día
-
needs triage
Dificultad 1/5 Menos de una hora Aptitud para principiantes 85/100
pbakaus/impeccable#1210 ·
Los mantenedores suelen responder en 1 día
-
area: ci priority: P4 ready-for-agent
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
shep-pm/shep#1249 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
L: javascript
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
dependabot/dependabot-core#16744 ·
Los mantenedores suelen responder en 1 día
-
bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
nautechsystems/nautilus_trader#5486 · 1 comentario ·
Los mantenedores suelen responder en 1 día