ONC RPC¶
ONC RPC version 2 is the call/reply framework that carries every protocol in the NFS stack. NFS itself, MOUNT, portmapper, NLM, RQUOTA, and NFS_ACL are all RPC programs. Each call specifies a program number, a version number, and a procedure number; the server dispatches accordingly and returns a reply. The protocol is defined in RFC 5531 (which obsoletes RFC 1831 without wire changes) and encoded with XDR (RFC 4506).
From a security perspective, ONC RPC has no built-in encryption, no per-connection state, and an authentication model where the dominant flavor -- AUTH_SYS -- trusts the caller's self-asserted identity without any verification. Every security property that NFS relies on (access control, credential checking, replay suppression) is layered on top of a framework that was not designed to enforce any of them.
The RPC model¶
Every interaction is a single call message paired with a single reply message. The client constructs a call, sends it, and blocks until the reply arrives (though implementations may operate asynchronously). The server receives the call, dispatches to the appropriate procedure, and sends back the result.
A procedure is uniquely identified by three integers:
| Identifier | Purpose | Example |
|---|---|---|
| Program number | Which service | 100003 = NFS |
| Version number | Which protocol revision | 3 = NFSv3 |
| Procedure number | Which operation | 6 = READ |
This triple is analogous to a function pointer: NFS.v3.READ(args) -> result. The RPC layer routes the call; it does not interpret the arguments or results, which are blocks of data whose meaning is defined by each program's protocol specification.
Procedure 0 is always NULL
By convention (RFC 5531 Section 12.1), procedure 0 of every RPC program accepts no arguments and returns no results. It exists purely for liveness probing and round-trip measurement. NULL must not require authentication, making it useful for port discovery and version fingerprinting.
Message format¶
Call message¶
Every RPC message begins with a 4-byte transaction identifier (XID), followed by a tagged union on message type. A call message (type 0) contains:
struct rpc_msg {
unsigned int xid; /* transaction identifier */
union switch (msg_type mtype) {
case CALL:
call_body cbody;
} body;
};
struct call_body {
unsigned int rpcvers; /* must be 2 */
unsigned int prog; /* program number */
unsigned int vers; /* program version */
unsigned int proc; /* procedure number */
opaque_auth cred; /* caller credentials */
opaque_auth verf; /* caller verifier */
/* procedure-specific arguments follow */
};
In plain English: every call starts with a transaction ID, then specifies which service (program), which version, and which operation (procedure) to invoke. The caller's identity (credential) and a verifier ride alongside, followed by the operation's arguments.
Field reference¶
| Field | XDR Type | Size | Purpose |
|---|---|---|---|
xid |
unsigned int |
4 bytes | Transaction ID. The client generates it; the server echoes it in the reply so the client can match responses to outstanding calls. |
mtype |
enum msg_type |
4 bytes | CALL (0) or REPLY (1). |
rpcvers |
unsigned int |
4 bytes | Always 2 for ONC RPC v2. |
prog |
unsigned int |
4 bytes | IANA-assigned program number (e.g., 100003). |
vers |
unsigned int |
4 bytes | Program version (must be non-zero; RFC 5531 Section 8.1). |
proc |
unsigned int |
4 bytes | Procedure number within the program. |
cred |
opaque_auth |
4 + 4 + body | Authentication credential: flavor enum + up to 400 bytes of flavor-specific data. |
verf |
opaque_auth |
4 + 4 + body | Authentication verifier: validates the credential. For AUTH_SYS, this is AUTH_NONE (empty). |
After the verifier, the procedure-specific XDR-encoded arguments follow directly with no additional framing.
Reply message¶
A reply message (type 1) uses a two-level tagged union:
union reply_body switch (reply_stat stat) {
case MSG_ACCEPTED:
accepted_reply areply;
case MSG_DENIED:
rejected_reply rreply;
};
In plain English: the reply is either "accepted" (the server processed the call) or "denied" (rejected before processing). An accepted reply carries a server verifier (so the client can authenticate the server in flavors that support mutual authentication) followed by a status code. A denied reply indicates either an RPC version mismatch or an authentication failure. The full reply taxonomy is covered in Reply types below.
Record marking (TCP framing)¶
RPC is transport-independent, but needs a way to delimit messages on stream transports. TCP uses record marking (RM), defined in RFC 5531 Section 11.
Each record is composed of one or more fragments. Each fragment begins with a 4-byte header:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|L| Fragment Length (31 bits) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Fragment Data ... |
| Bit | Name | Meaning |
|---|---|---|
| 31 (MSB) | Last-fragment flag | 1 = this is the final fragment of the record; 0 = more fragments follow. |
| 30..0 | Length | Number of bytes of fragment data that follow (up to 2^31 - 1). |
In practice, most RPC implementations send each message as a single fragment with the last-fragment bit set. The fragment header is NOT XDR-encoded; it uses raw network byte order (big-endian).
UDP has no record marking. Each UDP datagram carries exactly one complete RPC message. The datagram boundaries are the message boundaries.
nfswolf implementation
The onc-rpc-client crate's TCP transport reads the 4-byte RM header, assembles all fragments into a complete record, then XDR-decodes the RPC message. UDP uses the single-shot call_rpc_udp() helper in src/proto/udp.rs, which sends and receives one datagram per call with no record marking.
Authentication¶
Every RPC call carries two opaque_auth structures (credential + verifier), and every reply carries one (response verifier). The opaque_auth type is:
struct opaque_auth {
auth_flavor flavor; /* which authentication protocol */
opaque body<400>; /* up to 400 bytes of flavor-specific data */
};
In plain English: each credential is a flavor tag (which auth method) plus up to 400 bytes of auth-method-specific data.
The RPC layer does not interpret the body; it passes the raw bytes through to the service. The service (or an underlying authentication module) decides whether to accept or reject the credential. This design means authentication is per-call, not per-connection: each individual RPC message carries its own credentials, and the server re-evaluates them on every call.
Authentication flavors¶
| Flavor | Value | Defined In | What It Does |
|---|---|---|---|
AUTH_NONE |
0 | RFC 5531 Section 10.1 | No identity. Empty credential and verifier. Used for NULL probes and RPCSEC_GSS context setup. |
AUTH_SYS |
1 | RFC 5531 Appendix A | Client-asserted UNIX identity: stamp, machinename, uid, gid, gids<16>. Verifier is AUTH_NONE. No cryptographic protection. |
AUTH_SHORT |
2 | RFC 5531 Appendix A | Server-assigned opaque token returned in a reply verifier after an AUTH_SYS call. The client replays this token in subsequent calls to save bandwidth. |
AUTH_DH |
3 | RFC 2695 | Diffie-Hellman key exchange + DES-encrypted timestamps. Deprecated and insecure (56-bit DES, fixed modulus). |
RPCSEC_GSS |
6 | RFC 2203 | GSS-API wrapper supporting Kerberos 5 and other mechanisms. Provides authentication, optional integrity, and optional privacy (encryption). The only flavor with real security. |
Credential/verifier flow¶
sequenceDiagram
participant C as Client
participant S as Server (knfsd)
Note over C: Build AUTH_SYS credential:<br>stamp=47, machine="attacker",<br>uid=0, gid=0, gids=[]
C->>S: CALL [xid=0x1a2b3c4d]<br>prog=100003, vers=3, proc=6 (READ)<br>cred={AUTH_SYS, stamp/uid/gid/...}<br>verf={AUTH_NONE, empty}<br>args={filehandle, offset=0, count=65536}
Note over S: Accept credential at face value.<br>Map uid=0 -> root (or squash).<br>Check export permissions.<br>Execute READ.
S-->>C: REPLY [xid=0x1a2b3c4d]<br>stat=MSG_ACCEPTED<br>verf={AUTH_NONE, empty}<br>accept_stat=SUCCESS<br>data={file_attributes, 65536 bytes}
The key insight: the call verifier for AUTH_SYS is AUTH_NONE, literally empty. There is nothing for the server to verify. The server trusts the credential's uid and gid fields because there is no mechanism to challenge them. See Authentication Model for the full treatment of each flavor.
Program numbers¶
Program numbers are 32-bit unsigned integers administered by IANA (RFC 5531 Section 8.3). The number space is partitioned:
| Range | Assignment |
|---|---|
0x00000000 |
Reserved |
0x00000001 -- 0x1FFFFFFF |
IANA-assigned |
0x20000000 -- 0x3FFFFFFF |
Local administrator / private use |
0x40000000 -- 0x5FFFFFFF |
Transient (dynamically registered) |
0x60000000 -- 0xFFFFFFFF |
Reserved |
Key program numbers in the NFS ecosystem¶
| Program | Number | Versions | Purpose |
|---|---|---|---|
| Portmapper / rpcbind | 100000 | 2, 3, 4 | Service discovery. Maps (program, version, protocol) to port. Always on port 111. |
| NFS | 100003 | 2, 3, 4 | File operations: read, write, lookup, readdir, create, remove, etc. |
| MOUNT | 100005 | 1, 3 | Export enumeration and handle acquisition. Client calls MNT to get the root file handle for an export. |
| NLM | 100021 | 1, 3, 4 | Network Lock Manager. Advisory and mandatory file locking over NFS. |
| NSM | 100024 | 1 | Network Status Monitor. Crash notification for lock recovery. |
| NFS_ACL | 100227 | 2, 3 | POSIX ACL operations. Non-standard extension implemented by Linux knfsd and Solaris. |
| RQUOTA | 100011 | 1, 2 | Disk quota queries. Reveals UID existence and filesystem block sizes. |
nfswolf's IANA registry
nfswolf ships the complete IANA RPC program numbers registry (1251 entries) for offline program identification during scanning. The portmapper DUMP output is matched against this registry to identify every registered service.
Reply types¶
Accepted replies (MSG_ACCEPTED)¶
When the server accepts the call at the RPC level (regardless of whether the procedure itself succeeds), it returns an accepted_reply:
struct accepted_reply {
opaque_auth verf; /* server's verifier */
union switch (accept_stat stat) {
case SUCCESS:
opaque results[0]; /* procedure-specific results */
case PROG_MISMATCH:
struct { unsigned int low; unsigned int high; } mismatch_info;
default:
void; /* PROG_UNAVAIL, PROC_UNAVAIL,
GARBAGE_ARGS, SYSTEM_ERR */
} reply_data;
};
| Status | Value | Meaning |
|---|---|---|
SUCCESS |
0 | Procedure executed. Results follow. |
PROG_UNAVAIL |
1 | Server does not export this program number. |
PROG_MISMATCH |
2 | Program exists but not at the requested version. Returns the supported range. |
PROC_UNAVAIL |
3 | Version exists but does not implement this procedure number. |
GARBAGE_ARGS |
4 | Procedure could not decode the arguments. XDR mismatch. |
SYSTEM_ERR |
5 | Generic server-side failure (e.g., memory allocation). |
PROG_MISMATCH as a version oracle
When nfswolf sends a NULL call with an unsupported version, the server replies with PROG_MISMATCH and includes the low and high version numbers it actually supports. This is how resolve_version() discovers which NFS versions a server implements without any prior knowledge.
Denied replies (MSG_DENIED)¶
When the server rejects the call before reaching the service:
| Status | Value | Meaning |
|---|---|---|
RPC_MISMATCH |
0 | rpcvers in the call was not 2. Returns supported RPC version range. |
AUTH_ERROR |
1 | Authentication rejected. Carries an auth_stat code. |
Authentication status codes (auth_stat)¶
When a call is denied with AUTH_ERROR, the reply carries one of these status codes:
| Code | Value | Meaning |
|---|---|---|
AUTH_OK |
0 | Authentication succeeded (only in non-error paths). |
AUTH_BADCRED |
1 | Credential is malformed or corrupt. |
AUTH_REJECTEDCRED |
2 | Credential expired. Client must re-authenticate (e.g., re-establish AUTH_SHORT). |
AUTH_BADVERF |
3 | Verifier is malformed or corrupt. |
AUTH_REJECTEDVERF |
4 | Verifier expired or replayed. |
AUTH_TOOWEAK |
5 | Server requires a stronger authentication flavor. |
AUTH_INVALIDRESP |
6 | Server's response verifier was bogus (client-side check). |
AUTH_FAILED |
7 | Authentication failed for an unspecified reason. |
AUTH_KERB_GENERIC |
8 | Kerberos generic error (deprecated; see RFC 2695). |
AUTH_TIMEEXPIRE |
9 | Kerberos credential expired. |
AUTH_TKT_FILE |
10 | Problem with Kerberos ticket file. |
AUTH_DECODE |
11 | Cannot decode Kerberos authenticator. |
AUTH_NET_ADDR |
12 | Wrong network address in Kerberos ticket. |
RPCSEC_GSS_CREDPROBLEM |
13 | No GSS credentials for this user. |
RPCSEC_GSS_CTXPROBLEM |
14 | Problem with GSS security context. |
AUTH_TOOWEAK as an oracle
AUTH_TOOWEAK (5) tells an attacker that the program and export exist but require stronger authentication (typically Kerberos). This confirms the export path is valid, reveals the security policy, and enables targeted attacks (e.g., attempting NFSv2 downgrade where sec=krb5 enforcement may be weaker). See F-1.8.
The Duplicate Request Cache (DRC)¶
NFS servers maintain a Duplicate Request Cache (DRC) to achieve approximate at-most-once semantics for non-idempotent operations (CREATE, REMOVE, RENAME, WRITE). When the server receives a call, it checks the DRC for a matching entry. If found, the cached reply is returned without re-executing the procedure.
How the DRC matches requests¶
The DRC key typically includes:
- The client's source IP and port
- The XID from the RPC message
- The program, version, and procedure numbers
- Optionally, a hash of the credential and arguments
The XID is the primary discriminator. RFC 5531 Section 5 states that clients may reuse the same XID when retransmitting a call, and servers may use the XID to detect duplicates.
Why unique stamps matter¶
The stamp field in AUTH_SYS credentials is nominally an "arbitrary ID" (RFC 5531 Appendix A). In practice, servers may incorporate it into their DRC matching. If two distinct calls arrive with the same XID, same source address, and the same credential stamp, the server may incorrectly treat the second as a duplicate and return the cached reply from the first.
This matters during UID spraying: nfswolf sends many rapid calls to the same server with different UIDs but from the same source address. If the stamp is reused, the DRC can suppress legitimate new calls. nfswolf avoids this by incrementing stamps from a global AtomicU32 counter (src/proto/auth.rs), so every call has a unique stamp.
DRC collisions as a stealth risk
If an attacker accidentally collides with a legitimate client's XID and stamp, the server may return the cached reply from the legitimate operation, or worse, serve the attacker's cached reply to the legitimate client on a retransmit. The DRC is a correctness mechanism, not a security mechanism, and it can be abused in both directions.
Security implications¶
ONC RPC was designed in the 1980s for trusted local networks. Its security model reflects that era. Here is what goes wrong when it is deployed on untrusted networks:
No encryption¶
RPC messages are plaintext on the wire. Every field (credentials, file handles, file data, metadata) is visible to any network observer. Only RPCSEC_GSS with privacy mode provides encryption, and it is rarely deployed. See F-3.1.
Authentication is per-call, not per-connection¶
There is no session establishment, no TLS handshake, no connection-level identity. Each call carries its own credentials independently. A single TCP connection can interleave calls with different UIDs, different flavors, or no authentication at all. This means:
- An attacker who gains access to a TCP connection to an NFS server can immediately issue calls as any UID.
- There is no binding between the transport connection and the authenticated identity.
- Credentials from one call can be replayed on another connection.
AUTH_SYS is trivially spoofable¶
AUTH_SYS credentials contain uid, gid, and gids as plaintext integers with an AUTH_NONE verifier. The server has no way to verify that the caller actually has those identities. The machinename field is advisory and is not used for access control by Linux knfsd (it checks the TCP source IP for export ACL decisions). Forging a credential is writing different integers into the same fields.
File handles are bearer tokens¶
A bearer token is a credential that grants access to whoever holds it, like a physical key. Once a client obtains a file handle through any mechanism (MOUNT MNT, LOOKUP, READDIRPLUS, handle guessing), that handle works with any credential on any connection. There is no binding between the credential that obtained the handle and subsequent uses of it. A handle obtained as UID 1000 works equally well when presented with UID 0. See F-2.1.
No mutual authentication by default¶
With AUTH_SYS and AUTH_NONE, the server does not authenticate itself to the client. The client has no way to verify that it is talking to the real NFS server and not an attacker performing a man-in-the-middle attack. Only RPCSEC_GSS provides mutual authentication through the GSS verifier exchange.
nfswolf implementation
The onc-rpc-client crate handles all of this: record marking in transport/, AuthSys construction in auth/, XID generation, and the RpcTransport trait that nfswolf's policy layer (src/proto/transport.rs) wraps with pooling, circuit breaking, stealth delays, and credential management. The library encodes wire formats; the tool implements the attack logic on top.