diff --git a/CHANGELOG.md b/CHANGELOG.md index af604b4..cb7e31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - PowerShell Remoting transport over WinRM (pypsrp), one runspace pool per connection. - `get_facts`, `get_interfaces`, `get_interfaces_ip`, `get_arp_table`, `get_route_to`. - `get_services` and `manage_service` (start, stop, restart, enable, disable). +- `get_listening_sockets`: every listening TCP and bound UDP socket with its process and, + where the process runs one Windows service, that service (netOrk #673). Fixture synthetic. - Discovery fingerprints: WinRM ports 5985/5986, `Microsoft-HTTPAPI` / `Microsoft-IIS` server headers, Windows SNMP `sysDescr`, OpenSSH-for-Windows banner. - `tools/harvest.py` to record real script output as test fixtures. diff --git a/README.md b/README.md index a21cc6f..80358ac 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ handshake once. | `get_arp_table` | `Get-NetNeighbor -AddressFamily IPv4` (broadcast, multicast, unreachable dropped) | | `get_route_to` | `Get-NetRoute` (multicast, broadcast, loopback dropped) | | `get_services` / `manage_service` | `Win32_Service`, `Start-/Stop-/Restart-Service`, `Set-Service -StartupType` | +| `get_listening_sockets` | `Get-NetTCPConnection -State Listen`, `Get-NetUDPEndpoint`, `Get-Process`, `Win32_Service` (a shared svchost names no service) | `running_kernel` carries the full build including the update revision (`10.0.20348.2340`): Windows ships fixes as UBR bumps, which is what CVE diff --git a/napalm_windows/windows.py b/napalm_windows/windows.py index 6ac4ec9..4f85b02 100644 --- a/napalm_windows/windows.py +++ b/napalm_windows/windows.py @@ -24,7 +24,7 @@ from typing import Any from napalm.base.exceptions import ConnectionClosedException from napalm_device_types import FingerprintRule, OSDriver, PortSpec -from napalm_device_types.models import ServiceDict +from napalm_device_types.models import ListeningSocketDict, ListeningSocketsDict, ServiceDict from napalm_windows.transport import PowerShellError, PsrpTransport @@ -134,6 +134,42 @@ ConvertTo-Json -Compress -Depth 3 -InputObject @( ) """ +# Every listening TCP and bound UDP socket, the processes holding them and the +# services those processes run. Sockets owned by http.sys (WinRM, IIS) belong to +# the System process; a shared svchost runs several services in one process. +_PS_LISTENING = r""" +$ErrorActionPreference = 'Stop' +$tcp = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue) +$udp = @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue) +$ids = @(@($tcp) + @($udp) | ForEach-Object { [int]$_.OwningProcess } | Sort-Object -Unique) +ConvertTo-Json -Compress -Depth 3 -InputObject ([pscustomobject]@{ + tcp = @($tcp | ForEach-Object { + [pscustomobject]@{ + address = [string]$_.LocalAddress + port = [int]$_.LocalPort + pid = [int]$_.OwningProcess + } + }) + udp = @($udp | ForEach-Object { + [pscustomobject]@{ + address = [string]$_.LocalAddress + port = [int]$_.LocalPort + pid = [int]$_.OwningProcess + } + }) + processes = @(if ($ids.Count) { + Get-Process -Id $ids -ErrorAction SilentlyContinue | ForEach-Object { + [pscustomobject]@{ pid = [int]$_.Id; name = $_.ProcessName } + } + }) + services = @( + Get-CimInstance -ClassName Win32_Service -Filter 'ProcessId <> 0' | ForEach-Object { + [pscustomobject]@{ pid = [int]$_.ProcessId; name = $_.Name } + } + ) +}) +""" + _SERVICE_COMMANDS = { "start": "Start-Service -Name {name}", "stop": "Stop-Service -Name {name}", @@ -198,6 +234,27 @@ def _is_real_neighbour_mac(mac: str) -> bool: return not int(mac.split(":", 1)[0], 16) & 1 +def _listening_socket( + proto: str, entry: dict[str, Any], names: dict[int, str], owners: dict[int, list[str]] +) -> ListeningSocketDict: + """One socket of ``_PS_LISTENING``. A process running one service names it; a + shared svchost runs several, and which of them holds the socket Windows does + not say. PID 0 is no process.""" + pid = int(entry.get("pid") or 0) + address, _, zone = str(entry.get("address") or "").partition("%") + services = owners.get(pid, []) + return { + "proto": proto, + "address": address, + "port": int(entry.get("port") or 0), + "interface": zone or None, + "process": names.get(pid) if pid else None, + "pid": pid or None, + "unit": services[0] if len(services) == 1 else None, + "container_id": None, + } + + def _is_noise_route(destination: str) -> bool: """Multicast, limited broadcast and loopback — present on every host.""" try: @@ -444,6 +501,24 @@ class WindowsDriver(OSDriver): ) return services + def get_listening_sockets(self) -> ListeningSocketsDict: + """Every listening TCP and bound UDP socket, with the process and the + Windows service behind it (``Get-NetTCPConnection``, ``Get-NetUDPEndpoint``, + ``Win32_Service``). WinRM needs an administrator, who sees every + process, so the reading is ``attributed``.""" + raw = self._run_ps(_PS_LISTENING) or {} + names = {int(p["pid"]): str(p["name"]) for p in _as_list(raw.get("processes"))} + owners: dict[int, list[str]] = {} + for service in _as_list(raw.get("services")): + owners.setdefault(int(service["pid"]), []).append(str(service["name"])) + sockets = [ + _listening_socket(proto, entry, names, owners) + for proto in ("tcp", "udp") + for entry in _as_list(raw.get(proto)) + ] + sockets.sort(key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or "")) + return {"attributed": True, "sockets": sockets} + def manage_service(self, name: str, action: str) -> dict[str, Any]: template = _SERVICE_COMMANDS.get(action) if template is None: diff --git a/pyproject.toml b/pyproject.toml index 35c212c..892b7a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ ] dependencies = [ "napalm>=4.0", - "napalm-device-types>=0.5.0", + "napalm-device-types>=2.4.0", "pypsrp>=0.8", ] diff --git a/tests/fixtures/synthetic/listening.json b/tests/fixtures/synthetic/listening.json new file mode 100644 index 0000000..9e72998 --- /dev/null +++ b/tests/fixtures/synthetic/listening.json @@ -0,0 +1,31 @@ +{ + "tcp": [ + {"address": "0.0.0.0", "port": 135, "pid": 912}, + {"address": "::", "port": 135, "pid": 912}, + {"address": "0.0.0.0", "port": 445, "pid": 4}, + {"address": "0.0.0.0", "port": 5985, "pid": 4}, + {"address": "0.0.0.0", "port": 3389, "pid": 1180}, + {"address": "0.0.0.0", "port": 1433, "pid": 4321}, + {"address": "127.0.0.1", "port": 1434, "pid": 4321}, + {"address": "fe80::1c2a:3b4c:5d6e:7f80%12", "port": 1433, "pid": 4321} + ], + "udp": [ + {"address": "0.0.0.0", "port": 123, "pid": 1404}, + {"address": "::", "port": 123, "pid": 1404}, + {"address": "0.0.0.0", "port": 5353, "pid": 0} + ], + "processes": [ + {"pid": 4, "name": "System"}, + {"pid": 912, "name": "svchost"}, + {"pid": 1180, "name": "svchost"}, + {"pid": 1404, "name": "svchost"}, + {"pid": 4321, "name": "sqlservr"} + ], + "services": [ + {"pid": 912, "name": "RpcEptMapper"}, + {"pid": 912, "name": "RpcSs"}, + {"pid": 1180, "name": "TermService"}, + {"pid": 1404, "name": "W32Time"}, + {"pid": 4321, "name": "MSSQL$SQLEXPRESS"} + ] +} diff --git a/tests/test_windows.py b/tests/test_windows.py index d653991..516e2e5 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -362,6 +362,79 @@ class TestGetServices: ] +class TestGetListeningSockets: + """netOrk #673: what listens, and which Windows service it is.""" + + def _sockets(self, answer: str | None = None) -> dict: + answer = answer or _fixture("listening.json") + reading = _driver(_PS_LISTENING=answer).get_listening_sockets() + return {(s["proto"], s["port"], s["address"]): s for s in reading["sockets"]} + + def test_read_as_an_administrator(self): + reading = _driver(_PS_LISTENING=_fixture("listening.json")).get_listening_sockets() + + assert reading["attributed"] is True + assert len(reading["sockets"]) == 11 + + def test_a_socket_comes_with_its_process_and_service(self): + assert self._sockets()[("tcp", 1433, "0.0.0.0")] == { + "proto": "tcp", + "address": "0.0.0.0", + "port": 1433, + "interface": None, + "process": "sqlservr", + "pid": 4321, + "unit": "MSSQL$SQLEXPRESS", + "container_id": None, + } + + def test_a_svchost_of_its_own_names_its_service(self): + assert self._sockets()[("tcp", 3389, "0.0.0.0")]["unit"] == "TermService" + + def test_a_shared_svchost_names_none(self): + """RpcSs and RpcEptMapper share a process: the socket is one of theirs, + and which one Windows does not say.""" + rpc = self._sockets()[("tcp", 135, "0.0.0.0")] + + assert (rpc["process"], rpc["unit"]) == ("svchost", None) + + def test_http_sys_listens_as_the_system_process(self): + winrm = self._sockets()[("tcp", 5985, "0.0.0.0")] + + assert (winrm["process"], winrm["pid"], winrm["unit"]) == ("System", 4, None) + + def test_the_scope_of_a_link_local_address_is_its_interface(self): + assert self._sockets()[("tcp", 1433, "fe80::1c2a:3b4c:5d6e:7f80")]["interface"] == "12" + + def test_a_socket_without_a_process_is_kept(self): + mdns = self._sockets()[("udp", 5353, "0.0.0.0")] + + assert (mdns["process"], mdns["pid"], mdns["unit"]) == (None, None, None) + + def test_one_socket_arrives_as_a_bare_object(self): + """ConvertTo-Json unwraps a one-element array.""" + answer = json.dumps( + { + "tcp": {"address": "0.0.0.0", "port": 3389, "pid": 1180}, + "udp": [], + "processes": {"pid": 1180, "name": "svchost"}, + "services": {"pid": 1180, "name": "TermService"}, + } + ) + + assert self._sockets(answer)[("tcp", 3389, "0.0.0.0")]["unit"] == "TermService" + + def test_sorted_by_protocol_port_and_address(self): + reading = _driver(_PS_LISTENING=_fixture("listening.json")).get_listening_sockets() + keys = [(s["proto"], s["port"], s["address"]) for s in reading["sockets"]] + + assert keys == sorted(keys) + + def test_the_script_reads_and_changes_nothing(self): + for verb in ("Set-", "Stop-", "Start-", "Remove-", "New-"): + assert verb not in mod._PS_LISTENING + + class TestManageService: @pytest.mark.parametrize( ("action", "command"), diff --git a/tools/harvest.py b/tools/harvest.py index 7dba331..582edd9 100644 --- a/tools/harvest.py +++ b/tools/harvest.py @@ -24,6 +24,7 @@ SCRIPTS = { "arp": mod._PS_ARP, "routes": mod._PS_ROUTES, "services": mod._PS_SERVICES, + "listening": mod._PS_LISTENING, } OUT = pathlib.Path(__file__).parent / "harvest-out"