From 000514649ce7da9e98abc443827f8664e470b8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20J=2E=20Rodr=C3=ADguez?= Date: Sun, 14 Jun 2026 07:24:31 +0200 Subject: [PATCH 1/2] Add Windows XP/2003 network support to netscan windows.netscan previously raised NotImplementedError for Windows XP / Server 2003 (NT 5.1/5.2), since those versions use a different family of network pool structures (TCPT/TCPA) than Vista+ (TcpL/TcpE/UdpA). This folds XP/2003 support into netscan's existing version gating rather than adding separate plugins, so users get one unified network view across all versions. - netscan-winxp-x86.json: ISF defining _TCPT_OBJECT (tag TCPT, TCP connections) and _ADDRESS_OBJECT (tag TCPA, bound/listening sockets) for Windows XP SP2 x86. - extensions/network_xp.py: class_types exposing local/remote address, protocol, create time, and is_valid() filtering to reject tag-collision false positives. - netscan: determine_tcpip_version returns the XP ISF + class types for NT 5.x x86; create_netscan_constraints grows an XP (TCPT/TCPA) branch; the generator renders XP objects into the shared TreeGrid, resolving the Owner column via a one-shot pslist PID->name map. Validated on a Zeus XP SP2 image: netscan now lists 22 network objects (2 connections + 20 sockets) including the C2 connection 172.16.176.143:1054 -> 193.104.41.75:80 owned by svchost.exe (PID 856). The Vista+ path is unchanged (regression-checked on a Win7 x86 image: 102 rows, normal TcpL/TcpE/UdpA output). Note: PAE XP/Win7 images additionally require the separate PAE DTB detection fix for the kernel layer (and thus Owner-name resolution) to work. Co-Authored-By: Claude Opus 4.8 --- .../framework/plugins/windows/netscan.py | 110 ++++++++++++- .../symbols/windows/extensions/network_xp.py | 109 +++++++++++++ .../windows/netscan/netscan-winxp-x86.json | 146 ++++++++++++++++++ 3 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 volatility3/framework/symbols/windows/extensions/network_xp.py create mode 100644 volatility3/framework/symbols/windows/netscan/netscan-winxp-x86.json diff --git a/volatility3/framework/plugins/windows/netscan.py b/volatility3/framework/plugins/windows/netscan.py index fa422e103d..d2b193c360 100644 --- a/volatility3/framework/plugins/windows/netscan.py +++ b/volatility3/framework/plugins/windows/netscan.py @@ -12,9 +12,9 @@ from volatility3.framework.renderers import format_hints from volatility3.framework.symbols import intermed from volatility3.framework.symbols.windows import versions -from volatility3.framework.symbols.windows.extensions import network +from volatility3.framework.symbols.windows.extensions import network, network_xp from volatility3.plugins import timeliner -from volatility3.plugins.windows import info, poolscanner, verinfo +from volatility3.plugins.windows import info, poolscanner, pslist, verinfo vollog = logging.getLogger(__name__) @@ -23,7 +23,7 @@ class NetScan(interfaces.plugins.PluginInterface, timeliner.TimeLinerInterface): """Scans for network objects present in a particular windows memory image.""" _required_framework_version = (2, 0, 0) - _version = (2, 0, 0) + _version = (2, 1, 0) @classmethod def get_requirements(cls): @@ -39,6 +39,9 @@ def get_requirements(cls): requirements.VersionRequirement( name="info", component=info.Info, version=(2, 0, 0) ), + requirements.VersionRequirement( + name="pslist", component=pslist.PsList, version=(3, 0, 0) + ), requirements.VersionRequirement( name="timeliner", component=timeliner.TimeLinerInterface, @@ -69,6 +72,11 @@ def create_netscan_constraints( The list containing the built constraints. """ + # Windows XP / Server 2003 use the legacy TCPT/TCPA pool tags rather + # than the Vista+ TcpL/TcpE/UdpA tags, so they need their own scan. + if symbol_table.startswith("netscan-winxp"): + return cls.create_xp_netscan_constraints(symbol_table) + tcpl_size = context.symbol_space.get_type( symbol_table + constants.BANG + "_TCP_LISTENER" ).size @@ -118,6 +126,29 @@ def create_netscan_constraints( return constraints + @classmethod + def create_xp_netscan_constraints( + cls, symbol_table: str + ) -> List[poolscanner.PoolConstraint]: + """Pool constraints for the Windows XP / Server 2003 network objects: + TCPT (TCP connections) and TCPA (bound/listening sockets). + + These are tag-only constraints; false positives are rejected later by + the objects' ``is_valid()`` checks. + """ + return [ + poolscanner.PoolConstraint( + b"TCPT", + type_name=symbol_table + constants.BANG + "_TCPT_OBJECT", + size=(None, None), + ), + poolscanner.PoolConstraint( + b"TCPA", + type_name=symbol_table + constants.BANG + "_ADDRESS_OBJECT", + size=(None, None), + ), + ] + @classmethod def determine_tcpip_version( cls, @@ -179,6 +210,13 @@ def determine_tcpip_version( f"Determined OS Version: {kuser.NtMajorVersion}.{kuser.NtMinorVersion} {vers.MajorVersion}.{vers.MinorVersion}" ) + # Windows XP / Server 2003 (NT 5.x) use a different family of network + # pool structures (TCPT/TCPA) than Vista and later (TcpL/TcpE/UdpA), + # carried in a dedicated ISF with its own class types. + if nt_major_version == 5 and arch == "x86": + vollog.debug("Detected NT 5.x x86: using XP/2003 network structures") + return "netscan-winxp-x86", network_xp.class_types + if nt_major_version == 10 and arch == "x64": # win10 x64 has an additional class type we have to include. class_types = network.win10_x64_class_types @@ -391,6 +429,11 @@ def _generator(self, show_corrupt_results: Optional[bool] = None): self.context, self.config["kernel"], self.config_path ) + # XP/2003 objects only carry an owning PID, so resolve process names + # from a one-shot pslist walk to fill the Owner column. + is_xp = netscan_symbol_table.startswith("netscan-winxp") + pid_name_map = self._build_pid_name_map() if is_xp else {} + for netw_obj in self.scan( self.context, self.config["kernel"], @@ -403,6 +446,10 @@ def _generator(self, show_corrupt_results: Optional[bool] = None): if not show_corrupt_results and not netw_obj.is_valid(): continue + if is_xp: + yield from self._generate_xp_rows(netw_obj, pid_name_map) + continue + if isinstance(netw_obj, network._UDP_ENDPOINT): vollog.debug(f"Found UDP_ENDPOINT @ 0x{netw_obj.vol.offset:2x}") @@ -483,6 +530,63 @@ def _generator(self, show_corrupt_results: Optional[bool] = None): f"Found network object unsure of its type: {netw_obj} of type {type(netw_obj)}" ) + def _build_pid_name_map(self): + """Maps PID -> process image name via a single pslist walk, used to + populate the Owner column for XP/2003 network objects.""" + pid_name_map = {} + for proc in pslist.PsList.list_processes(self.context, self.config["kernel"]): + try: + pid_name_map[int(proc.UniqueProcessId)] = proc.ImageFileName.cast( + "string", + max_length=proc.ImageFileName.vol.count, + errors="replace", + ) + except exceptions.InvalidAddressException: + continue + return pid_name_map + + def _generate_xp_rows(self, netw_obj, pid_name_map): + """Renders an XP/2003 _TCPT_OBJECT or _ADDRESS_OBJECT into the shared + netscan TreeGrid row format.""" + pid = int(netw_obj.Pid) + owner = pid_name_map.get(pid) or renderers.UnreadableValue() + + if isinstance(netw_obj, network_xp._TCPT_OBJECT): + yield ( + 0, + ( + format_hints.Hex(netw_obj.vol.offset), + "TCPv4", + netw_obj.get_local_address() or renderers.UnreadableValue(), + int(netw_obj.LocalPort), + netw_obj.get_remote_address() or renderers.UnreadableValue(), + int(netw_obj.RemotePort), + # XP connection pool objects don't carry a recoverable state + renderers.NotApplicableValue(), + pid, + owner, + renderers.NotApplicableValue(), + ), + ) + elif isinstance(netw_obj, network_xp._ADDRESS_OBJECT): + proto = netw_obj.get_protocol() + state = "LISTENING" if proto == "TCP" else renderers.NotApplicableValue() + yield ( + 0, + ( + format_hints.Hex(netw_obj.vol.offset), + proto + "v4", + netw_obj.get_local_address() or renderers.UnreadableValue(), + int(netw_obj.LocalPort), + "*", + 0, + state, + pid, + owner, + netw_obj.get_create_time(), + ), + ) + def generate_timeline(self): for row in self._generator(): _depth, row_data = row diff --git a/volatility3/framework/symbols/windows/extensions/network_xp.py b/volatility3/framework/symbols/windows/extensions/network_xp.py new file mode 100644 index 0000000000..0db15636cd --- /dev/null +++ b/volatility3/framework/symbols/windows/extensions/network_xp.py @@ -0,0 +1,109 @@ +# This file is Copyright 2026 Volatility Foundation and licensed under the Volatility Software License 1.0 +# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 +# + +import logging +import socket +from typing import Optional + +from volatility3.framework import interfaces, objects, renderers +from volatility3.framework.renderers import conversion + +vollog = logging.getLogger(__name__) + +# IPPROTO values stored in _ADDRESS_OBJECT.Protocol +PROTO_NAMES = {6: "TCP", 17: "UDP"} + + +def _ip4(addr) -> str: + """Render a raw 4-byte IPv4 address (network order) as dotted-quad.""" + return socket.inet_ntop(socket.AF_INET, bytes(addr)) + + +class _TCPT_OBJECT(objects.StructType): + """A TCP connection object (pool tag ``TCPT``) used by tcpip.sys on + Windows XP / Server 2003 (x86).""" + + def get_local_address(self) -> Optional[str]: + try: + return _ip4(self.LocalIpAddress) + except (ValueError, OSError): + return None + + def get_remote_address(self) -> Optional[str]: + try: + return _ip4(self.RemoteIpAddress) + except (ValueError, OSError): + return None + + def get_owner_pid(self): + return self.Pid + + def is_valid(self) -> bool: + # Reject obvious false positives: PIDs are small, multiples of 4 on + # XP, and a real connection has at least one non-zero endpoint. + try: + pid = int(self.Pid) + if pid <= 0 or pid > 0xFFFF or pid % 4 != 0: + return False + if int(self.LocalPort) == 0 and int(self.RemotePort) == 0: + return False + local = self.get_local_address() + remote = self.get_remote_address() + if local is None or remote is None: + return False + if local == "0.0.0.0" and remote == "0.0.0.0": + return False + except Exception: + return False + return True + + +class _ADDRESS_OBJECT(objects.StructType): + """A bound/listening socket object (pool tag ``TCPA``) used by tcpip.sys + on Windows XP / Server 2003 (x86).""" + + def get_local_address(self) -> Optional[str]: + try: + return _ip4(self.LocalIpAddress) + except (ValueError, OSError): + return None + + def get_protocol(self) -> str: + return PROTO_NAMES.get(int(self.Protocol), str(int(self.Protocol))) + + def get_owner_pid(self): + return self.Pid + + def get_create_time(self): + try: + if int(self.CreateTime) == 0: + return renderers.NotApplicableValue() + return conversion.wintime_to_datetime(self.CreateTime) + except Exception: + return renderers.UnreadableValue() + + def is_valid(self) -> bool: + try: + pid = int(self.Pid) + # A real socket is owned by a process (never the Idle PID 0) and + # XP PIDs are small multiples of 4. + if pid <= 0 or pid > 0xFFFF or pid % 4 != 0: + return False + # Only TCP/UDP are tracked via _ADDRESS_OBJECT; anything else is a + # false positive from tag collision. + if int(self.Protocol) not in PROTO_NAMES: + return False + if int(self.LocalPort) == 0: + return False + if self.get_local_address() is None: + return False + except Exception: + return False + return True + + +class_types = { + "_TCPT_OBJECT": _TCPT_OBJECT, + "_ADDRESS_OBJECT": _ADDRESS_OBJECT, +} diff --git a/volatility3/framework/symbols/windows/netscan/netscan-winxp-x86.json b/volatility3/framework/symbols/windows/netscan/netscan-winxp-x86.json new file mode 100644 index 0000000000..9d6001bbbb --- /dev/null +++ b/volatility3/framework/symbols/windows/netscan/netscan-winxp-x86.json @@ -0,0 +1,146 @@ +{ + "base_types": { + "unsigned long": { + "kind": "int", + "size": 4, + "signed": false, + "endian": "little" + }, + "unsigned short": { + "kind": "int", + "size": 2, + "signed": false, + "endian": "little" + }, + "unsigned be short": { + "kind": "int", + "size": 2, + "signed": false, + "endian": "big" + }, + "unsigned char": { + "kind": "char", + "size": 1, + "signed": false, + "endian": "little" + }, + "long long": { + "kind": "int", + "size": 8, + "signed": true, + "endian": "little" + }, + "pointer": { + "kind": "int", + "size": 4, + "signed": false, + "endian": "little" + } + }, + "user_types": { + "_TCPT_OBJECT": { + "kind": "struct", + "size": 32, + "fields": { + "RemoteIpAddress": { + "offset": 12, + "type": { + "kind": "array", + "count": 4, + "subtype": { + "kind": "base", + "name": "unsigned char" + } + } + }, + "LocalIpAddress": { + "offset": 16, + "type": { + "kind": "array", + "count": 4, + "subtype": { + "kind": "base", + "name": "unsigned char" + } + } + }, + "RemotePort": { + "offset": 20, + "type": { + "kind": "base", + "name": "unsigned be short" + } + }, + "LocalPort": { + "offset": 22, + "type": { + "kind": "base", + "name": "unsigned be short" + } + }, + "Pid": { + "offset": 24, + "type": { + "kind": "base", + "name": "unsigned long" + } + } + } + }, + "_ADDRESS_OBJECT": { + "kind": "struct", + "size": 352, + "fields": { + "LocalIpAddress": { + "offset": 44, + "type": { + "kind": "array", + "count": 4, + "subtype": { + "kind": "base", + "name": "unsigned char" + } + } + }, + "LocalPort": { + "offset": 48, + "type": { + "kind": "base", + "name": "unsigned be short" + } + }, + "Protocol": { + "offset": 50, + "type": { + "kind": "base", + "name": "unsigned short" + } + }, + "Pid": { + "offset": 328, + "type": { + "kind": "base", + "name": "unsigned long" + } + }, + "CreateTime": { + "offset": 344, + "type": { + "kind": "base", + "name": "long long" + } + } + } + } + }, + "symbols": {}, + "enums": {}, + "metadata": { + "producer": { + "version": "0.0.1", + "name": "volatility3-xp-network-poc", + "datetime": "2026-06-14T00:00:00" + }, + "format": "6.0.0" + } +} \ No newline at end of file From a5af1346d7ec23a78929b950a5d67d807876cb36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20J=2E=20Rodr=C3=ADguez?= Date: Sun, 14 Jun 2026 07:39:05 +0200 Subject: [PATCH 2/2] netstat: cleanly reject Windows XP / Server 2003 (NT 5.x) netstat performs active enumeration by walking tcpip.sys's live tracking structures (partition tables, port bitmaps), which only exist on Vista+. Since netscan's symbol-table loader now returns an XP ISF for NT 5.x (rather than raising), netstat on XP would otherwise fail with a confusing "PartitionTable: Unknown symbol" error. Add an explicit NT 5.x guard that warns and points the user to windows.netscan (which does support XP via TCPT/TCPA pool scanning). Vista+ behaviour is unchanged (regression-checked on a Win7 x86 image). Co-Authored-By: Claude Opus 4.8 --- volatility3/framework/plugins/windows/netstat.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/volatility3/framework/plugins/windows/netstat.py b/volatility3/framework/plugins/windows/netstat.py index cf7f5272ac..c55bc9dbf2 100644 --- a/volatility3/framework/plugins/windows/netstat.py +++ b/volatility3/framework/plugins/windows/netstat.py @@ -633,6 +633,20 @@ def _generator(self, show_corrupt_results: Optional[bool] = None): kernel = self.context.modules[self.config["kernel"]] + # netstat performs *active* enumeration by walking tcpip.sys's live + # tracking structures (partition tables, port bitmaps), which only exist + # on Vista and later. Windows XP / Server 2003 (NT 5.x) use an entirely + # different design, so direct the user to netscan (pool scanning) which + # does support those versions. + kuser = info.Info.get_kuser_structure(self.context, self.config["kernel"]) + if int(kuser.NtMajorVersion) == 5: + vollog.warning( + "windows.netstat does not support Windows XP / Server 2003 " + "(NT 5.x); use windows.netscan instead, which scans the legacy " + "TCPT/TCPA pool allocations." + ) + return + netscan_symbol_table = netscan.NetScan.create_netscan_symbol_table( self.context, self.config["kernel"], self.config_path )