Skip to content

Commit 0a90945

Browse files
gijzelaerrclaude
andcommitted
Revert speculative SessionKey send; keep public-key checksum capture
Wireshark's s7comm-plus dissector reveals that the value at address 1830 is a Struct(1800) named ``StructSecurityKey`` carrying a ``SessionKey`` — specifically a 180-byte ``SecurityKeyEncryptedKey`` blob with the layout: 0-3 uint32 LE magic = 0xFEE1DEAD 4-7 uint32 LE blobsize (0xB4) 16-23 uint64 LE symmetric_key_checksum 32-39 uint64 LE public_key_checksum (what attribute 233 carries) 48-N encrypted_random_seed (RSA-encrypted) N.. 16 bytes AES-CBC IV N+16..56 bytes encrypted_challenge So generating this requires Siemens' RSA public key (identified by the 8-byte fingerprint in attribute 233's "01:HEX" string) plus the KDF that turns the seed into the AES-CBC key. We don't have either, so the TIA-replay we shipped in a2111e7 was always going to fail against any new session — the PLC cannot decrypt a static blob. Roll back the address-1830 send. Keep the public-key checksum extraction as a diagnostic capture (renamed from `_oms_session_uuid` to `_public_key_checksum` to match the dissector's terminology); a future commit can use it to dispatch to a key-aware handshake once we have the keys, or to clearly log "this firmware needs a SessionKey we can't generate" when we don't. Net effect on V1-initial S7-1200 (FW < 4.5): same as before this PR — SetupSession still fails, the unified client falls back to legacy PUT/GET, db_read works, browse() requires CommPlus and so still raises. PR #713 stays a draft pending the key situation. Refs #710 #712 #713 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eae0160 commit 0a90945

2 files changed

Lines changed: 41 additions & 197 deletions

File tree

s7/connection.py

Lines changed: 35 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
from snap7.connection import ISOTCPConnection
4848
from .protocol import (
4949
FunctionCode,
50-
LegitimationId,
5150
Opcode,
5251
ProtocolVersion,
5352
ElementID,
@@ -63,111 +62,6 @@
6362
logger = logging.getLogger(__name__)
6463

6564

66-
# V2 session-setup legitimation BLOB observed in TIA Portal V19 captures
67-
# against an S7-1200 FW 4.2.2 (V1-initial firmware). 180 bytes; written to
68-
# address 1830 alongside ServerSessionVersion (306) in a single
69-
# SetMultiVariables. Without it the PLC drops the TCP connection silently
70-
# after the setup write.
71-
#
72-
# Most of the bytes are opaque — likely identity/integrity material that
73-
# TIA either pre-computes or signs with key material we don't have. The
74-
# only field we know we have to make per-session is the embedded PLC OMS
75-
# UUID at offset 32 (little-endian). Everything else is replayed verbatim
76-
# from one TIA capture as a first-pass experiment; if a later test shows
77-
# the PLC validates other byte ranges, we'll have to reverse those too.
78-
#
79-
# Reference capture: ``TIAPortalV19AccessibleDevices.pcapng`` frame 31
80-
# (attached to PR #713 / issue #710).
81-
_V2_SETUP_LEGITIMATION_BLOB_TEMPLATE = bytes.fromhex(
82-
"ad de e1 fe b4 00 00 00 01 00 00 00 01 00 00 00"
83-
"ce 9b 9f 3a 94 03 98 6b 01 01 00 00 00 00 00 00"
84-
"00 00 00 00 00 00 00 00 10 01 00 00 00 00 00 00" # OMS UUID slot at offset 32
85-
"3f c8 df c2 7c 03 7f d9 99 4c ea c7 e2 b9 bb 1a"
86-
"53 be 2a cd 00 49 53 3a fc 0e 43 64 49 5a c2 8e"
87-
"20 ce ef 14 09 fe b4 aa e8 1c 54 08 e4 53 4c 08"
88-
"2d ed 4b e6 31 ff b4 c5 1e 3b 56 ee b3 d4 1a d9"
89-
"4a 83 b7 bf ac 3c ec 28 9d 5c dd f9 2e 12 59 87"
90-
"f1 03 c7 08 f1 0b dc e1 e9 40 63 b5 2b 84 dc 58"
91-
"9b d1 4c b6 26 a1 16 36 12 22 8e 5b 3d da c0 a0"
92-
"b8 37 97 76 2f b1 f1 bb b2 b0 fb 44 f4 6a 50 9e"
93-
"03 42 d5 6f"
94-
)
95-
_V2_SETUP_LEGITIMATION_OMS_UUID_OFFSET = 32
96-
97-
98-
def _build_v2_session_setup_legitimation_value(oms_session_uuid: bytes) -> bytes:
99-
"""Build the typed value written to address 1830 during session setup.
100-
101-
The PObject tree shape is:
102-
103-
Struct(1800)
104-
1801: UDInt(0)
105-
1802: USInt(0)
106-
1803: Struct(1825)
107-
1826: ULInt (opaque, replayed)
108-
1827: UDInt(272)
109-
1828: UDInt(0)
110-
1804: Struct(1825)
111-
1826: ULInt (opaque, replayed)
112-
1827: UDInt(65793)
113-
1828: UDInt(0)
114-
1805: Blob(180 bytes — see template above; OMS UUID patched in LE)
115-
116-
Args:
117-
oms_session_uuid: 8-byte OMS session UUID parsed from the
118-
CreateObject response (attribute 233 ``"01:HEX"`` string).
119-
120-
Returns:
121-
Encoded typed value (flags + datatype + struct body), ready to
122-
append to a SetMultiVariables item.
123-
"""
124-
if len(oms_session_uuid) != 8:
125-
raise ValueError(f"OMS session UUID must be 8 bytes, got {len(oms_session_uuid)}")
126-
127-
blob = bytearray(_V2_SETUP_LEGITIMATION_BLOB_TEMPLATE)
128-
blob[_V2_SETUP_LEGITIMATION_OMS_UUID_OFFSET : _V2_SETUP_LEGITIMATION_OMS_UUID_OFFSET + 8] = oms_session_uuid[::-1]
129-
130-
# PValue helpers. Each returns a complete typed value: flags + datatype
131-
# + value bytes. _elem prepends the element-key VLQ for placement
132-
# inside a struct.
133-
def _elem(elem_id: int, typed_value: bytes) -> bytes:
134-
return encode_uint32_vlq(elem_id) + typed_value
135-
136-
def _udint(value: int) -> bytes:
137-
return bytes([0x00, DataType.UDINT]) + encode_uint32_vlq(value)
138-
139-
def _usint(value: int) -> bytes:
140-
return bytes([0x00, DataType.USINT, value & 0xFF])
141-
142-
def _ulint_raw(raw_vlq: bytes) -> bytes:
143-
return bytes([0x00, DataType.ULINT]) + raw_vlq
144-
145-
def _struct(struct_id: int, body: bytes) -> bytes:
146-
# Struct PValue: flags(0) + STRUCT + 4-byte ID + body + terminator(0)
147-
return bytes([0x00, DataType.STRUCT]) + struct.pack(">I", struct_id) + body + bytes([0x00])
148-
149-
def _blob(data: bytes) -> bytes:
150-
return bytes([0x00, DataType.BLOB]) + encode_uint32_vlq(0) + encode_uint32_vlq(len(data)) + data
151-
152-
# Opaque ULInt VLQs from the TIA reference capture. Each is a 9-byte
153-
# VLQ. Treated as opaque payload rather than decoded numeric values
154-
# since we don't know what they encode.
155-
opaque_ulint_1 = bytes.fromhex("de d0 cd b0 c8 fc 90 f3 1a")
156-
opaque_ulint_2 = bytes.fromhex("b5 e6 80 b9 a1 ea bf 9b ce")
157-
158-
inner_1803 = _elem(1826, _ulint_raw(opaque_ulint_1)) + _elem(1827, _udint(272)) + _elem(1828, _udint(0))
159-
inner_1804 = _elem(1826, _ulint_raw(opaque_ulint_2)) + _elem(1827, _udint(65793)) + _elem(1828, _udint(0))
160-
161-
outer_body = b""
162-
outer_body += _elem(1801, _udint(0))
163-
outer_body += _elem(1802, _usint(0))
164-
outer_body += _elem(1803, _struct(1825, inner_1803))
165-
outer_body += _elem(1804, _struct(1825, inner_1804))
166-
outer_body += _elem(1805, _blob(bytes(blob)))
167-
168-
return _struct(1800, outer_body)
169-
170-
17165
def _strip_paom_string_in_session_version(struct_bytes: bytes) -> bytes:
17266
"""Replace element 319 in a captured ServerSessionVersion struct with an empty WString.
17367
@@ -242,11 +136,13 @@ def __init__(
242136
self._server_session_version: Optional[int] = None
243137
self._server_session_version_raw: Optional[bytes] = None
244138
self._session_setup_ok: bool = False
245-
# PLC-provided 8-byte OMS session UUID (parsed from the
139+
# PLC-provided 8-byte public-key checksum (parsed from the
246140
# ObjectVariableTypeName "01:HEX" attribute in the CreateObject
247-
# response). Required to build the V2 session-setup legitimation
248-
# value on V1-initial S7-1200 firmware.
249-
self._oms_session_uuid: Optional[bytes] = None
141+
# response). It identifies which Siemens RSA key the PLC expects
142+
# for the V2 SessionKey handshake. Captured for diagnostics and
143+
# future use; we don't generate the SessionKey blob ourselves
144+
# because we don't have the matching public key.
145+
self._public_key_checksum: Optional[bytes] = None
250146

251147
# V2+ IntegrityId tracking
252148
self._integrity_id_read: int = 0
@@ -934,9 +830,12 @@ def _parse_create_object_response(self, payload: bytes) -> None:
934830

935831
elif attr_id == 233:
936832
# ObjectVariableTypeName: a WString shaped "01:HEX",
937-
# where HEX is the PLC's OMS session UUID encoded as
938-
# 16 hex characters. Captured for the V2 session-setup
939-
# legitimation value (address 1830).
833+
# where HEX is the 8-byte fingerprint of the Siemens
834+
# RSA public key the PLC expects for the V2 SessionKey
835+
# handshake (Wireshark s7comm-plus dissector calls
836+
# this "publickeychecksum"). Captured for diagnostics
837+
# and so future code can decide whether the matching
838+
# public key is available.
940839
if offset + 2 > len(payload):
941840
break
942841
_flags = payload[offset]
@@ -950,8 +849,8 @@ def _parse_create_object_response(self, payload: bytes) -> None:
950849
try:
951850
text = raw.decode("utf-8")
952851
if len(text) == 19 and text[2] == ":":
953-
self._oms_session_uuid = bytes.fromhex(text[3:])
954-
logger.info(f"OMS session UUID captured: {text}")
852+
self._public_key_checksum = bytes.fromhex(text[3:])
853+
logger.info(f"Public key checksum captured: {text}")
955854
except (UnicodeDecodeError, ValueError):
956855
logger.debug(f"Unparseable ObjectVariableTypeName: {raw!r}")
957856
else:
@@ -1070,7 +969,7 @@ def _skip_typed_value(self, data: bytes, offset: int, datatype: int, flags: int)
1070969
return offset
1071970

1072971
def _setup_session(self) -> bool:
1073-
"""Send V2 SetMultiVariables to complete the session handshake.
972+
"""Send V2 SetMultiVariables to echo ServerSessionVersion back to the PLC.
1074973
1075974
Always uses V2 framing (`72 02 ...`), transport flags 0x34, and no
1076975
IntegrityId — these are the established session-setup conventions
@@ -1080,16 +979,16 @@ def _setup_session(self) -> bool:
1080979
Without this step, the PLC rejects all subsequent data operations
1081980
with ERROR2 (0x05A9).
1082981
1083-
Two-item form (V1-initial S7-1200, FW < 4.07): writes both
1084-
``SESSION_SETUP_LEGITIMATION`` (1830) and ``SERVER_SESSION_VERSION``
1085-
(306) in one SetMultiVariables. TIA Portal V19 does this on FW 4.2.2
1086-
captures; without the legitimation item the PLC closes the TCP
1087-
connection silently right after the setup write. We use the two-item
1088-
form whenever the PLC sent us its OMS session UUID in the
1089-
CreateObject response (a V1-initial-firmware behaviour).
1090-
1091-
One-item form (everything else): just the ``SERVER_SESSION_VERSION``
1092-
echo, matching the C# driver's ``SetSessionSetupData``.
982+
Note on V1-initial S7-1200 (FW < 4.5): TIA Portal V19 also writes a
983+
``SessionKey`` value at address 1830 in the same frame, carrying an
984+
RSA-encrypted random seed and an AES-CBC-encrypted challenge.
985+
Without that key, the PLC drops the connection right after this
986+
write — so CommPlus data ops (incl. ``browse()``) never come up on
987+
V1-initial firmware. The unified client transparently falls back to
988+
legacy PUT/GET in that case. See PR #713 / issue #710 / issue #712
989+
for the diagnosis (Wireshark s7comm-plus dissector confirmed
990+
Struct(1800) is ``StructSecurityKey``; we don't have Siemens'
991+
public keys to generate the encrypted seed).
1093992
1094993
Returns:
1095994
True if session setup succeeded (return_value == 0).
@@ -1112,36 +1011,23 @@ def _setup_session(self) -> bool:
11121011
0x34,
11131012
)
11141013

1014+
payload = bytearray()
1015+
payload += struct.pack(">I", self._session_id) # InObjectId
1016+
payload += encode_uint32_vlq(1) # ItemCount
1017+
payload += encode_uint32_vlq(1) # AddressCount
1018+
payload += encode_uint32_vlq(ObjectId.SERVER_SESSION_VERSION) # Address: 306
1019+
payload += encode_uint32_vlq(1) # ItemNumber
1020+
11151021
if self._server_session_version_raw is not None:
11161022
# Echo the Struct(314) value, but strip element 319 (the device
11171023
# PAOM string) to empty. TIA Portal does this; writing the PLC's
11181024
# own identity back appears to be rejected — the V1-initial
11191025
# S7-1200 silently drops the connection if we don't strip it.
1120-
session_version_value = _strip_paom_string_in_session_version(self._server_session_version_raw)
1026+
payload += _strip_paom_string_in_session_version(self._server_session_version_raw)
11211027
else:
11221028
# Test/emulator path: scalar UDInt fallback.
1123-
session_version_value = bytes([0x00, DataType.UDINT]) + encode_uint32_vlq(self._server_session_version or 0)
1124-
1125-
oms_uuid = self._oms_session_uuid
1126-
1127-
payload = bytearray()
1128-
payload += struct.pack(">I", self._session_id) # InObjectId
1129-
1130-
if oms_uuid is not None:
1131-
payload += encode_uint32_vlq(2) # ItemCount
1132-
payload += encode_uint32_vlq(2) # AddressCount
1133-
payload += encode_uint32_vlq(LegitimationId.SESSION_SETUP_LEGITIMATION) # 1830
1134-
payload += encode_uint32_vlq(ObjectId.SERVER_SESSION_VERSION) # 306
1135-
payload += encode_uint32_vlq(1) # ItemNumber 1
1136-
payload += _build_v2_session_setup_legitimation_value(oms_uuid)
1137-
payload += encode_uint32_vlq(2) # ItemNumber 2
1138-
payload += session_version_value
1139-
else:
1140-
payload += encode_uint32_vlq(1) # ItemCount
1141-
payload += encode_uint32_vlq(1) # AddressCount
1142-
payload += encode_uint32_vlq(ObjectId.SERVER_SESSION_VERSION) # 306
1143-
payload += encode_uint32_vlq(1) # ItemNumber
1144-
payload += session_version_value
1029+
payload += bytes([0x00, DataType.UDINT])
1030+
payload += encode_uint32_vlq(self._server_session_version or 0)
11451031

11461032
payload += bytes([0x00]) # Fill byte
11471033
payload += encode_object_qualifier()

tests/test_s7_unit.py

Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from s7.codec import encode_pvalue_blob
1414
from s7.connection import (
1515
S7CommPlusConnection,
16-
_build_v2_session_setup_legitimation_value,
1716
_element_size,
1817
_strip_paom_string_in_session_version,
1918
)
@@ -451,52 +450,11 @@ def test_strip_paom_string_no_match_returns_unchanged(self) -> None:
451450
nopaom = bytes.fromhex("00170000013a823b0004840000")
452451
assert _strip_paom_string_in_session_version(nopaom) == nopaom
453452

454-
def test_build_v2_session_setup_legitimation_value(self) -> None:
455-
# The PObject tree we send at address 1830 must be byte-identical
456-
# to the value TIA Portal V19 sent on a real V1-initial S7-1200,
457-
# with the OMS UUID patched in. Reference: frame 31 of
458-
# ``TIAPortalV19AccessibleDevices.pcapng`` (PR #713 / issue #710).
459-
oms_uuid = bytes.fromhex("BD426B091F08731A")
460-
got = _build_v2_session_setup_legitimation_value(oms_uuid)
461-
expected_hex = (
462-
"00 17 00 00 07 08"
463-
"8e 09 00 04 00"
464-
"8e 0a 00 02 00"
465-
"8e 0b 00 17 00 00 07 21 8e 22 00 05 de d0 cd b0 c8 fc 90 f3 1a 8e 23 00 04 82 10 8e 24 00 04 00 00"
466-
"8e 0c 00 17 00 00 07 21 8e 22 00 05 b5 e6 80 b9 a1 ea bf 9b ce 8e 23 00 04 84 82 01 8e 24 00 04 00 00"
467-
"8e 0d 00 14 00 81 34"
468-
"ad de e1 fe b4 00 00 00 01 00 00 00 01 00 00 00"
469-
"ce 9b 9f 3a 94 03 98 6b 01 01 00 00 00 00 00 00"
470-
"1a 73 08 1f 09 6b 42 bd 10 01 00 00 00 00 00 00"
471-
"3f c8 df c2 7c 03 7f d9 99 4c ea c7 e2 b9 bb 1a"
472-
"53 be 2a cd 00 49 53 3a fc 0e 43 64 49 5a c2 8e"
473-
"20 ce ef 14 09 fe b4 aa e8 1c 54 08 e4 53 4c 08"
474-
"2d ed 4b e6 31 ff b4 c5 1e 3b 56 ee b3 d4 1a d9"
475-
"4a 83 b7 bf ac 3c ec 28 9d 5c dd f9 2e 12 59 87"
476-
"f1 03 c7 08 f1 0b dc e1 e9 40 63 b5 2b 84 dc 58"
477-
"9b d1 4c b6 26 a1 16 36 12 22 8e 5b 3d da c0 a0"
478-
"b8 37 97 76 2f b1 f1 bb b2 b0 fb 44 f4 6a 50 9e"
479-
"03 42 d5 6f"
480-
"00"
481-
)
482-
assert got == bytes.fromhex(expected_hex)
483-
484-
def test_build_v2_session_setup_legitimation_value_patches_oms_uuid(self) -> None:
485-
# A different OMS UUID should appear in the blob in little-endian
486-
# byte order at the documented offset, leaving the rest of the
487-
# template untouched.
488-
oms_uuid = bytes.fromhex("0102030405060708")
489-
got = _build_v2_session_setup_legitimation_value(oms_uuid)
490-
# Locate the BLOB length marker (`81 34`) and check the next 32 bytes
491-
# land us at the OMS UUID slot.
492-
idx = got.index(bytes.fromhex("81 34"))
493-
oms_le = got[idx + 2 + 32 : idx + 2 + 40]
494-
assert oms_le == bytes.fromhex("0807060504030201")
495-
496-
def test_parse_oms_session_uuid(self) -> None:
497-
# CreateObject response carries the PLC's OMS session UUID as a
498-
# WString shaped "01:HEX". The parser captures it as raw bytes for
499-
# the V2 session-setup legitimation value.
453+
def test_parse_public_key_checksum(self) -> None:
454+
# CreateObject response carries the Siemens RSA public-key
455+
# checksum as a WString shaped "01:HEX" in attribute 233
456+
# (ObjectVariableTypeName). The parser captures it as raw bytes
457+
# for diagnostics.
500458
conn = S7CommPlusConnection("127.0.0.1")
501459
payload = bytearray()
502460
payload += bytes([ElementID.ATTRIBUTE])
@@ -506,7 +464,7 @@ def test_parse_oms_session_uuid(self) -> None:
506464
payload += encode_uint32_vlq(len(text))
507465
payload += text.encode("utf-8")
508466
conn._parse_create_object_response(bytes(payload))
509-
assert conn._oms_session_uuid == bytes.fromhex("BD426B091F08731A")
467+
assert conn._public_key_checksum == bytes.fromhex("BD426B091F08731A")
510468

511469
def test_parse_struct_version(self) -> None:
512470
# Real S7-1200/1500 PLCs send ServerSessionVersion as Struct(314)

0 commit comments

Comments
 (0)