Skip to content

Commit 3e96e96

Browse files
committed
Implement poc nfs
1 parent 3a3a0c4 commit 3e96e96

File tree

7 files changed

+241
-34
lines changed

7 files changed

+241
-34
lines changed

dissect/target/helpers/nfs/client.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
from typing import Generic, NamedTuple, TypeVar
1+
from typing import Generic, Iterator, NamedTuple, TypeVar
22

33
from dissect.target.helpers.nfs.nfs import (
44
CookieVerf3,
55
EntryPlus3,
66
FileAttributes3,
77
FileHandle3,
88
NfsStat,
9+
Read3args,
910
ReadDirPlusParams,
1011
)
1112
from dissect.target.helpers.nfs.serializer import (
13+
Read3ArgsSerializer,
14+
Read3ResultDeserializer,
1215
ReadDirPlusParamsSerializer,
1316
ReadDirPlusResultDeserializer,
1417
)
@@ -19,6 +22,10 @@
1922
Verifier = TypeVar("Verifier")
2023

2124

25+
class ReadFileError(Exception):
26+
pass
27+
28+
2229
class ReadDirResult(NamedTuple):
2330
dir_attributes: FileAttributes3 | None
2431
entries: list[EntryPlus3]
@@ -30,6 +37,7 @@ class ReadDirResult(NamedTuple):
3037
class Client(Generic[Credentials, Verifier]):
3138
DIR_COUNT = 4096 # See https://datatracker.ietf.org/doc/html/rfc1813#section-3.3.17
3239
MAX_COUNT = 32768
40+
READ_CHUNK_SIZE = 1024 * 1024
3341

