Skip to content

Commit 61074b7

Browse files
Port opcua_endpoint_enum onto the OPC-UA library
1 parent 4b8cea1 commit 61074b7

7 files changed

Lines changed: 920 additions & 494 deletions

File tree

lib/rex/proto/opc_ua/secure_channel.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,33 @@ class OpenSecureChannelResponse < BinData::Record
140140
opc_ua_byte_string :server_nonce
141141
end
142142

143+
# Decode the body of an OPN message, which is everything the transport hands
144+
# back after the 8 byte message header.
145+
#
146+
# An OPN body is framed as a plaintext SecureChannelId, an
147+
# AsymmetricSecurityHeader, a SequenceHeader and the TypeId naming the
148+
# service, and only then the service structure. The lengths of the first three
149+
# depend on their contents, so they have to be walked rather than skipped.
150+
#
151+
# The envelope is identical for a request and a response, and what follows the
152+
# TypeId is decided by that TypeId, so this reads the response form the caller
153+
# asked for rather than dispatching on it.
154+
#
155+
# @param body [String] the OPN message body.
156+
# @return [OpenSecureChannelResponse]
157+
# @raise [BinData::ValidityError] if a record along the way will not decode.
158+
# @raise [IOError] if the body is shorter than the framing it declares.
159+
def self.parse_open_response(body)
160+
raw = body.to_s.b
161+
offset = 4 # SecureChannelId
162+
163+
[AsymmetricSecurityHeader, SequenceHeader, Rex::Proto::OpcUa::Types::OpcUaNodeId].each do |record|
164+
offset += record.read(raw.byteslice(offset..-1).to_s).num_bytes
165+
end
166+
167+
OpenSecureChannelResponse.read(raw.byteslice(offset..-1).to_s)
168+
end
169+
143170
# CloseSecureChannelRequest. The channel being closed is the one the message
144171
# is sent on, so the request carries nothing beyond its header.
145172
#

lib/rex/proto/opc_ua/tcp.rb

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,28 @@ class ErrorMessage < BinData::Record
132132

133133
uint32 :status_code
134134
opc_ua_string :reason
135+
136+
# Decode a body that arrives only once the server has decided it cannot
137+
# answer, so it is read as leniently as it can be: the two fields are taken
138+
# one at a time, and a Reason that will not decode still leaves the
139+
# StatusCode reportable. An ERR is the server's only account of why it gave
140+
# up, and half of one is worth more than none.
141+
#
142+
# @param body [String] the two fields, without any framing.
143+
# @return [Array(Integer, String), Array(Integer, nil), Array(nil, nil)] the
144+
# StatusCode and Reason, either of which is nil when it could not be read.
145+
def self.decode(body)
146+
raw = body.to_s.b
147+
return [nil, nil] if raw.bytesize < 4
148+
149+
reason = begin
150+
Rex::Proto::OpcUa::Types::OpcUaString.read(raw.byteslice(4..-1).to_s).snapshot
151+
rescue ::IOError, ::BinData::ValidityError
152+
nil
153+
end
154+
155+
[raw.byteslice(0, 4).unpack1('V'), reason]
156+
end
135157
end
136158

137159
# One framed message as it came off the wire. The body excludes the header.
@@ -160,10 +182,12 @@ def intermediate?
160182

161183
# Frames and reassembles OPC-UA TCP messages over a socket.
162184
#
163-
# The only thing required of the socket is get_once(length, timeout), which is
164-
# what makes this testable without a network: Msf::Exploit::Remote::Tcp#sock
165-
# satisfies it and so does a test double. Writing is deliberately not part of
166-
# this class, since building a request is the business of the layer above.
185+
# The only thing required of the socket is get_once(length, timeout) and put,
186+
# which is what makes this testable without a network:
187+
# Msf::Exploit::Remote::Tcp#sock satisfies it and so does a test double.
188+
#
189+
# Building the body of a request belongs to the layer above; what belongs here
190+
# is the header that wraps it, so that a caller cannot get MessageSize wrong.
167191
class MessageStream
168192
# Seconds allowed per read when the caller gives no timeout of its own.
169193
DEFAULT_TIMEOUT = 5
@@ -181,6 +205,22 @@ def initialize(sock, timeout: DEFAULT_TIMEOUT)
181205
@timeout = timeout
182206
end
183207

208+
# Frame a message and write it. MessageSize counts the header, so it is
209+
# computed here rather than trusted from the caller.
210+
#
211+
# Nothing this library sends needs more than one chunk: a Hello, an
212+
# OpenSecureChannel and a GetEndpoints request are all small, and the
213+
# SendBufferSize a server may impose is at least 8192 bytes.
214+
#
215+
# @param message_type [String] a MessageType value.
216+
# @param body [String] everything that follows the 8 byte header.
217+
# @param chunk_type [String] a ChunkType value.
218+
# @return [Integer] the number of bytes written.
219+
def send_message(message_type, body, chunk_type: ChunkType::FINAL)
220+
raw = body.to_s.b
221+
@sock.put((message_type + chunk_type).b + [HEADER_LEN + raw.bytesize].pack('V') + raw)
222+
end
223+
184224
# Read exactly len bytes, accumulating across reads. A single read is not
185225
# guaranteed to return the full amount, and a GetEndpoints response carrying
186226
# server certificates routinely spans several segments.
@@ -307,22 +347,15 @@ def abort_error(body)
307347
Error::AbortError.new(**status_and_reason(body.byteslice(SECURE_MSG_PREFIX_LEN..-1).to_s))
308348
end
309349

310-
# Decode the StatusCode and Reason that an ERR body and an abort body both
311-
# carry, in the same two fields in the same order.
312-
#
313-
# Either arrives only once the server has decided it cannot answer, so a
314-
# body that will not decode is still reported as the failure it is rather
315-
# than being replaced by a complaint about the decode; the StatusCode is
316-
# simply left unknown.
350+
# The StatusCode and Reason that an ERR body and an abort body both carry, in
351+
# the same two fields in the same order.
317352
#
318353
# @param body [String] the bytes of the two fields.
319-
# @return [Hash] keyword arguments for the exception, empty when the body
320-
# could not be decoded.
354+
# @return [Hash] keyword arguments for the exception.
321355
def status_and_reason(body)
322-
err = ErrorMessage.read(body)
323-
{ status_code: err.status_code.snapshot, reason: err.reason.snapshot }
324-
rescue ::IOError, ::BinData::Error
325-
{}
356+
status_code, reason = ErrorMessage.decode(body)
357+
358+
{ status_code: status_code, reason: reason }
326359
end
327360
end
328361
end

0 commit comments

Comments
 (0)