From 2badf42e41ed9e3364e282a6b66d3d946786f423 Mon Sep 17 00:00:00 2001 From: Kaiser <1727168348@qq.com> Date: Fri, 16 Oct 2026 11:51:26 +0800 Subject: [PATCH] fix: validate bounded Modbus TCP discovery responses Correlate complete replies, bind basic-object pages to the fixed query, and reject reserved TCP exception codes. Preserve TCP-only evidence. AI-assisted implementation and independent review; 386 local tests passed. --- core/generic_detector.py | 30 ++-- core/protocol_engine.py | 52 ++++++ docs/modbus-discovery.md | 17 ++ tests/test_modbus_discovery.py | 312 +++++++++++++++++++++++++++++++++ 4 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 docs/modbus-discovery.md create mode 100644 tests/test_modbus_discovery.py diff --git a/core/generic_detector.py b/core/generic_detector.py index d029807..5d27310 100644 --- a/core/generic_detector.py +++ b/core/generic_detector.py @@ -29,6 +29,7 @@ protocol_probes, request_payload, response_rule, + validate_modbus_device_id_response, validate_modbus_tcp_response, ) @@ -2305,15 +2306,16 @@ def _modbus_tcp_identify(self, ip: str, port: int) -> Optional[dict]: try: sock.connect((ip, port)) sock.sendall(MODBUS_TCP_DEVICE_ID) - response = sock.recv(260) + response = receive_response(sock, 0.45, max_bytes=260, complete=modbus_tcp_complete) duration_ms = round((time.time() - start) * 1000, 1) - if response: + response_ok, response_note, valid_frame = validate_modbus_device_id_response(response, 1, 1) + if response_ok: return { - "type": "TCP连通 + Modbus TCP设备识别", + "type": "TCP连通 + Modbus TCP异常响应" if valid_frame[7] == 0xAB else "TCP连通 + Modbus TCP设备识别", "request": f"TCP connect; {self._bytes_to_hex(MODBUS_TCP_DEVICE_ID)}", - "response": self._bytes_to_hex(response), + "response": self._bytes_to_hex(valid_frame), "status": "有响应", - "summary": f"{ip}:{port} 对Modbus TCP设备识别请求有响应", + "summary": f"{ip}:{port} {response_note}", "duration_ms": duration_ms, } except OSError: @@ -2349,16 +2351,22 @@ def _modbus_tcp_standard_probe(self, ip: str, port: int) -> Optional[dict]: try: sock.connect((ip, port)) sock.sendall(frame) - response = sock.recv(260) + response = receive_response(sock, 0.45, max_bytes=260, complete=modbus_tcp_complete) duration_ms = round((time.time() - start) * 1000, 1) - if response: - status_note = self._modbus_tcp_response_note(response) + response_ok, response_note, valid_frame = validate_modbus_tcp_response( + response, int.from_bytes(frame[:2], "big"), unit_id, 3, + accept_exception=True, expected_count=count, + ) + if response_ok: + is_exception = valid_frame[7] == 0x83 + if is_exception: + response_note = f"设备返回Modbus异常码0x{valid_frame[8]:02X},仅确认协议可达" return { - "type": "TCP连通 + Modbus TCP标准参数只读验证", + "type": "TCP连通 + Modbus TCP异常响应" if is_exception else "TCP连通 + Modbus TCP标准参数只读验证", "request": f"{label}; {self._bytes_to_hex(frame)}", - "response": self._bytes_to_hex(response), + "response": self._bytes_to_hex(valid_frame), "status": "有响应", - "summary": f"{ip}:{port} 对标准只读寄存器请求有响应;{status_note}", + "summary": f"{ip}:{port} 对标准只读寄存器请求返回有效协议帧;{response_note}", "duration_ms": duration_ms, } except OSError: diff --git a/core/protocol_engine.py b/core/protocol_engine.py index 2ace73d..a072be8 100644 --- a/core/protocol_engine.py +++ b/core/protocol_engine.py @@ -5,6 +5,7 @@ READ_ONLY_MODBUS_FUNCTIONS = (1, 2, 3, 4) +MODBUS_EXCEPTION_CODES = (1, 2, 3, 4, 5, 6, 8, 10, 11) class ProtocolConfigError(ValueError): @@ -293,6 +294,8 @@ def validate_modbus_tcp_response(data: bytes, transaction_id: int, unit_id: int, if declared_length != 3: return False, "Modbus TCP异常响应长度不正确", frame code = frame[8] + if code not in MODBUS_EXCEPTION_CODES: + return False, "Modbus TCP异常码未定义", frame if accept_exception: return True, f"设备返回Modbus异常码0x{code:02X},按配置允许异常响应", frame return False, f"设备返回Modbus异常码0x{code:02X}", frame @@ -308,3 +311,52 @@ def validate_modbus_tcp_response(data: bytes, transaction_id: int, unit_id: int, if byte_count != expected_bytes: return False, "Modbus TCP响应数量与请求不匹配", frame return True, "返回有效Modbus TCP响应", frame + + +def validate_modbus_device_id_response(data: bytes, transaction_id: int, + unit_id: int) -> Tuple[bool, str, bytes]: + """Validate the first page of a 43/14 basic-ID reply; never infer a model.""" + validate_modbus_target(unit_id, tcp=True) + if len(data) < 9: + return False, "设备识别响应长度不足", b"" + if int.from_bytes(data[:2], "big") != transaction_id or data[2:4] != b"\x00\x00": + return False, "设备识别事务号或协议标识不匹配", b"" + length = int.from_bytes(data[4:6], "big") + if not 3 <= length <= 254 or len(data) < 6 + length: + return False, "设备识别长度字段不正确", b"" + frame = data[:6 + length] + if frame[6] != unit_id: + return False, "设备识别单元标识不匹配", frame + if frame[7] == 0xAB: + if length != 3: + return False, "设备识别异常响应长度不正确", frame + if frame[8] not in MODBUS_EXCEPTION_CODES: + return False, "设备识别异常码未定义", frame + return True, f"设备返回Modbus异常码0x{frame[8]:02X},仅确认协议可达", frame + if length < 8 or frame[7:10] != b"\x2B\x0E\x01": + return False, "设备识别功能、MEI或访问码不匹配", frame + conformity, more, next_object, count = frame[10:14] + if conformity not in (1, 2, 3, 0x81, 0x82, 0x83) or more not in (0, 0xFF): + return False, "设备识别能力或分页标识无效", frame + if more == 0 and next_object != 0: + return False, "设备识别末页的后续对象标识无效", frame + if not 1 <= count <= 3 or (more == 0 and count != 3): + return False, "基本设备识别对象为空或缺失", frame + offset, last_object = 14, -1 + for expected_object in range(count): + if offset + 2 > len(frame): + return False, "设备识别对象头不完整", frame + object_id, size = frame[offset:offset + 2] + offset += 2 + if object_id != expected_object or offset + size > len(frame): + return False, "设备识别对象次序或长度无效", frame + last_object = object_id + offset += size + if offset != len(frame): + return False, "设备识别对象数量与长度不匹配", frame + if more == 0xFF and (count == 3 or next_object != last_object + 1): + return False, "设备识别分页未前进", frame + note = f"返回有效基本设备识别响应,含{count}个对象" + if more == 0xFF: + note += ";存在后续分页,本次不继续查询" + return True, note, frame diff --git a/docs/modbus-discovery.md b/docs/modbus-discovery.md new file mode 100644 index 0000000..414684c --- /dev/null +++ b/docs/modbus-discovery.md @@ -0,0 +1,17 @@ +# Modbus TCP discovery response integrity / 发现响应校验 + +TCP connection and Modbus response are separate evidence. An open port remains a TCP connection result when protocol discovery receives unrelated, truncated or mismatched bytes; it must not be relabelled Modbus merely because data arrived. + +端口连通与协议有效性分别记录。标准寄存器发现使用与配置读检查相同的MBAP关联、功能和数量验证;基本设备识别验证固定43/14、MEI14、访问码1、结构字段与有界对象列表。两条路径均使用既有有界收包逻辑,支持TCP任意分片;超时预算仍为0.45秒、上限260字节,socket始终在finally关闭。 + +The read probe reuses the existing response validator and preserves configured unit IDs, including TCP0. The fallback device-identification probe retains its existing transaction1/unit1/basic-object0 request. It validates the first returned page only; pagination metadata is reported without issuing more requests. Valid Modbus exceptions indicate protocol reachability, not successful register reads or identified model names. Returned object bytes are not authenticated device identity. + +The fixed basic-object0 response must start at object0 and contain a nonempty contiguous prefix of basic objects0/1/2. A final first page contains all three mandatory objects; a continuation points to the next remaining basic object. Empty/non-progressing pages and regular/extended objects are refused. TCP response validators reject exception bytes outside the defined code set1/2/3/4/5/6/8/0A/0B, even when exceptions are allowed. This shared TCP check also covers configured reads; RTU exception handling is unchanged by this correction. + +兼容范围:既有只读请求字节、配置轮询上限和超时不变。合法异常帧保留为协议可达,摘要明确异常;缺失或错位事务/单元、非法长度、错功能/MEI/访问码和不完整对象不会产生Modbus分类。基础识别不提供厂商认证、完整设备型谱或自动分页;其他访问码及厂商扩展不由本固定查询处理。 + +Verification uses deterministic byte sockets, all single-split positions for representative responses and real loopback TCP. It does not connect to field hardware or establish electrical performance. The broader application still needs independent security and hardware validation; this bounded correction does not certify the service. + +Protocol references: [Modbus Application Protocol V1.1b3, §6.21 and §7](https://www.modbus.org/file/secure/modbusprotocolspecification.pdf), [Modbus TCP/IP Implementation Guide, MBAP header](https://www.modbus.org/file/secure/messagingimplementationguide.pdf). + +Implementation, tests and documentation are AI-assisted. / 本次实现、测试与说明为AI辅助。 diff --git a/tests/test_modbus_discovery.py b/tests/test_modbus_discovery.py new file mode 100644 index 0000000..bd1c0d0 --- /dev/null +++ b/tests/test_modbus_discovery.py @@ -0,0 +1,312 @@ +"""Exercise real discovery methods using byte sockets and authorized loopback only.""" +import socket +import threading + +import pytest + +from core import generic_detector as gd +from core.protocol_engine import validate_modbus_tcp_response + + +READ_PDU = bytes.fromhex('03 02 00 01') +ID_PDU = bytes.fromhex('2b 0e 01 01 00 00 03 00 01 56 01 01 50 02 01 31') + + +class Config: + def __init__(self, unit=1, registers=None): + self.unit = unit + self.registers = registers or [{'address': 0, 'count': 1}] + + def protocol_probes(self, protocol): + assert protocol == 'modbus_tcp' + return [{'unit_id': self.unit, 'test_registers': self.registers}] + + +def detector(unit=1, registers=None): + d = object.__new__(gd.GenericDetector) + d.config = Config(unit, registers) + return d + + +def packet(request, pdu): + return request[:2] + b'\0\0' + (len(pdu) + 1).to_bytes(2, 'big') + request[6:7] + pdu + + +def invoke(d, path, port=1502): + return (d._modbus_tcp_identify if path == 'identify' else d._modbus_tcp_standard_probe)('127.0.0.1', port) + + +class ByteSocket: + def __init__(self, make_parts, opened): + self.make_parts = make_parts + self.parts = [] + self.closed = False + self.sent = [] + self.timeouts = [] + opened.append(self) + + def settimeout(self, timeout): + assert 0 < timeout <= .45 + self.timeouts.append(timeout) + + def connect(self, endpoint): + assert endpoint[0] == '127.0.0.1' + + def connect_ex(self, endpoint): + self.connect(endpoint) + return 0 + + def sendall(self, request): + self.sent.append(request) + self.parts = list(self.make_parts(request)) + + def recv(self, size): + if not self.parts: + return b'' + item = self.parts.pop(0) + if isinstance(item, BaseException): + raise item + if len(item) > size: + self.parts.insert(0, item[size:]) + return item[:size] + + def close(self): + self.closed = True + + +def install(monkeypatch, make_parts): + opened = [] + monkeypatch.setattr(gd.socket, 'socket', lambda *a, **kw: ByteSocket(make_parts, opened)) + return opened + + +def malformed(request, path, kind): + raw = packet(request, ID_PDU if path == 'identify' else READ_PDU) + if kind == 'text': + return b'HTTP/1.1 200 OK\r\n' + if kind == 'transaction': + return b'\x7f\xff' + raw[2:] + if kind == 'unit': + return raw[:6] + b'\x02' + raw[7:] + if kind == 'protocol': + return raw[:2] + b'\x00\x01' + raw[4:] + if kind == 'function': + return raw[:7] + b'\x04' + raw[8:] + if kind == 'short': + return raw[:5] + if kind == 'missing_body': + return raw[:-1] + if kind == 'too_short_length': + return raw[:4] + b'\0\x02' + raw[6:] + if kind == 'oversized_length': + return raw[:4] + b'\x01\x00' + raw[6:] + raise AssertionError(kind) + + +@pytest.mark.parametrize('path', ['read', 'identify']) +@pytest.mark.parametrize('kind', ['text', 'transaction', 'unit', 'protocol', 'function', 'short', 'missing_body', 'too_short_length', 'oversized_length']) +def test_discovery_does_not_classify_nonempty_invalid_protocol(monkeypatch, path, kind): + opened = install(monkeypatch, lambda request: [malformed(request, path, kind)]) + assert invoke(detector(), path) is None + assert opened and all(s.closed for s in opened) + + +@pytest.mark.parametrize('path', ['read', 'identify']) +def test_every_single_fragment_split_preserves_complete_protocol_evidence(monkeypatch, path): + pdu = ID_PDU if path == 'identify' else READ_PDU + for split in range(1, 7 + len(pdu)): + expected = [] + def parts(request): + raw = packet(request, pdu) + expected.append(raw) + return [raw[:split], raw[split:]] + opened = install(monkeypatch, parts) + result = invoke(detector(), path) + assert result + assert result['response'] == expected[0].hex(' ').upper() + assert all(s.closed for s in opened) + + +@pytest.mark.parametrize('path', ['read', 'identify']) +@pytest.mark.parametrize('ending', ['eof', 'timeout', 'reset']) +def test_partial_frame_followed_by_interruption_is_not_identified(monkeypatch, path, ending): + tail = {'eof': b'', 'timeout': socket.timeout(), 'reset': ConnectionResetError()}[ending] + opened = install(monkeypatch, lambda request: [packet(request, ID_PDU if path == 'identify' else READ_PDU)[:8], tail]) + assert invoke(detector(), path) is None + assert all(s.closed for s in opened) + + +@pytest.mark.parametrize('path', ['read', 'identify']) +@pytest.mark.parametrize('ending', [b'', socket.timeout(), ConnectionResetError(), OSError('fixture down')]) +def test_empty_or_failed_discovery_closes_transport(monkeypatch, path, ending): + opened = install(monkeypatch, lambda request: [ending]) + assert invoke(detector(), path) is None + assert all(s.closed for s in opened) + + +@pytest.mark.parametrize('path', ['read', 'identify']) +def test_valid_exception_only_claims_protocol_reachability(monkeypatch, path): + pdu = bytes.fromhex('ab 01' if path == 'identify' else '83 02') + opened = install(monkeypatch, lambda request: [packet(request, pdu)]) + result = invoke(detector(), path) + assert result and '异常' in result['summary'] + assert result['type'] == 'TCP连通 + Modbus TCP异常响应' + assert '仅确认协议可达' in result['summary'] + assert '按配置' not in result['summary'] + assert '读取成功' not in result['summary'] and '识别成功' not in result['summary'] + assert all(s.closed for s in opened) + + +@pytest.mark.parametrize('unit', [0, 1, 255]) +def test_standard_read_preserves_explicit_unit_and_request(monkeypatch, unit): + opened = install(monkeypatch, lambda request: [packet(request, READ_PDU)]) + result = invoke(detector(unit), 'read') + assert result + assert opened[0].sent == [bytes([0, 2, 0, 0, 0, 6, unit, 3, 0, 0, 0, 1])] + assert all(s.closed for s in opened) + + +@pytest.mark.parametrize('pdu', [bytes.fromhex('03 04 00 01 00 02'), bytes.fromhex('03 01 00'), bytes.fromhex('83 02 00')]) +def test_standard_read_rejects_wrong_count_odd_data_and_malformed_exception(monkeypatch, pdu): + install(monkeypatch, lambda request: [packet(request, pdu)]) + assert invoke(detector(), 'read') is None + + +@pytest.mark.parametrize('pdu', [ + bytes.fromhex('2b 0f 01 01 00 00 00'), + bytes.fromhex('2b 0e 02 01 00 00 00'), + bytes.fromhex('2b 0e 01 00 00 00 00'), + bytes.fromhex('2b 0e 01 01 01 00 00'), + bytes.fromhex('2b 0e 01 01 00 01 00'), + bytes.fromhex('2b 0e 01 01 00 00 01'), + bytes.fromhex('2b 0e 01 01 00 00 01 00 03 56'), + bytes.fromhex('2b 0e 01 01 00 00 00 00'), + bytes.fromhex('2b 0e 01 01 00 00 02 00 01 56 00 01 50'), + bytes.fromhex('2b 0e 01 01 00 00 02 01 01 50 00 01 56'), + bytes.fromhex('ab 01 00'), +]) +def test_basic_identification_refuses_malformed_fields_and_object_lengths(monkeypatch, pdu): + install(monkeypatch, lambda request: [packet(request, pdu)]) + assert invoke(detector(), 'identify') is None + + +@pytest.mark.parametrize('conformity', [1, 2, 3, 0x81, 0x82, 0x83]) +def test_basic_identification_accepts_actual_conformity_levels(monkeypatch, conformity): + pdu = ID_PDU[:3] + bytes([conformity]) + ID_PDU[4:] + opened = install(monkeypatch, lambda request: [packet(request, pdu)]) + assert invoke(detector(), 'identify') + assert opened[0].sent == [bytes.fromhex('00 01 00 00 00 05 01 2b 0e 01 00')] + + +@pytest.mark.parametrize('pdu', [ + bytes.fromhex('2b 0e 01 01 ff 01 01 00 01 56'), + bytes.fromhex('2b 0e 01 01 ff 02 02 00 01 56 01 01 50'), +]) +def test_identification_accepts_a_bounded_first_page_without_followup_requests(monkeypatch, pdu): + opened = install(monkeypatch, lambda request: [packet(request, pdu)]) + result = invoke(detector(), 'identify') + assert result and len(opened) == 1 and len(opened[0].sent) == 1 + assert '分页' in result['summary'] + + +@pytest.mark.parametrize('pdu', [ + bytes.fromhex('2b 0e 01 01 00 00 00'), + bytes.fromhex('2b 0e 01 01 ff 00 00'), + bytes.fromhex('2b 0e 01 01 ff 01 00'), + bytes.fromhex('2b 0e 01 01 00 00 01 02 01 31'), + bytes.fromhex('2b 0e 01 01 00 00 01 03 01 31'), + bytes.fromhex('2b 0e 01 01 00 00 01 7f 01 31'), + bytes.fromhex('2b 0e 01 01 00 00 01 80 01 31'), + bytes.fromhex('2b 0e 01 01 ff ff 01 00 01 56'), + bytes.fromhex('2b 0e 01 01 ff 02 01 00 01 56'), + bytes.fromhex('2b 0e 01 01 00 00 01 00 01 56'), + bytes.fromhex('2b 0e 01 01 00 00 02 00 01 56 02 01 31'), + bytes.fromhex('2b 0e 01 01 ff 03 03 00 01 56 01 01 50 02 01 31'), +]) +def test_fixed_basic_query_refuses_empty_wrong_scope_or_incomplete_pages(monkeypatch, pdu): + install(monkeypatch, lambda request: [packet(request, pdu)]) + assert invoke(detector(), 'identify') is None + + +@pytest.mark.parametrize('path', ['read', 'identify']) +@pytest.mark.parametrize('code', [0, 7, 9, 255]) +def test_discovery_refuses_reserved_exception_codes(monkeypatch, path, code): + pdu = bytes([0xAB if path == 'identify' else 0x83, code]) + install(monkeypatch, lambda request: [packet(request, pdu)]) + assert invoke(detector(), path) is None + + +@pytest.mark.parametrize('path', ['read', 'identify']) +@pytest.mark.parametrize('code', [1, 2, 3, 4, 5, 6, 8, 10, 11]) +def test_defined_exception_codes_retain_explicit_protocol_only_label(monkeypatch, path, code): + pdu = bytes([0xAB if path == 'identify' else 0x83, code]) + install(monkeypatch, lambda request: [packet(request, pdu)]) + result = invoke(detector(), path) + assert result and result['type'] == 'TCP连通 + Modbus TCP异常响应' + assert '仅确认协议可达' in result['summary'] + + +@pytest.mark.parametrize('code', [0, 7, 9, 255]) +@pytest.mark.parametrize('accept', [False, True]) +def test_configured_TCP_validator_never_accepts_reserved_exception(code, accept): + raw = bytes([0, 1, 0, 0, 0, 3, 1, 0x83, code]) + assert not validate_modbus_tcp_response(raw, 1, 1, 3, accept_exception=accept)[0] + + +def test_bad_first_read_proceeds_to_next_configured_register(monkeypatch): + def parts(request): + raw = packet(request, READ_PDU) + return [b'not a Modbus frame' if request[1] == 2 else raw] + opened = install(monkeypatch, parts) + result = invoke(detector(registers=[{'address': 0, 'count': 1}, {'address': 1, 'count': 1}]), 'read') + assert result and result['request'].endswith('00 03 00 00 00 06 01 03 00 01 00 01') + assert len(opened) == 2 and all(s.closed for s in opened) + + +def test_invalid_responses_preserve_TCP_connect_result_without_Modbus_label(monkeypatch): + opened = install(monkeypatch, lambda request: [b'HTTP/1.1 200 OK\r\n']) + result = detector()._tcp_probe('127.0.0.1', 502) + assert result['type'] == 'TCP端口连通' + assert result['status'] == '连通' and result['response'] == 'connected' + assert len(opened) == 3 and all(s.closed for s in opened) + + +@pytest.mark.parametrize('path', ['read', 'identify']) +def test_loopback_actual_TCP_fragmentation_preserves_frame(path): + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + listener.settimeout(2) + port = listener.getsockname()[1] + expected, errors = [], [] + def serve(): + try: + conn, _ = listener.accept() + with conn: + conn.settimeout(2) + request = bytearray() + size = 11 if path == 'identify' else 12 + while len(request) < size: + fragment = conn.recv(size - len(request)) + if not fragment: + raise AssertionError('client disconnected before complete request') + request.extend(fragment) + raw = packet(bytes(request), ID_PDU if path == 'identify' else READ_PDU) + expected.append(raw) + # Real TCP may coalesce writes. All split positions are also + # deterministically covered by the ByteSocket test above. + for byte in raw: + conn.sendall(bytes([byte])) + except BaseException as exc: + errors.append(repr(exc)) + finally: + listener.close() + thread = threading.Thread(target=serve, daemon=True) + thread.start() + try: + result = invoke(detector(), path, port) + finally: + thread.join(3) + listener.close() + assert not thread.is_alive() and not errors + assert result and result['response'] == expected[0].hex(' ').upper()