3442
def __init__(self, rpc_client: SunRpcClient[Credentials, Verifier]):
3543
self._rpc_client = rpc_client
@@ -39,7 +47,7 @@ def connect(cls, hostname: str, port: int, auth: AuthScheme[Credentials, Verifie
3947
rpc_client = SunRpcClient.connect(hostname, port, auth, local_port)
4048
return Client(rpc_client)
4149

42-
def readdirplus(self, dir: FileHandle3) -> list[EntryPlus3] | NfsStat:
50+
def readdirplus(self, dir: FileHandle3) -> ReadDirResult | NfsStat:
4351
"""Read the contents of a directory, including file attributes"""
4452

4553
entries = list[EntryPlus3]()
@@ -61,3 +69,17 @@ def readdirplus(self, dir: FileHandle3) -> list[EntryPlus3] | NfsStat:
6169

6270
cookie = result.entries[-1].cookie
6371
cookieverf = result.cookieverf
72+
73+
def readfile_by_handle(self, handle: FileHandle3) -> Iterator[bytes]:
74+
"""Read a file by its file handle"""
75+
offset = 0
76+
count = self.READ_CHUNK_SIZE
77+
while True:
78+
params = Read3args(handle, offset, count)
79+
result = self._rpc_client.call(100003, 3, 6, params, Read3ArgsSerializer(), Read3ResultDeserializer())
80+
if isinstance(result, NfsStat):
81+
raise ReadFileError(f"Failed to read file: {result}")
82+
yield result.data
83+
if result.eof:
84+
return
85+
offset += result.count

dissect/target/helpers/nfs/demo.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import argparse
2-
from dissect.target.helpers.sunrpc.client import Client, auth_null, auth_unix
3-
from dissect.target.helpers.nfs.serializer import MountResultDeserializer
2+
43
from dissect.target.helpers.nfs.client import Client as NfsClient
4+
from dissect.target.helpers.nfs.nfs import EntryPlus3
5+
from dissect.target.helpers.nfs.serializer import MountResultDeserializer
6+
from dissect.target.helpers.sunrpc.client import Client, auth_null, auth_unix
57
from dissect.target.helpers.sunrpc.serializer import (
68
PortMappingSerializer,
79
StringSerializer,
@@ -16,9 +18,6 @@
1618
NFS_PROGRAM = 100003
1719
NFS_V3 = 3
1820

19-
hostname = "localhost"
20-
root = "/home/roel"
21-
2221

2322
# NFS client demo, showing how to connect to an NFS server and list the contents of a directory
2423
# Note: some nfs servers require connecting using a low port number (use --port)
@@ -29,6 +28,7 @@ def main():
2928
parser.add_argument("--port", type=int, default=0, help="The local port to bind to (default: 0)")
3029
parser.add_argument("--uid", type=int, default=1000, help="The user id to use for authentication")
3130
parser.add_argument("--gid", type=int, default=1000, help="The group id to use for authentication")
31+
parser.add_argument("--index", type=int, default=0, help="The index of the file to read (starting at 1)")
3232
args = parser.parse_args()
3333

3434
# RdJ: Perhaps move portmapper to nfs client and cache the mapping
@@ -38,16 +38,25 @@ def main():
3838
params_nfs = PortMapping(program=NFS_PROGRAM, version=NFS_V3, protocol=Protocol.TCP)
3939
nfs_port = port_mapper_client.call(100000, 2, 3, params_nfs, PortMappingSerializer(), UInt32Serializer())
4040

41-
mount_client = Client.connect(hostname, mount_port, auth_null(), args.port)
41+
mount_client = Client.connect(args.hostname, mount_port, auth_null(), args.port)
4242
mount_result = mount_client.call(
4343
MOUNT_PROGRAM, MOUNT_V3, MOUNT, args.root, StringSerializer(), MountResultDeserializer()
4444
)
4545
mount_client.close()
4646

4747
auth = auth_unix("twigtop", args.uid, args.gid, [])
48-
nfs_client = NfsClient.connect(hostname, nfs_port, auth, args.port)
48+
nfs_client = NfsClient.connect(args.hostname, nfs_port, auth, args.port)
4949
readdir_result = nfs_client.readdirplus(mount_result.filehandle)
50-
print(readdir_result)
50+
for index, entry in enumerate(readdir_result.entries, start=1):
51+
if entry.attributes:
52+
print(f"{index:<5} {entry.name:<30} {entry.attributes.size:<10}")
53+
54+
file_entry: EntryPlus3 = readdir_result.entries[args.index - 1]
55+
if file_entry.attributes:
56+
file_contents = nfs_client.readfile_by_handle(file_entry.handle)
57+
with open(file_entry.name, "wb") as f:
58+
for chunk in file_contents:
59+
f.write(chunk)
5160

5261

5362
if __name__ == "__main__":

dissect/target/helpers/nfs/nfs.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from typing import ClassVar
44

55

6+
# See https://datatracker.ietf.org/doc/html/rfc1057
7+
8+
69
class NfsStat(Enum):
710
NFS3_OK = 0
811
NFS3ERR_PERM = 1
@@ -125,7 +128,7 @@ class EntryPlus3:
125128
fileid: int
126129
name: str
127130
cookie: int
128-
attrs: FileAttributes3 | None
131+
attributes: FileAttributes3 | None
129132
handle: FileHandle3 | None
130133

131134

@@ -135,3 +138,18 @@ class ReadDirPlusResult3:
135138
cookieverf: CookieVerf3
136139
entries: list[EntryPlus3]
137140
eof: bool
141+
142+
143+
@dataclass
144+
class Read3args:
145+
file: FileHandle3
146+
offset: int
147+
count: int
148+
149+
150+
@dataclass
151+
class Read3resok:
152+
file_attributes: FileAttributes3 | None
153+
count: int
154+
eof: bool
155+
data: bytes

dissect/target/helpers/nfs/serializer.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
MountStat,
1111
NfsStat,
1212
NfsTime3,
13+
Read3args,
14+
Read3resok,
1315
ReadDirPlusParams,
1416
ReadDirPlusResult3,
1517
SpecData3,
@@ -110,6 +112,27 @@ def deserialize(self, payload: io.BytesIO) -> ReadDirPlusResult3:
110112

111113
entries.append(entry)
112114

113-
eof = self._read_enum(payload, Bool)
115+
eof = self._read_enum(payload, Bool) == Bool.TRUE
114116

115117
return ReadDirPlusResult3(dir_attributes, CookieVerf3(cookieverf), entries, eof)
118+
119+
120+
class Read3ArgsSerializer(Serializer[ReadDirPlusParams]):
121+
def serialize(self, args: Read3args) -> bytes:
122+
result = self._write_var_length_opaque(args.file.opaque)
123+
result += self._write_uint64(args.offset)
124+
result += self._write_uint32(args.count)
125+
return result
126+
127+
128+
class Read3ResultDeserializer(Deserializer[Read3resok]):
129+
def deserialize(self, payload: io.BytesIO) -> Read3resok:
130+
stat = self._read_enum(payload, NfsStat)
131+
if stat != NfsStat.NFS3_OK:
132+
return stat
133+
134+
file_attributes = self._read_optional(payload, FileAttributesSerializer())
135+
count = self._read_uint32(payload)
136+
eof = self._read_enum(payload, Bool) == Bool.TRUE
137+
data = self._read_var_length_opaque(payload)
138+
return Read3resok(file_attributes, count, eof, data)

dissect/target/helpers/sunrpc/client.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import random
12
import socket
23
from typing import Generic, NamedTuple, TypeVar
34

45
from dissect.target.helpers.sunrpc import sunrpc
5-
import random
66
from dissect.target.helpers.sunrpc.serializer import (
77
AuthNullSerializer,
88
AuthSerializer,
@@ -43,6 +43,7 @@ def auth_unix(machine: str | None, uid: int, gid: int, gids: list[int]) -> AuthS
4343
)
4444

4545

46+
# RdJ: Error handing is a bit minimalistic. Expand later on.
4647
class MismatchXidError(Exception):
4748
pass
4849

@@ -139,8 +140,10 @@ def _receive(self) -> bytes:
139140

140141
fragment_header = int.from_bytes(header, "big")
141142
fragment_size = fragment_header & 0x7FFFFFFF
142-
fragment = self._sock.recv(fragment_size)
143-
fragments.append(fragment)
143+
while fragment_size > 0:
144+
fragment = self._sock.recv(fragment_size)
145+
fragments.append(fragment)
146+
fragment_size -= len(fragment)
144147

145148
# Check for last fragment or underflow
146149
if (fragment_header & 0x80000000) > 0 or len(fragment) < fragment_size:

dissect/target/helpers/sunrpc/serializer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from __future__ import annotations
22

3-
3+
import io
44
from abc import ABC, abstractmethod
55
from enum import Enum
6-
import io
76
from typing import Generic, TypeVar
7+
88
from dissect.target.helpers.sunrpc import sunrpc
99

1010
ProcedureParams = TypeVar("ProcedureParams")

0 commit comments

Comments
 (0)