Skip to content

Commit 41060bb

Browse files
committed
Implement poc nfs
1 parent 3a3a0c4 commit 41060bb

File tree

7 files changed

+259
-43
lines changed

7 files changed

+259
-43
lines changed

dissect/target/helpers/nfs/client.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
from typing import Generic, NamedTuple, TypeVar
1+
from __future__ import annotations
2+
3+
from typing import Generic, Iterator, NamedTuple, TypeVar
24

35
from dissect.target.helpers.nfs.nfs import (
46
CookieVerf3,
57
EntryPlus3,
68
FileAttributes3,
79
FileHandle3,
810
NfsStat,
11+
Read3args,
912
ReadDirPlusParams,
1013
)
1114
from dissect.target.helpers.nfs.serializer import (
15+
Read3ArgsSerializer,
16+
Read3ResultDeserializer,
1217
ReadDirPlusParamsSerializer,
1318
ReadDirPlusResultDeserializer,
1419
)
@@ -19,6 +24,10 @@
1924
Verifier = TypeVar("Verifier")
2025

2126

27+
class ReadFileError(Exception):
28+
pass
29+
30+
2231
class ReadDirResult(NamedTuple):
2332
dir_attributes: FileAttributes3 | None
2433
entries: list[EntryPlus3]
@@ -30,6 +39,7 @@ class ReadDirResult(NamedTuple):
3039
class Client(Generic[Credentials, Verifier]):
3140
DIR_COUNT = 4096 # See https://datatracker.ietf.org/doc/html/rfc1813#section-3.3.17
3241
MAX_COUNT = 32768
42+
READ_CHUNK_SIZE = 1024 * 1024
3343

3444
def __init__(self, rpc_client: SunRpcClient[Credentials, Verifier]):
3545
self._rpc_client = rpc_client
@@ -39,7 +49,7 @@ def connect(cls, hostname: str, port: int, auth: AuthScheme[Credentials, Verifie
3949
rpc_client = SunRpcClient.connect(hostname, port, auth, local_port)
4050
return Client(rpc_client)
4151

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

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

6272
cookie = result.entries[-1].cookie
6373
cookieverf = result.cookieverf
74+
75+
def readfile_by_handle(self, handle: FileHandle3) -> Iterator[bytes]:
76+
"""Read a file by its file handle"""
77+
offset = 0
78+
count = self.READ_CHUNK_SIZE
79+
while True:
80+
params = Read3args(handle, offset, count)
81+
result = self._rpc_client.call(100003, 3, 6, params, Read3ArgsSerializer(), Read3ResultDeserializer())
82+
if isinstance(result, NfsStat):
83+
raise ReadFileError(f"Failed to read file: {result}")
84+
yield result.data
85+
if result.eof:
86+
return
87+
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: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass
24
from enum import Enum
35
from typing import ClassVar
46

7+
# See https://datatracker.ietf.org/doc/html/rfc1057
8+
59

610
class NfsStat(Enum):
711
NFS3_OK = 0
@@ -125,7 +129,7 @@ class EntryPlus3:
125129
fileid: int
126130
name: str
127131
cookie: int
128-
attrs: FileAttributes3 | None
132+
attributes: FileAttributes3 | None
129133
handle: FileHandle3 | None
130134

131135

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

dissect/target/helpers/nfs/serializer.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import io
24

35
from dissect.target.helpers.nfs.nfs import (
@@ -10,6 +12,8 @@
1012
MountStat,
1113
NfsStat,
1214
NfsTime3,
15+
Read3args,
16+
Read3resok,
1317
ReadDirPlusParams,
1418
ReadDirPlusResult3,
1519
SpecData3,
@@ -25,13 +29,13 @@
2529

2630
class MountResultDeserializer(Deserializer[MountOK | MountStat]):
2731
def deserialize(self, payload: io.BytesIO) -> MountOK | None:
28-
mountStat = self._read_enum(payload, MountStat)
29-
if mountStat != MountStat.MNT3_OK:
30-
return mountStat
32+
mount_stat = self._read_enum(payload, MountStat)
33+
if mount_stat != MountStat.MNT3_OK:
34+
return mount_stat
3135
filehandle_bytes = self._read_var_length_opaque(payload)
32-
authFlavors = self._read_var_length(payload, Int32Serializer())
36+
auth_flavors = self._read_var_length(payload, Int32Serializer())
3337

34-
return MountOK(FileHandle3(filehandle_bytes), authFlavors)
38+
return MountOK(FileHandle3(filehandle_bytes), auth_flavors)
3539

3640

3741
class ReadDirPlusParamsSerializer(Serializer[ReadDirPlusParams]):
@@ -73,10 +77,10 @@ def deserialize(self, payload: io.BytesIO) -> FileAttributes3:
7377
rdev = SpecDataSerializer().deserialize(payload)
7478
fsid = self._read_uint64(payload)
7579
fileid = self._read_uint64(payload)
76-
timeDeserializer = NfsTimeSerializer()
77-
atime = timeDeserializer.deserialize(payload)
78-
mtime = timeDeserializer.deserialize(payload)
79-
ctime = timeDeserializer.deserialize(payload)
80+
time_deserializer = NfsTimeSerializer()
81+
atime = time_deserializer.deserialize(payload)
82+
mtime = time_deserializer.deserialize(payload)
83+
ctime = time_deserializer.deserialize(payload)
8084

8185
return FileAttributes3(type, mode, nlink, uid, gid, size, used, rdev, fsid, fileid, atime, mtime, ctime)
8286

@@ -110,6 +114,27 @@ def deserialize(self, payload: io.BytesIO) -> ReadDirPlusResult3:
110114

111115
entries.append(entry)
112116

113-
eof = self._read_enum(payload, Bool)
117+
eof = self._read_enum(payload, Bool) == Bool.TRUE
114118

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

dissect/target/helpers/sunrpc/client.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from __future__ import annotations
2+
3+
import random
14
import socket
25
from typing import Generic, NamedTuple, TypeVar
36

47
from dissect.target.helpers.sunrpc import sunrpc
5-
import random
68
from dissect.target.helpers.sunrpc.serializer import (
79
AuthNullSerializer,
810
AuthSerializer,
@@ -43,6 +45,7 @@ def auth_unix(machine: str | None, uid: int, gid: int, gids: list[int]) -> AuthS
4345
)
4446

4547

48+
# RdJ: Error handing is a bit minimalistic. Expand later on.
4649
class MismatchXidError(Exception):
4750
pass
4851

@@ -139,8 +142,10 @@ def _receive(self) -> bytes:
139142

140143
fragment_header = int.from_bytes(header, "big")
141144
fragment_size = fragment_header & 0x7FFFFFFF
142-
fragment = self._sock.recv(fragment_size)
143-
fragments.append(fragment)
145+
while fragment_size > 0:
146+
fragment = self._sock.recv(fragment_size)
147+
fragments.append(fragment)
148+
fragment_size -= len(fragment)
144149

145150
# Check for last fragment or underflow
146151
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)