Filesystems · Network Filesystems
Credentials on the Wire and Identity Squashing
The client asserts who the caller is on every request, and the server decides whether to believe it. That decision is where network filesystems diverge from local permission checking, and where the root account becomes the recurring problem.
On a local filesystem, a process’s identity is a fact of the kernel’s own state. The kernel knows which user started the process, it holds the credentials in the task structure, and when the process opens a file the permission check uses those credentials directly. Nothing is asserted and nothing is verified, because there is no one to assert to.
Over the network the check happens on a different machine, and that machine has never seen the process. The client must tell the server who the caller is, and the server must decide whether to believe it. Everything about network authentication follows from that gap, and the recurring problem is the same one: the client’s assertion arrives as data, and data can be forged.
What a request carries about its caller
In the ONC RPC authentication flavours that NFS version 2 and version 3 use, the credential is part of the call header. The AUTH_SYS flavour carries a structure with the caller’s user identifier, the caller’s primary group identifier, and a variable-length list of supplementary group identifiers, plus a machine name and a numeric stamp. The server reads those numbers and uses them for its permission checks.
Nothing in that structure is authenticated. The flavour is named AUTH_SYS because it is the bare system identity, and the protocol documents that it provides no guarantee of authenticity: a client can put any numbers it likes in those fields. The server’s only protection is the export configuration — restricting which hosts may mount, and treating a request from an unknown host as unauthenticated.
This is not an oversight that implementations forgot to fix. The security model is that the network is trusted and the clients are cooperating kernels, and the export list is the boundary. The numbers identify a user within that trust domain, and the assumption is that a machine admitted to the export will not lie about its users. The assumption holds in a data centre and fails everywhere else, which is why the protocol later gained stronger flavours.
AUTH_DES was an attempt at cryptographic authentication using Diffie-Hellman key exchange and a per-user key. AUTH_KERB4, the Kerberos Version 4 flavour specified in RFC 2695, was an earlier step in the same direction and was deprecated; RPCSEC_GSS, originally specified in RFC 2203, replaced both with a framework that carries a verifiable credential and, unlike its predecessors, can protect the integrity of the request body. Version 4 requires a security mechanism of this kind for full conformance, though implementations commonly allow the older flavour for compatibility.
The difference is not only in the strength of the identity claim. A protected credential can be bound to the request, so that an attacker cannot take a credential from one call and reuse it on another. An unprotected credential can be replayed, which is why a server that accepts it must rely on the network boundary rather than on the credential itself.
Squashing: what the server does with root
The specific problem with AUTH_SYS is that the client’s root user has user identifier zero, and identifier zero on the server is also root. A process running as root on a client can therefore assert identifier zero and be treated as root on the server, which would let it read every file in the export, change ownership, and modify permissions.
The standard mitigation is to map the incoming identifier zero to a different local identity before the permission check runs. The usual choice is the server’s own unprivileged user, which is a well-known account intended for exactly this purpose. The effect is that root on a client becomes an ordinary user on the server: it can read and write files that user can access, and it cannot change ownership or override permissions.
The mapping is configurable, and the configurations form a spectrum. Squashing root only leaves other identifiers untouched, so an ordinary user on a client is that same user on the server. Squashing all identifiers maps every incoming identity to one anonymous account, which makes the export effectively read-only for most purposes and is used for public data. The anonymous identity is itself a setting: exports(5) gives it the default numeric value 65534, adjustable through anonuid and anongid. There is no per-user mapping list in the export configuration; a site whose machines disagree about which number belongs to whom reconciles that outside the export, by keeping the numbering identical everywhere or by drawing it from a shared directory service.
/* Schematic: the server-side identity decision, not a kernel interface. */
struct cred *map_cred(const struct authsys_cred *in, const struct export *ex)
{
struct cred *out = cred_alloc();
out->uid = in->uid;
out->gid = in->gid;
if (ex->squash_root && in->uid == 0) {
out->uid = ex->anon_uid; /* root becomes the anonymous user */
out->gid = ex->anon_gid;
}
if (ex->squash_all) {
out->uid = ex->anon_uid;
out->gid = ex->anon_gid;
}
/* Supplementary groups are mapped the same way, so a squashed
caller cannot keep a privileged group that gid squashing removed. */
copy_groups(out, in, ex);
return out;
}
The mapping happens before the check, not after, so the operation is evaluated against the mapped identity. That ordering matters: a server that checked permission first and mapped afterwards would have already granted access. It also has to cover the whole credential, not just the user identifier. A caller whose user number is squashed but whose group identifier zero survives the mapping still passes group-ownership checks, so squashing a root caller means mapping the group identifier and dropping any privileged supplementary groups too.
The identifier is not the user
The deeper problem with AUTH_SYS is that a numeric user identifier is not a portable name. Two machines can assign the same number to different people, and one person can have different numbers on two machines. A client that asserts identifier 1000 is asserting that the caller is whoever the server calls 1000, which may be someone else entirely.
Sites that run directory services solve this by making the number assignment global, so that the number does identify the same person everywhere. Sites that do not have this coordination see the classic failure: a user creates a file on one machine, and on another machine the file appears owned by a stranger, or is inaccessible. The file’s owner is stored as a number, and the number means different things in the two places.
The name-based alternative is to put a string in the credential instead of a number, and to let the server resolve it. That requires a directory service both sides can query, and it introduces a resolution step on the server’s critical path. It also changes the failure mode: an unresolvable name becomes an error rather than a wrong answer, which is preferable but not free. Either representation has to be paired with a rule about which requests carry which credential, and the unstable write path is where that rule costs the most, since bulk data requests are the ones a site is most tempted to exempt from strong checking.
Version 4 moved toward name-based identity by making the protocol’s owner and group attributes strings rather than numbers. The owner attribute is a string such as alice@example.com, formed from a local identity plus a DNS domain, and the client translates whatever local representation it uses into that syntax on the way to the server. The server keeps its own representation and derives it from the string. The gain is that the wire representation is stable across machines with different local numbering. The cost is that every permission decision now involves a name lookup, and the translation between names and local identifiers is a configuration surface that can be wrong in its own ways — a user whose name does not resolve on the server becomes a number nobody owns, and the file appears to belong to a stranger.
Where the check actually happens
There are two places a permission check can occur, and the difference is visible to applications.
The client can check locally, using its own credentials and its cached attributes, and refuse an operation without contacting the server. This is fast and it produces the same error a local filesystem would. But it is advisory: the client’s cached attributes may be stale, and the client’s identity may not match the server’s mapping.
The server checks authoritatively when it executes the operation. If the client’s local check passed and the server’s check fails, the application sees a permission error from an operation it believed was allowed. If the client’s local check failed and the server would have allowed it, the operation is refused locally and never attempted — a false denial.
Most clients do the local check because the alternative is a round trip for every operation. The consequence is that the client’s view of permissions can differ from the server’s, and the difference is resolved in the direction of whichever check ran. This is why a file’s permissions appearing to change after a mount option change is not necessarily a bug: the client’s check and the server’s check use different inputs.
The server’s check also involves a step the local case does not. When a client opens a file, the server checks the credentials supplied on that request. When the client then performs reads and writes, each of those requests carries credentials too, and the server checks them again. A client that changes identity between the open and the read — because the process called setuid, or because a file descriptor was passed to a differently-credentialled process — can be refused on the read even though the open succeeded. Local filesystems check at open and then trust the descriptor; a network filesystem has no descriptor on the server side, so it checks every request.
The consequences that reach applications
The most visible consequence is that a file descriptor’s permissions are not fixed at open time. A program that opens a file as root, drops privileges, and then reads through the descriptor works on a local filesystem and fails on a network mount where root was squashed. The program’s design assumed that the descriptor carried the authority of the open; the network filesystem has no such object.
The second is that group membership is asserted, not looked up. A process that calls setgroups on the client changes what the client asserts on the next request, and the server honours the new list. On a local filesystem the same call changes the kernel’s own view. The two agree, but only because the client is the one making the assertion.
The third is that a supplementary group list is bounded. The AUTH_SYS credential carries the groups in an array declared with at most 16 entries inside a fixed-size call header, so the classic encoding admits sixteen supplementary groups and no more. A process that belongs to more groups than that will have some of them dropped from the credential, and a file whose access depends on one of the dropped groups will be refused. The limit does not exist locally, and it surfaces only when a site’s group structure grows past what the encoding allows, which is why clients expose a way to cap the list they send and why servers that cannot parse a full list need it capped lower.
The fourth is the interaction with file handles. Because the handle identifies the object and the credential identifies the caller, a server can check access to a handle without resolving a path. But the handle was obtained through some directory, and the server’s export boundary check must still confirm that the object is under the export root. A handle obtained through one export, presented with credentials that would be valid for another, is exactly the case the boundary check exists to refuse.
The unresolved part
The design has no way to make an unauthenticated credential safe, and it has no way to make an authenticated one cheap. Strong authentication costs a key exchange and a per-request signature, which is real work on the server’s critical path. Weak authentication costs nothing and trusts the network.
The usual arrangement is a compromise: strong authentication for the mount and for operations that change metadata, weak identity for bulk data transfer, with the identity derived from the authenticated context rather than asserted independently. That split is a configuration decision rather than a protocol feature, and it is the point where the security of a network filesystem becomes a property of how it was deployed rather than of what it implements.
References
- RFC 8881: Network File System (NFS) Version 4 Minor Version 1 Protocol — IETF
- RFC 5531: Remote Procedure Call Protocol Specification Version 2 — IETF
- exports(5) — Linux manual page — man7.org
- NFS Illustrated — Brent Callaghan, Addison-Wesley, 2000