feat: report what listens, and which Windows service it is #1
No files matched your search
@@ -5,6 +5,8 @@
|
|||||||
- PowerShell Remoting transport over WinRM (pypsrp), one runspace pool per connection.
|
- 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_facts`, `get_interfaces`, `get_interfaces_ip`, `get_arp_table`, `get_route_to`.
|
||||||
- `get_services` and `manage_service` (start, stop, restart, enable, disable).
|
- `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`
|
- Discovery fingerprints: WinRM ports 5985/5986, `Microsoft-HTTPAPI` / `Microsoft-IIS`
|
||||||
server headers, Windows SNMP `sysDescr`, OpenSSH-for-Windows banner.
|
server headers, Windows SNMP `sysDescr`, OpenSSH-for-Windows banner.
|
||||||
- `tools/harvest.py` to record real script output as test fixtures.
|
- `tools/harvest.py` to record real script output as test fixtures.
|
||||||
@@ -26,6 +26,7 @@ handshake once.
|
|||||||
| `get_arp_table` | `Get-NetNeighbor -AddressFamily IPv4` (broadcast, multicast, unreachable dropped) |
|
| `get_arp_table` | `Get-NetNeighbor -AddressFamily IPv4` (broadcast, multicast, unreachable dropped) |
|
||||||
| `get_route_to` | `Get-NetRoute` (multicast, broadcast, loopback dropped) |
|
| `get_route_to` | `Get-NetRoute` (multicast, broadcast, loopback dropped) |
|
||||||
| `get_services` / `manage_service` | `Win32_Service`, `Start-/Stop-/Restart-Service`, `Set-Service -StartupType` |
|
| `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
|
`running_kernel` carries the full build including the update revision
|
||||||
(`10.0.20348.2340`): Windows ships fixes as UBR bumps, which is what CVE
|
(`10.0.20348.2340`): Windows ships fixes as UBR bumps, which is what CVE
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from typing import Any
|
|||||||
|
|
||||||
from napalm.base.exceptions import ConnectionClosedException
|
from napalm.base.exceptions import ConnectionClosedException
|
||||||
from napalm_device_types import FingerprintRule, OSDriver, PortSpec
|
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
|
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 = {
|
_SERVICE_COMMANDS = {
|
||||||
"start": "Start-Service -Name {name}",
|
"start": "Start-Service -Name {name}",
|
||||||
"stop": "Stop-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
|
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:
|
def _is_noise_route(destination: str) -> bool:
|
||||||
"""Multicast, limited broadcast and loopback — present on every host."""
|
"""Multicast, limited broadcast and loopback — present on every host."""
|
||||||
try:
|
try:
|
||||||
@@ -444,6 +501,24 @@ class WindowsDriver(OSDriver):
|
|||||||
)
|
)
|
||||||
return services
|
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]:
|
def manage_service(self, name: str, action: str) -> dict[str, Any]:
|
||||||
template = _SERVICE_COMMANDS.get(action)
|
template = _SERVICE_COMMANDS.get(action)
|
||||||
if template is None:
|
if template is None:
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napalm>=4.0",
|
"napalm>=4.0",
|
||||||
"napalm-device-types>=0.5.0",
|
"napalm-device-types>=2.4.0",
|
||||||
"pypsrp>=0.8",
|
"pypsrp>=0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+31
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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:
|
class TestManageService:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("action", "command"),
|
("action", "command"),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ SCRIPTS = {
|
|||||||
"arp": mod._PS_ARP,
|
"arp": mod._PS_ARP,
|
||||||
"routes": mod._PS_ROUTES,
|
"routes": mod._PS_ROUTES,
|
||||||
"services": mod._PS_SERVICES,
|
"services": mod._PS_SERVICES,
|
||||||
|
"listening": mod._PS_LISTENING,
|
||||||
}
|
}
|
||||||
|
|
||||||
OUT = pathlib.Path(__file__).parent / "harvest-out"
|
OUT = pathlib.Path(__file__).parent / "harvest-out"
|
||||||
|
|||||||
Reference in new issue
Block a user