Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 107 additions & 3 deletions volatility3/framework/plugins/windows/netscan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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):
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand All @@ -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}")

Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions volatility3/framework/plugins/windows/netstat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
109 changes: 109 additions & 0 deletions volatility3/framework/symbols/windows/extensions/network_xp.py
Original file line number Diff line number Diff line change
@@ -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,
}
Loading