From 3e96e96cab01e8ec79ba6a6bdbdbb35718626eab Mon Sep 17 00:00:00 2001 From: Roel de Jong <12800443+twiggler@users.noreply.github.com> Date: Fri, 17 Jan 2025 14:06:26 +0100 Subject: [PATCH] Implement poc nfs --- dissect/target/helpers/nfs/client.py | 26 ++- dissect/target/helpers/nfs/demo.py | 25 ++- dissect/target/helpers/nfs/nfs.py | 20 ++- dissect/target/helpers/nfs/serializer.py | 25 ++- dissect/target/helpers/sunrpc/client.py | 9 +- dissect/target/helpers/sunrpc/serializer.py | 4 +- tests/helpers/sunrpc/test_client.py | 166 ++++++++++++++++++-- 7 files changed, 241 insertions(+), 34 deletions(-) diff --git a/dissect/target/helpers/nfs/client.py b/dissect/target/helpers/nfs/client.py index d7d630fd3..41a187bd2 100644 --- a/dissect/target/helpers/nfs/client.py +++ b/dissect/target/helpers/nfs/client.py @@ -1,4 +1,4 @@ -from typing import Generic, NamedTuple, TypeVar +from typing import Generic, Iterator, NamedTuple, TypeVar from dissect.target.helpers.nfs.nfs import ( CookieVerf3, @@ -6,9 +6,12 @@ FileAttributes3, FileHandle3, NfsStat, + Read3args, ReadDirPlusParams, ) from dissect.target.helpers.nfs.serializer import ( + Read3ArgsSerializer, + Read3ResultDeserializer, ReadDirPlusParamsSerializer, ReadDirPlusResultDeserializer, ) @@ -19,6 +22,10 @@ Verifier = TypeVar("Verifier") +class ReadFileError(Exception): + pass + + class ReadDirResult(NamedTuple): dir_attributes: FileAttributes3 | None entries: list[EntryPlus3] @@ -30,6 +37,7 @@ class ReadDirResult(NamedTuple): class Client(Generic[Credentials, Verifier]): DIR_COUNT = 4096 # See https://datatracker.ietf.org/doc/html/rfc1813#section-3.3.17 MAX_COUNT = 32768 + READ_CHUNK_SIZE = 1024 * 1024 def __init__(self, rpc_client: SunRpcClient[Credentials, Verifier]): self._rpc_client = rpc_client @@ -39,7 +47,7 @@ def connect(cls, hostname: str, port: int, auth: AuthScheme[Credentials, Verifie rpc_client = SunRpcClient.connect(hostname, port, auth, local_port) return Client(rpc_client) - def readdirplus(self, dir: FileHandle3) -> list[EntryPlus3] | NfsStat: + def readdirplus(self, dir: FileHandle3) -> ReadDirResult | NfsStat: """Read the contents of a directory, including file attributes""" entries = list[EntryPlus3]() @@ -61,3 +69,17 @@ def readdirplus(self, dir: FileHandle3) -> list[EntryPlus3] | NfsStat: cookie = result.entries[-1].cookie cookieverf = result.cookieverf + + def readfile_by_handle(self, handle: FileHandle3) -> Iterator[bytes]: + """Read a file by its file handle""" + offset = 0 + count = self.READ_CHUNK_SIZE + while True: + params = Read3args(handle, offset, count) + result = self._rpc_client.call(100003, 3, 6, params, Read3ArgsSerializer(), Read3ResultDeserializer()) + if isinstance(result, NfsStat): + raise ReadFileError(f"Failed to read file: {result}") + yield result.data + if result.eof: + return + offset += result.count diff --git a/dissect/target/helpers/nfs/demo.py b/dissect/target/helpers/nfs/demo.py index 5a6d8e9bb..94c0fca43 100644 --- a/dissect/target/helpers/nfs/demo.py +++ b/dissect/target/helpers/nfs/demo.py @@ -1,7 +1,9 @@ import argparse -from dissect.target.helpers.sunrpc.client import Client, auth_null, auth_unix -from dissect.target.helpers.nfs.serializer import MountResultDeserializer + from dissect.target.helpers.nfs.client import Client as NfsClient +from dissect.target.helpers.nfs.nfs import EntryPlus3 +from dissect.target.helpers.nfs.serializer import MountResultDeserializer +from dissect.target.helpers.sunrpc.client import Client, auth_null, auth_unix from dissect.target.helpers.sunrpc.serializer import ( PortMappingSerializer, StringSerializer, @@ -16,9 +18,6 @@ NFS_PROGRAM = 100003 NFS_V3 = 3 -hostname = "localhost" -root = "/home/roel" - # NFS client demo, showing how to connect to an NFS server and list the contents of a directory # Note: some nfs servers require connecting using a low port number (use --port) @@ -29,6 +28,7 @@ def main(): parser.add_argument("--port", type=int, default=0, help="The local port to bind to (default: 0)") parser.add_argument("--uid", type=int, default=1000, help="The user id to use for authentication") parser.add_argument("--gid", type=int, default=1000, help="The group id to use for authentication") + parser.add_argument("--index", type=int, default=0, help="The index of the file to read (starting at 1)") args = parser.parse_args() # RdJ: Perhaps move portmapper to nfs client and cache the mapping @@ -38,16 +38,25 @@ def main(): params_nfs = PortMapping(program=NFS_PROGRAM, version=NFS_V3, protocol=Protocol.TCP) nfs_port = port_mapper_client.call(100000, 2, 3, params_nfs, PortMappingSerializer(), UInt32Serializer()) - mount_client = Client.connect(hostname, mount_port, auth_null(), args.port) + mount_client = Client.connect(args.hostname, mount_port, auth_null(), args.port) mount_result = mount_client.call( MOUNT_PROGRAM, MOUNT_V3, MOUNT, args.root, StringSerializer(), MountResultDeserializer() ) mount_client.close() auth = auth_unix("twigtop", args.uid, args.gid, []) - nfs_client = NfsClient.connect(hostname, nfs_port, auth, args.port) + nfs_client = NfsClient.connect(args.hostname, nfs_port, auth, args.port) readdir_result = nfs_client.readdirplus(mount_result.filehandle) - print(readdir_result) + for index, entry in enumerate(readdir_result.entries, start=1): + if entry.attributes: + print(f"{index:<5} {entry.name:<30} {entry.attributes.size:<10}") + + file_entry: EntryPlus3 = readdir_result.entries[args.index - 1] + if file_entry.attributes: + file_contents = nfs_client.readfile_by_handle(file_entry.handle) + with open(file_entry.name, "wb") as f: + for chunk in file_contents: + f.write(chunk) if __name__ == "__main__": diff --git a/dissect/target/helpers/nfs/nfs.py b/dissect/target/helpers/nfs/nfs.py index 07e342755..8acc911ae 100644 --- a/dissect/target/helpers/nfs/nfs.py +++ b/dissect/target/helpers/nfs/nfs.py @@ -3,6 +3,9 @@ from typing import ClassVar +# See https://datatracker.ietf.org/doc/html/rfc1057 + + class NfsStat(Enum): NFS3_OK = 0 NFS3ERR_PERM = 1 @@ -125,7 +128,7 @@ class EntryPlus3: fileid: int name: str cookie: int - attrs: FileAttributes3 | None + attributes: FileAttributes3 | None handle: FileHandle3 | None @@ -135,3 +138,18 @@ class ReadDirPlusResult3: cookieverf: CookieVerf3 entries: list[EntryPlus3] eof: bool + + +@dataclass +class Read3args: + file: FileHandle3 + offset: int + count: int + + +@dataclass +class Read3resok: + file_attributes: FileAttributes3 | None + count: int + eof: bool + data: bytes diff --git a/dissect/target/helpers/nfs/serializer.py b/dissect/target/helpers/nfs/serializer.py index 019c94b1e..b2b5600f0 100644 --- a/dissect/target/helpers/nfs/serializer.py +++ b/dissect/target/helpers/nfs/serializer.py @@ -10,6 +10,8 @@ MountStat, NfsStat, NfsTime3, + Read3args, + Read3resok, ReadDirPlusParams, ReadDirPlusResult3, SpecData3, @@ -110,6 +112,27 @@ def deserialize(self, payload: io.BytesIO) -> ReadDirPlusResult3: entries.append(entry) - eof = self._read_enum(payload, Bool) + eof = self._read_enum(payload, Bool) == Bool.TRUE return ReadDirPlusResult3(dir_attributes, CookieVerf3(cookieverf), entries, eof) + + +class Read3ArgsSerializer(Serializer[ReadDirPlusParams]): + def serialize(self, args: Read3args) -> bytes: + result = self._write_var_length_opaque(args.file.opaque) + result += self._write_uint64(args.offset) + result += self._write_uint32(args.count) + return result + + +class Read3ResultDeserializer(Deserializer[Read3resok]): + def deserialize(self, payload: io.BytesIO) -> Read3resok: + stat = self._read_enum(payload, NfsStat) + if stat != NfsStat.NFS3_OK: + return stat + + file_attributes = self._read_optional(payload, FileAttributesSerializer()) + count = self._read_uint32(payload) + eof = self._read_enum(payload, Bool) == Bool.TRUE + data = self._read_var_length_opaque(payload) + return Read3resok(file_attributes, count, eof, data) diff --git a/dissect/target/helpers/sunrpc/client.py b/dissect/target/helpers/sunrpc/client.py index c78dad391..3b7cdaf05 100644 --- a/dissect/target/helpers/sunrpc/client.py +++ b/dissect/target/helpers/sunrpc/client.py @@ -1,8 +1,8 @@ +import random import socket from typing import Generic, NamedTuple, TypeVar from dissect.target.helpers.sunrpc import sunrpc -import random from dissect.target.helpers.sunrpc.serializer import ( AuthNullSerializer, AuthSerializer, @@ -43,6 +43,7 @@ def auth_unix(machine: str | None, uid: int, gid: int, gids: list[int]) -> AuthS ) +# RdJ: Error handing is a bit minimalistic. Expand later on. class MismatchXidError(Exception): pass @@ -139,8 +140,10 @@ def _receive(self) -> bytes: fragment_header = int.from_bytes(header, "big") fragment_size = fragment_header & 0x7FFFFFFF - fragment = self._sock.recv(fragment_size) - fragments.append(fragment) + while fragment_size > 0: + fragment = self._sock.recv(fragment_size) + fragments.append(fragment) + fragment_size -= len(fragment) # Check for last fragment or underflow if (fragment_header & 0x80000000) > 0 or len(fragment) < fragment_size: diff --git a/dissect/target/helpers/sunrpc/serializer.py b/dissect/target/helpers/sunrpc/serializer.py index 528b78d3a..b3f595d4e 100644 --- a/dissect/target/helpers/sunrpc/serializer.py +++ b/dissect/target/helpers/sunrpc/serializer.py @@ -1,10 +1,10 @@ from __future__ import annotations - +import io from abc import ABC, abstractmethod from enum import Enum -import io from typing import Generic, TypeVar + from dissect.target.helpers.sunrpc import sunrpc ProcedureParams = TypeVar("ProcedureParams") diff --git a/tests/helpers/sunrpc/test_client.py b/tests/helpers/sunrpc/test_client.py index f96aae230..d1267dd10 100644 --- a/tests/helpers/sunrpc/test_client.py +++ b/tests/helpers/sunrpc/test_client.py @@ -1,10 +1,26 @@ from unittest.mock import MagicMock, patch + import pytest -from dissect.target.helpers.nfs.nfs import FileHandle3, MountOK + +from dissect.target.helpers.nfs.client import Client as NfsClient +from dissect.target.helpers.nfs.client import ReadDirResult +from dissect.target.helpers.nfs.nfs import ( + EntryPlus3, + FileAttributes3, + FileHandle3, + FileType3, + MountOK, + NfsTime3, + SpecData3, +) from dissect.target.helpers.nfs.serializer import MountResultDeserializer from dissect.target.helpers.sunrpc import sunrpc -from dissect.target.helpers.sunrpc.client import Client, auth_null -from dissect.target.helpers.sunrpc.serializer import PortMappingSerializer, StringSerializer, UInt32Serializer +from dissect.target.helpers.sunrpc.client import Client, auth_null, auth_unix +from dissect.target.helpers.sunrpc.serializer import ( + PortMappingSerializer, + StringSerializer, + UInt32Serializer, +) from dissect.target.helpers.sunrpc.sunrpc import PortMapping @@ -14,10 +30,10 @@ def mock_socket(): yield mock_socket -def test_portmap_call(mock_socket) -> None: - portmap_request = b'\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x01\x86\xa0\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x86\xa3\x00\x00\x00\x03\x00\x00\x00\x06\x00\x00\x00\x00' # noqa: E501 +def test_portmap_call(mock_socket: MagicMock) -> None: + portmap_request = b"\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x01\x86\xa0\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x86\xa3\x00\x00\x00\x03\x00\x00\x00\x06\x00\x00\x00\x00" # noqa: E501 - portmap_response = b'\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\x01' # noqa: E501 + portmap_response = b"\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\x01" # noqa: E501 # Mock the socket instance mock_sock_instance = MagicMock() @@ -29,8 +45,8 @@ def test_portmap_call(mock_socket) -> None: portmap_params = PortMapping(program=100003, version=3, protocol=sunrpc.Protocol.TCP) # Set up the mock to return the response payload - portmap_response_fragment_header = (len(portmap_response) | 0x80000000).to_bytes(4, "big") - mock_sock_instance.recv.side_effect = [portmap_response_fragment_header, portmap_response] + response_fragment_header = (len(portmap_response) | 0x80000000).to_bytes(4, "big") + mock_sock_instance.recv.side_effect = [response_fragment_header, portmap_response] result = client.call(100000, 2, 3, portmap_params, PortMappingSerializer(), UInt32Serializer()) @@ -42,29 +58,145 @@ def test_portmap_call(mock_socket) -> None: assert result == 2049 -def test_mount_call(mock_socket) -> None: - mount_request = b'\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x01\x86\xa5\x00\x00\x00\x03\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\n/home/roel\x00\x00' # noqa: E501 +def test_mount_call(mock_socket: MagicMock) -> None: + mount_request = b"\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x01\x86\xa5\x00\x00\x00\x03\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\n/home/roel\x00\x00" # noqa: E501 - mount_response = b'\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1c\x01\x00\x07\x00\x02\x00\xec\x02\x00\x00\x00\x00\xb5g\x131&\xf1I\xed\xb8R\rx\\h8\xb4\x00\x00\x00\x01\x00\x00\x00\x01' # noqa: E501 + mount_response = b"\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1c\x01\x00\x07\x00\x02\x00\xec\x02\x00\x00\x00\x00\xb5g\x131&\xf1I\xed\xb8R\rx\\h8\xb4\x00\x00\x00\x01\x00\x00\x00\x01" # noqa: E501 # Mock the socket instance mock_sock_instance = MagicMock() mock_socket.return_value = mock_sock_instance # Set up the mock to return the response payload - portmap_response_fragment_header = (len(mount_response) | 0x80000000).to_bytes(4, "big") - mock_sock_instance.recv.side_effect = [portmap_response_fragment_header, mount_response] + mount_response_fragment_header = (len(mount_response) | 0x80000000).to_bytes(4, "big") + mock_sock_instance.recv.side_effect = [mount_response_fragment_header, mount_response] mount_client = Client.connect("localhost", 2049, auth_null()) - result = mount_client.call( - 100005, 3, 1, "/home/roel", StringSerializer(), MountResultDeserializer() - ) + result = mount_client.call(100005, 3, 1, "/home/roel", StringSerializer(), MountResultDeserializer()) # Verify that the request payload was sent portmap_request_fragment_header = (len(mount_request) | 0x80000000).to_bytes(4, "big") mock_sock_instance.sendall.assert_called_with(portmap_request_fragment_header + mount_request) # Verify that the result of the call equals the mount_result variable - assert result == MountOK(filehandle=FileHandle3(opaque=b'\x01\x00\x07\x00\x02\x00\xec\x02\x00\x00\x00\x00\xb5g\x131&\xf1I\xed\xb8R\rx\\h8\xb4'), authFlavors=[1]) # noqa: E501 + assert result == MountOK( + filehandle=FileHandle3( + opaque=b"\x01\x00\x07\x00\x02\x00\xec\x02\x00\x00\x00\x00\xb5g\x131&\xf1I\xed\xb8R\rx\\h8\xb4" + ), + authFlavors=[1], + ) + + +def test_readdir(mock_socket: MagicMock) -> None: + readdir_request = b"\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x01\x86\xa3\x00\x00\x00\x03\x00\x00\x00\x11\x00\x00\x00\x01\x00\x00\x00\x1cq\xd5\x93D\x00\x00\x00\x07twigtop\x00\x00\x00\x03\xe8\x00\x00\x03\xe8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1c\x01\x00\x07\x00\xda&\xee\x02\x00\x00\x00\x00\xb5g\x131&\xf1I\xed\xb8R\rx\\h8\xb4\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x80\x00" # noqa: E501 + readdir_response = b"\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x01\xfd\x00\x00\x00\x02\x00\x00\x03\xe8\x00\x00\x03\xe8\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00Yq\x99zI\x1e5\r\x00\x00\x00\x00\x02\xee&\xdag\x8ar\xba)\xd7\xba.g\x8ar\x96\x18\xd7\x91z;\x99\x07@\x9c_\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x01\xb4\x00\x00\x00\x01\x00\x00\x03\xe8\x00\x00\x03\xe8\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00Yq\x99zI\x1e5\r\x00\x00\x00\x00\x02\xee\x84\x19g\x8aqk\r\xa4\xb7\x8eg\x8aqg\x11\x93h\xf9g\x8aqg\x11\x93h\xf9\x00\x00\x00\x01\x00\x00\x00$\x01\x00\x07\x01\xda&\xee\x02\x00\x00\x00\x00\xb5g\x131&\xf1I\xed\xb8R\rx\\h8\xb4\x19\x84\xee\x02\xc1\x8a\x8c\\\x00\x00\x00\x01\x00\x00\x00\x00\x02\xee&\xda\x00\x00\x00\x01.\x00\x00\x00CjR\xafoN\x82\xf0\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x01\xfd\x00\x00\x00\x02\x00\x00\x03\xe8\x00\x00\x03\xe8\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00Yq\x99zI\x1e5\r\x00\x00\x00\x00\x02\xee&\xdag\x8ar\xba)\xd7\xba.g\x8ar\x96\x18\xd7\x91\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x01\xb4\x00\x00\x00\x01\x00\x00\x03\xe8\x00\x00\x03\xe8\x00\x00\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00Yq\x99zI\x1e5\r\x00\x00\x00\x00\x02\xee\x84\x07g\x8ar\x96\x18\xd7\x91