From 9710f8dccda6cc7dfcd466fd77123ef18fd3778f Mon Sep 17 00:00:00 2001 From: ethan-thomason Date: Wed, 26 Aug 2026 08:10:46 -0700 Subject: [PATCH 1/8] Add OPC-UA endpoint enumeration auxiliary scanner module --- .../scanner/scada/opcua_endpoint_enum.md | 369 ++++++++++ .../scanner/scada/opcua_endpoint_enum.rb | 682 ++++++++++++++++++ 2 files changed, 1051 insertions(+) create mode 100644 documentation/modules/auxiliary/scanner/scada/opcua_endpoint_enum.md create mode 100644 modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb diff --git a/documentation/modules/auxiliary/scanner/scada/opcua_endpoint_enum.md b/documentation/modules/auxiliary/scanner/scada/opcua_endpoint_enum.md new file mode 100644 index 0000000000000..16482f8ec6367 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/scada/opcua_endpoint_enum.md @@ -0,0 +1,369 @@ +## Vulnerable Application + +This module enumerates the endpoints advertised by an OPC-UA server over the +OPC-UA TCP binary transport (`opc.tcp://`). OPC-UA (IEC 62541) is the dominant +interoperability standard in industrial automation and is exposed by PLCs, SCADA +platforms, historians, and gateway products. + +The module performs the following exchange: + +1. **HEL -> ACK** - the OPC-UA connection handshake. +2. **OPN -> OPN** - `OpenSecureChannel` using `SecurityPolicy=None`. No + cryptography is applied; the asymmetric security header carries the None + policy URI with null certificate fields. +3. **MSG -> MSG** - the `GetEndpoints` service call. +4. **CLO** - `CloseSecureChannel`, so the channel is released rather than left + open until its lifetime expires. + +`GetEndpoints` is specified as a discovery service that must be reachable +without authentication, so that a client can learn how it is expected to +connect. This holds even when every endpoint the server actually offers demands +encryption and credentials. The module therefore enumerates and reports the +advertised security posture; it does not authenticate, does not browse the +address space, and does not read or write any tags. + +For each endpoint the module reports: + +* the advertised endpoint URL +* `MessageSecurityMode` (None, Sign, or SignAndEncrypt) +* `SecurityPolicyUri` (None, Basic256Sha256, and so on) +* the accepted `UserIdentityToken` types (Anonymous, UserName, Certificate, + IssuedToken) + +It also reports the server's `ApplicationUri` and `ProductUri`, which fingerprint +the product and distinguish, for example, an Ignition OPC-UA server from Kepware +or a bare open62541 instance. + +An endpoint that accepts the **Anonymous** identity token over a channel with +`MessageSecurityMode` **None** is flagged, and a vulnerability is recorded. Such +an endpoint allows any host that can reach the port to connect with no +credentials over an unencrypted channel, which on most deployments is sufficient +to read live process data and, depending on the server's node permissions, to +write it. + +### Port Notes + +The IANA-registered port for OPC-UA TCP is **4840**, which is this module's +default `RPORT`. Several common OT products use non-standard ports: + +* **Inductive Automation Ignition** runs its OPC-UA server on **62541** by + default. Set `RPORT 62541` when scanning Ignition gateways. +* By default Ignition binds its OPC-UA server to **localhost only**. A + default-configured gateway is not reachable across the network until an + administrator adds a non-loopback bind address. See "Setting Up a Test Server" + below for how to change this on 8.3.x. + +### Advertised Endpoint URLs + +Servers commonly advertise endpoint URLs that are not reachable from the +scanning host. Ignition, for example, advertises its configured hostname +alongside `localhost` and `127.0.0.1`. These are the server's own view of how it +can be reached, reported verbatim. A client that follows an advertised URL rather +than the address it connected to will fail against those entries. + +### Setting Up a Test Server + +Any OPC-UA server will exercise the endpoint enumeration path. Two targets are +documented here: an Ignition gateway, which is the primary real-world target, +and a `node-opcua` server, which additionally exercises the weak-endpoint +reporting path. + +**Inductive Automation Ignition (Docker)** + +``` +docker run -d --name ignition-opcua-test \ + -p 8088:8088 -p 62541:62541 \ + -e ACCEPT_IGNITION_EULA=Y \ + -e GATEWAY_ADMIN_USERNAME=admin \ + -e GATEWAY_ADMIN_PASSWORD=password \ + -e IGNITION_EDITION=standard \ + inductiveautomation/ignition:8.3 +``` + +The gateway commissions unattended. The OPC-UA server is enabled by default but +binds to loopback only, so it must be exposed before it is reachable. On 8.3.x +the bind address is a gateway config resource and can be changed over the REST +API with `curl`, with no browser step. The change takes effect immediately; the +OPC-UA module rebinds its endpoints without a gateway restart. The following was +verified against `inductiveautomation/ignition:8.3` (build 8.3.9) and requires +`curl` and `jq`. + +```bash +GW=http://localhost:8088 +JAR=$(mktemp) + +# 1. Authenticate (8.3.x IdP chained-token flow). Walk the login redirects to +# capture the OIDC auth URL (carries state+nonce) and the IdP token. +url="$GW/data/app/login"; OIDC_URL=""; LOGIN_URL="" +for _ in $(seq 1 12); do + redirect=$(curl -s -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' "$url") + case "$redirect" in + */idp/default/oidc/auth\?*) OIDC_URL="$redirect" ;; + */idp/default/authn/login\?*) LOGIN_URL="$redirect" ;; + esac + [ -z "$redirect" ] && break; url="$redirect" +done +T0=$(printf '%s' "$LOGIN_URL" | sed -n 's/.*[?&]token=\([^&]*\).*/\1/p') + +T1=$(curl -s -b "$JAR" -c "$JAR" -H 'Content-Type: application/json' \ + -d "{\"token\":\"$T0\"}" "$GW/idp/default/authn/next-challenge" | jq -r .token) +T2=$(curl -s -b "$JAR" -c "$JAR" -H 'Content-Type: application/json' \ + -d "{\"token\":\"$T1\",\"challenge\":{\"username\":\"admin\",\"password\":\"password\"}}" \ + "$GW/idp/default/authn/submit-challenge/basic" | jq -r .token) +T3=$(curl -s -b "$JAR" -c "$JAR" -H 'Content-Type: application/json' \ + -d "{\"token\":\"$T2\"}" "$GW/idp/default/authn/next-challenge" | jq -r .token) +curl -s -b "$JAR" -c "$JAR" -o /dev/null -L "${OIDC_URL}&token=$T3" + +# 2. CSRF token (required for the write) +CSRF=$(curl -s -b "$JAR" "$GW/data/app/session" | jq -r .csrfToken) + +# 3. Read the OPC-UA server config, set bindAddresses to 0.0.0.0, PUT it back. +# Note the write path has no "/singleton/" segment and the body is an array. +curl -s -b "$JAR" \ + "$GW/data/api/v1/resources/singleton/com.inductiveautomation.opcua/server-config" \ + | jq '.config.endpoint.bindAddresses = ["0.0.0.0"] | [.]' \ + | curl -s -b "$JAR" -X PUT \ + -H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' --data @- \ + "$GW/data/api/v1/resources/com.inductiveautomation.opcua/server-config" +``` + +Verify from the gateway log rather than from a host socket listing. With Docker +port publishing the host socket shows `*:62541` whether or not the container +process is bound to loopback, so `ss` cannot distinguish the two states: + +```bash +docker logs ignition-opcua-test 2>&1 | grep "Binding endpoint" | tail -6 +# ... Binding endpoint opc.tcp://:62541 to 0.0.0.0:62541 [Basic256Sha256/SignAndEncrypt] +``` + +To revert, repeat step 3 with `["localhost"]`. + +The bind address governs which interfaces the server listens on. The endpoint +URLs it advertises come from a separate `endpointAddresses` setting (hostname, +`localhost`, `127.0.0.1`) and are unchanged by the procedure above, which is why +loopback entries still appear in the enumerated list. + +**A standalone node-opcua server (Docker)** + +Ignition offers no unsecured endpoint, so it cannot exercise the module's +weak-endpoint reporting. `node-opcua` serves its default endpoint set, which +includes `SecurityPolicy=None` with anonymous access alongside the secured +policies. The package version is pinned so that the endpoint set stays +reproducible; a later release that changes the defaults would silently remove +the unsecured endpoint. + +`Dockerfile`: + +``` +FROM node:20-slim +WORKDIR /app +RUN npm install node-opcua@2.175.6 +COPY server.js . +EXPOSE 4840 +CMD ["node", "server.js"] +``` + +`server.js`: + +```javascript +const { OPCUAServer, Variant, DataType } = require("node-opcua"); +(async () => { + const server = new OPCUAServer({ + port: 4840, + resourcePath: "/UA/BackdraftTest", + buildInfo: { + productName: "BackdraftNodeOpcuaTestServer", + buildNumber: "1", + buildDate: new Date() + } + }); + await server.initialize(); + const addressSpace = server.engine.addressSpace; + const namespace = addressSpace.getOwnNamespace(); + const device = namespace.addObject({ + organizedBy: addressSpace.rootFolder.objects, + browseName: "ProcessValues" + }); + let level = 42.5; + namespace.addVariable({ + componentOf: device, + browseName: "TankLevel", + dataType: "Double", + value: { + get: () => new Variant({ dataType: DataType.Double, value: level }) + } + }); + namespace.addVariable({ + componentOf: device, + browseName: "BatchId", + dataType: "String", + value: { + get: () => new Variant({ dataType: DataType.String, value: "LOT-2026-0001" }) + } + }); + await server.start(); + console.log("[*] node-opcua server listening"); + server.endpoints.forEach((ep) => { + ep.endpointDescriptions().forEach((desc) => { + console.log( + `[*] ${desc.endpointUrl} mode=${desc.securityMode} policy=${desc.securityPolicyUri}` + ); + }); + }); +})(); +``` + +Build and run. The explicit `--hostname` is worth setting: `node-opcua` derives +the advertised endpoint URL from the host name, which inside a container +otherwise defaults to the container ID. + +``` +docker build -t backdraft/node-opcua . +docker run -d --name ua-node --hostname ua-node -p 4840:4840 backdraft/node-opcua +docker logs ua-node +``` + +The server prints its own endpoint list at startup, which is a useful +independent check on what the module reports. `mode=1` is None, `mode=2` is +Sign, `mode=3` is SignAndEncrypt: + +``` +[*] node-opcua server listening +[*] opc.tcp://ua-node:4840/UA/BackdraftTest mode=1 policy=http://opcfoundation.org/UA/SecurityPolicy#None +[*] opc.tcp://ua-node:4840/UA/BackdraftTest mode=2 policy=http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 +[*] opc.tcp://ua-node:4840/UA/BackdraftTest mode=2 policy=http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep +[*] opc.tcp://ua-node:4840/UA/BackdraftTest mode=2 policy=http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss +[*] opc.tcp://ua-node:4840/UA/BackdraftTest mode=3 policy=http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 +[*] opc.tcp://ua-node:4840/UA/BackdraftTest mode=3 policy=http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep +[*] opc.tcp://ua-node:4840/UA/BackdraftTest mode=3 policy=http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss +``` + +open62541, Eclipse Milo, and the Prosys Simulation Server are alternatives that +also ship an unsecured endpoint by default. + +## Verification Steps + +1. Start `msfconsole`. +2. `use auxiliary/scanner/scada/opcua_endpoint_enum` +3. `set RHOSTS ` +4. If the target is an Ignition gateway, `set RPORT 62541`. +5. `run` +6. Each advertised endpoint is listed with its security policy, security mode, + and accepted identity token types, followed by the server's `ApplicationUri` + and `ProductUri`. + +## Options + +### READ_TIMEOUT + +Advanced option. Seconds to wait for each OPC-UA response. Defaults to **5**. +Increase on high-latency links or when a server advertises a large number of +endpoints, each carrying a certificate. + +## Scenarios + +### Inductive Automation Ignition 8.3.4 + +A gateway with its OPC-UA server exposed on 62541. All advertised endpoints +require encryption and a username, so nothing is flagged: + +``` +msf6 > use auxiliary/scanner/scada/opcua_endpoint_enum +msf6 auxiliary(scanner/scada/opcua_endpoint_enum) > set RHOSTS 10.10.0.3 +RHOSTS => 10.10.0.3 +msf6 auxiliary(scanner/scada/opcua_endpoint_enum) > set RPORT 62541 +RPORT => 62541 +msf6 auxiliary(scanner/scada/opcua_endpoint_enum) > run + +[+] 10.10.0.3:62541 - OPC-UA server enumerated - 3 endpoint(s), 0 unauthenticated and unencrypted +[*] 10.10.0.3:62541 - [0] opc.tcp://bd-83-primary:62541 +[*] 10.10.0.3:62541 - security: Basic256Sha256/SignAndEncrypt identity: UserName +[*] 10.10.0.3:62541 - [1] opc.tcp://localhost:62541 +[*] 10.10.0.3:62541 - security: Basic256Sha256/SignAndEncrypt identity: UserName +[*] 10.10.0.3:62541 - [2] opc.tcp://127.0.0.1:62541 +[*] 10.10.0.3:62541 - security: Basic256Sha256/SignAndEncrypt identity: UserName +[*] 10.10.0.3:62541 - ApplicationUri: urn:inductiveautomation:ignition:opcua:server:20bd682b-9fa1-4741-9758-341ca9ee66fb +[*] 10.10.0.3:62541 - ProductUri: urn:inductiveautomation:ignition:opcua:server +[*] 10.10.0.3:62541 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` + +Note that the endpoint list is returned even though no endpoint offers the None +security policy. The discovery channel is open by specification regardless of +what the server's real endpoints require. + +### node-opcua 2.175.6, unsecured endpoint present + +The `ua-node` container described above, on the default port. Endpoint 0 offers +`SecurityPolicy=None` with `MessageSecurityMode=None` and accepts the Anonymous +identity token, so it is flagged and a vulnerability is recorded: + +``` +msf6 > use auxiliary/scanner/scada/opcua_endpoint_enum +msf6 auxiliary(scanner/scada/opcua_endpoint_enum) > set RHOSTS 127.0.0.1 +RHOSTS => 127.0.0.1 +msf6 auxiliary(scanner/scada/opcua_endpoint_enum) > run + +[+] 127.0.0.1:4840 - OPC-UA server enumerated - 7 endpoint(s), 1 unauthenticated and unencrypted +[*] 127.0.0.1:4840 - [0] opc.tcp://ua-node:4840/UA/BackdraftTest +[*] 127.0.0.1:4840 - security: None/None identity: UserName, Certificate, Anonymous +[!] 127.0.0.1:4840 - endpoint accepts anonymous clients over an unencrypted channel +[*] 127.0.0.1:4840 - [1] opc.tcp://ua-node:4840/UA/BackdraftTest +[*] 127.0.0.1:4840 - security: Basic256Sha256/Sign identity: UserName, Certificate, Anonymous +[*] 127.0.0.1:4840 - [2] opc.tcp://ua-node:4840/UA/BackdraftTest +[*] 127.0.0.1:4840 - security: Aes128_Sha256_RsaOaep/Sign identity: UserName, Certificate, Anonymous +[*] 127.0.0.1:4840 - [3] opc.tcp://ua-node:4840/UA/BackdraftTest +[*] 127.0.0.1:4840 - security: Aes256_Sha256_RsaPss/Sign identity: UserName, Certificate, Anonymous +[*] 127.0.0.1:4840 - [4] opc.tcp://ua-node:4840/UA/BackdraftTest +[*] 127.0.0.1:4840 - security: Basic256Sha256/SignAndEncrypt identity: UserName, Certificate, Anonymous +[*] 127.0.0.1:4840 - [5] opc.tcp://ua-node:4840/UA/BackdraftTest +[*] 127.0.0.1:4840 - security: Aes128_Sha256_RsaOaep/SignAndEncrypt identity: UserName, Certificate, Anonymous +[*] 127.0.0.1:4840 - [6] opc.tcp://ua-node:4840/UA/BackdraftTest +[*] 127.0.0.1:4840 - security: Aes256_Sha256_RsaPss/SignAndEncrypt identity: UserName, Certificate, Anonymous +[*] 127.0.0.1:4840 - ApplicationUri: urn:ua-node:NodeOPCUA-Server +[*] 127.0.0.1:4840 - ProductUri: NodeOPCUA-Server +[*] 127.0.0.1:4840 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` + +All seven endpoints share one URL and differ only in security policy and mode, +which is normal: an OPC-UA server advertises one endpoint per supported +policy/mode combination. + +### Server not reachable or not OPC-UA + +Hosts that do not answer the Hello are skipped silently unless `VERBOSE` is set: + +``` +msf6 auxiliary(scanner/scada/opcua_endpoint_enum) > set VERBOSE true +VERBOSE => true +msf6 auxiliary(scanner/scada/opcua_endpoint_enum) > run + +[*] 10.10.0.9:4840 - No OPC-UA response to HEL +[*] 10.10.0.9:4840 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` + +A server that answers the Hello but refuses an unsecured channel is reported as +present, with the reason, and enumeration stops there. + +## Confirming Detection + +The module records a service of type `opc-ua`, a note of type `opcua.endpoints` +holding the full parsed endpoint list, and a vulnerability entry for any endpoint +accepting anonymous identity without encryption. + +``` +msf6 > services -S opc-ua +msf6 > notes -t opcua.endpoints +msf6 > vulns +``` + +## References + +* OPC-UA Specification Part 4 (Services) - the GetEndpoints service and the + EndpointDescription structure, +* OPC-UA Specification Part 6 (Mappings) - binary encoding and the OPC-UA + Connection Protocol, +* OPC Foundation - OPC-UA overview, + diff --git a/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb b/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb new file mode 100644 index 0000000000000..4779ac586e0cd --- /dev/null +++ b/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb @@ -0,0 +1,682 @@ +# frozen_string_literal: true + +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + # Every OPC-UA TCP message begins with an 8 byte header: + # MessageType (3 bytes ASCII) + ChunkType (1 byte ASCII) + MessageSize (UInt32 LE) + # MessageSize is the total length including the header itself. + HEADER_LEN = 8 + + # Each MSG chunk repeats SecureChannelId + TokenId + SequenceNumber + RequestId + # ahead of its slice of the service payload. + SECURE_MSG_PREFIX_LEN = 16 + + NONE_POLICY_URI = 'http://opcfoundation.org/UA/SecurityPolicy#None' + + # NodeIds for the services used here, in FourByte encoding: + # 0x01 (FourByte) + NamespaceIndex (Byte) + Identifier (UInt16 LE) + # Numeric identifiers are from OPC-UA Specification Part 6, Annex A. + OPN_REQUEST_NODEID = [0x01, 0x00, 446].pack('CCv').freeze + GET_ENDPOINTS_NODEID = [0x01, 0x00, 428].pack('CCv').freeze + CLOSE_CHANNEL_NODEID = [0x01, 0x00, 452].pack('CCv').freeze + + # MessageSecurityMode enumeration (Part 4, section 7.15). + SECURITY_MODES = { + 0 => 'Invalid', + 1 => 'None', + 2 => 'Sign', + 3 => 'SignAndEncrypt' + }.freeze + + # UserTokenType enumeration (Part 4, section 7.36). + TOKEN_TYPES = { + 0 => 'Anonymous', + 1 => 'UserName', + 2 => 'Certificate', + 3 => 'IssuedToken' + }.freeze + + # OPC-UA StatusCodes that may appear in an ERR response from the UA TCP + # transport. Values per the OPC Foundation StatusCodes definitions + # (Opc.Ua.StatusCodes) and OPC-UA Specification Part 6. + STATUS_CODES = { + # UA TCP transport-specific errors (Part 6, 7.1.2) + 0x807D0000 => 'Bad_TcpServerTooBusy', + 0x807E0000 => 'Bad_TcpMessageTypeInvalid', + 0x807F0000 => 'Bad_TcpSecureChannelUnknown', + 0x80800000 => 'Bad_TcpMessageTooLarge', + 0x80810000 => 'Bad_TcpNotEnoughResources', + 0x80820000 => 'Bad_TcpInternalError', + 0x80830000 => 'Bad_TcpEndpointUrlInvalid', + # Connection/security errors also seen at the transport layer + 0x80BE0000 => 'Bad_ProtocolVersionUnsupported', + 0x80130000 => 'Bad_SecurityChecksFailed', + 0x80120000 => 'Bad_CertificateInvalid', + 0x80840000 => 'Bad_RequestInterrupted', + 0x80850000 => 'Bad_RequestTimeout', + 0x80860000 => 'Bad_SecureChannelClosed', + 0x80870000 => 'Bad_SecureChannelTokenUnknown', + 0x80AC0000 => 'Bad_ConnectionRejected', + 0x80AE0000 => 'Bad_ConnectionClosed' + }.freeze + + # Defensive ceilings. A malformed or hostile response must fail quickly rather + # than allocate without bound or spin on an absurd array length. + MAX_MESSAGE_SIZE = 4 * 1024 * 1024 + MAX_CHUNKS = 64 + MAX_ENDPOINTS = 64 + MAX_ARRAY_LENGTH = 512 + + # Raised whenever a decode would read past the end of a response buffer or + # encounter an encoding this module does not handle. Always caught locally. + class UaParseError < StandardError; end + + # Position tracking reader over an OPC-UA binary message body. + # Encoding rules follow OPC-UA Specification Part 6, section 5.2. All + # multi-byte integers are little-endian. Every reader advances the cursor and + # bounds-checks first, so a truncated response raises instead of silently + # desynchronising the walk through the nested structures. + class Cursor + def initialize(data) + @data = data.to_s.dup.force_encoding('BINARY') + @pos = 0 + end + + def remaining + @data.bytesize - @pos + end + + def take(len) + raise UaParseError, "read of #{len} bytes past end of buffer" if len.negative? || len > remaining + + out = @data.byteslice(@pos, len) + @pos += len + out + end + + def u8 + take(1).unpack1('C') + end + + def u16 + take(2).unpack1('v') + end + + def u32 + take(4).unpack1('V') + end + + def i32 + take(4).unpack1('l<') + end + + def i64 + take(8).unpack1('q<') + end + + def skip(len) + take(len) + nil + end + + # String and ByteString share a wire format: an Int32 length prefix followed + # by that many bytes. A negative length denotes null; zero denotes empty. + def bytestring + len = i32 + return nil if len.negative? + + take(len) + end + + def string + raw = bytestring + return nil if raw.nil? + + raw.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') + end + + def skip_string + bytestring + nil + end + + # Array length prefix. A negative value denotes a null array. Anything above + # the ceiling is treated as a malformed response. + def array_length(max = MAX_ARRAY_LENGTH) + len = i32 + return 0 if len.negative? + raise UaParseError, "array length #{len} exceeds ceiling #{max}" if len > max + + len + end + + # LocalizedText: one encoding mask byte, then Locale and/or Text depending + # on mask bits 0x01 and 0x02. Returns the Text field only. + def localized_text + mask = u8 + skip_string if (mask & 0x01).positive? + (mask & 0x02).positive? ? string : nil + end + + # NodeId. The low nibble of the leading byte selects the identifier form; + # bits 0x40 and 0x80 add trailing NamespaceUri and ServerIndex fields. + def skip_node_id + encoding = u8 + case encoding & 0x0F + when 0x00 then skip(1) # TwoByte: Identifier only + when 0x01 then skip(3) # FourByte: ns (Byte) + id (UInt16) + when 0x02 then skip(6) # Numeric: ns (UInt16) + id (UInt32) + when 0x03 # String: ns (UInt16) + String + skip(2) + skip_string + when 0x04 then skip(2 + 16) # GUID: ns (UInt16) + 16 bytes + when 0x05 # ByteString: ns (UInt16) + ByteString + skip(2) + skip_string + else + raise UaParseError, format('unknown NodeId encoding 0x%02X', encoding) + end + skip_string if (encoding & 0x80).positive? # NamespaceUri (String), per Part 6 5.2.2.9 + skip(4) if (encoding & 0x40).positive? # ServerIndex (UInt32), per Part 6 5.2.2.9 + nil + end + + # ExtensionObject: TypeId NodeId, an encoding byte, then an optional body. + def skip_extension_object + skip_node_id + encoding = u8 + case encoding + when 0x00 then nil # no body + when 0x01, 0x02 then skip_string # ByteString or XmlElement body + else + raise UaParseError, format('unknown ExtensionObject encoding 0x%02X', encoding) + end + nil + end + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'OPC-UA Endpoint Enumeration', + 'Description' => %q{ + This module enumerates the endpoints advertised by an OPC-UA server + over the OPC-UA TCP binary transport (opc.tcp://). It performs the + connection handshake, opens a secure channel with SecurityPolicy=None, + and calls the GetEndpoints service, which the specification requires to + be available without authentication so that clients can discover how to + connect. + + For each returned endpoint the module reports the advertised URL, the + MessageSecurityMode, the SecurityPolicy, and the accepted user identity + token types, along with the server's ApplicationUri and ProductUri as a + fingerprint. Endpoints that accept anonymous identity over an + unencrypted channel are flagged, as these allow an unauthenticated + client to read and potentially write process data. + + No credentials are used and no address space operations are performed. + }, + 'Author' => [ + 'Ethan Thomason ' + ], + 'References' => [ + ['URL', 'https://reference.opcfoundation.org/Core/Part4/'], + ['URL', 'https://reference.opcfoundation.org/Core/Part6/'], + ['URL', 'https://opcfoundation.org/about/opc-technologies/opc-ua/'] + ], + 'License' => MSF_LICENSE, + 'DefaultOptions' => { + 'RPORT' => 4840 + }, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [] + } + ) + ) + + register_advanced_options( + [ + OptInt.new('READ_TIMEOUT', [true, 'Seconds to wait for each OPC-UA response', 5]) + ] + ) + end + + def read_timeout + datastore['READ_TIMEOUT'].to_i + end + + # --------------------------------------------------------------------------- + # Encoding helpers + # --------------------------------------------------------------------------- + + # Encode a String or ByteString: Int32 length prefix then the raw bytes. + # A nil value is encoded as null (length -1). + def encode_string(str) + return [-1].pack('l<') if str.nil? + + raw = str.to_s.dup.force_encoding('BINARY') + [raw.bytesize].pack('l<') + raw + end + + def frame(msg_type, body, chunk = 'F') + size = HEADER_LEN + body.bytesize + (msg_type + chunk).b + [size].pack('V') + body + end + + # RequestHeader (Part 4, section 7.28). The authentication token is a null + # NodeId because GetEndpoints is called without a session. + def build_request_header(request_handle) + hdr = [0x00, 0x00].pack('CC') # AuthenticationToken: null NodeId + hdr << [ua_timestamp].pack('q<') # Timestamp + hdr << [request_handle].pack('V') # RequestHandle + hdr << [0].pack('V') # ReturnDiagnostics: none + hdr << encode_string(nil) # AuditEntryId: null + hdr << [10_000].pack('V') # TimeoutHint in milliseconds + hdr << [0x00, 0x00, 0x00].pack('CCC') # AdditionalHeader: null ExtensionObject + hdr + end + + # OPC-UA DateTime: 100 nanosecond ticks since 1601-01-01 UTC. + def ua_timestamp + ((::Time.now.to_f + 11_644_473_600) * 10_000_000).to_i + end + + def build_hello(endpoint_url) + body = [ + 0, # ProtocolVersion + 65_535, # ReceiveBufferSize + 65_535, # SendBufferSize + 0, # MaxMessageSize (0 = no limit) + 0 # MaxChunkCount (0 = no limit) + ].pack('V*') + body << encode_string(endpoint_url) + frame('HEL', body) + end + + # OpenSecureChannel for SecurityPolicy=None. The asymmetric security header + # carries the None policy URI with null certificate fields, so no cryptography + # is applied to this or any subsequent message on the channel. + def build_open_secure_channel + req = OPN_REQUEST_NODEID.dup + req << build_request_header(1) + req << [0].pack('V') # ClientProtocolVersion + req << [0].pack('V') # SecurityTokenRequestType: Issue + req << [1].pack('V') # MessageSecurityMode: None + req << encode_string(nil) # ClientNonce: null under the None policy + req << [3_600_000].pack('V') # RequestedLifetime in milliseconds + + asym = encode_string(NONE_POLICY_URI) # SecurityPolicyUri + asym << encode_string(nil) # SenderCertificate + asym << encode_string(nil) # ReceiverCertificateThumbprint + + seq = [1, 1].pack('VV') # SequenceNumber, RequestId + + frame('OPN', [0].pack('V') + asym + seq + req) + end + + def build_get_endpoints(channel_id, token_id, endpoint_url) + req = GET_ENDPOINTS_NODEID.dup + req << build_request_header(2) + req << encode_string(endpoint_url) # EndpointUrl + req << [-1].pack('l<') # LocaleIds: null array + req << [-1].pack('l<') # ProfileUris: null array + + frame('MSG', [channel_id, token_id, 2, 2].pack('VVVV') + req) + end + + def build_close_secure_channel(channel_id, token_id) + req = CLOSE_CHANNEL_NODEID.dup + req << build_request_header(3) + + frame('CLO', [channel_id, token_id, 3, 3].pack('VVVV') + req) + end + + # --------------------------------------------------------------------------- + # Transport + # --------------------------------------------------------------------------- + + # Read exactly len bytes, accumulating across reads. A single read is not + # guaranteed to return the full amount and a GetEndpoints response carrying + # server certificates routinely spans several segments. + def read_exact(len) + buf = ''.b + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + read_timeout + while buf.bytesize < len + left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + return nil unless left.positive? + + chunk = sock.get_once(len - buf.bytesize, left) + return nil if chunk.nil? || chunk.empty? + + buf << chunk.b + end + buf + end + + # Read one framed message. Returns [message_type, chunk_type, body] or nil. + def recv_message + header = read_exact(HEADER_LEN) + return nil if header.nil? + + size = header.byteslice(4, 4).unpack1('V') + return nil if size < HEADER_LEN || size > MAX_MESSAGE_SIZE + + body_len = size - HEADER_LEN + body = body_len.positive? ? read_exact(body_len) : ''.b + return nil if body.nil? + + [header.byteslice(0, 3), header.byteslice(3, 1), body] + end + + # Read a complete service response, reassembling chunks where the server has + # split it. Continuation chunks repeat the SecureChannelId, TokenId and + # SequenceHeader ahead of their payload slice, so those bytes are stripped + # before concatenation. The returned buffer therefore starts at the response + # TypeId, not at the SecureChannelId. + # Returns [payload, nil] on success or [nil, reason] on failure. + def recv_service_response + payload = ''.b + MAX_CHUNKS.times do + msg = recv_message + return [nil, 'no response'] if msg.nil? + + msg_type, chunk_type, body = msg + + if msg_type == 'ERR' + status, reason = decode_error(body) + detail = reason.to_s.empty? ? status : "#{status} - #{reason}" + return [nil, "server returned ERR: #{detail}"] + end + return [nil, "unexpected message type #{msg_type.inspect}"] unless msg_type == 'MSG' + return [nil, 'server aborted the response'] if chunk_type == 'A' + return [nil, 'chunk shorter than its own headers'] if body.bytesize < SECURE_MSG_PREFIX_LEN + + payload << body.byteslice(SECURE_MSG_PREFIX_LEN..-1).to_s + return [payload, nil] if chunk_type == 'F' + end + [nil, "response exceeded #{MAX_CHUNKS} chunks"] + end + + # Decode an ERR body: UInt32 StatusCode followed by a String reason. + def decode_error(body) + return ['unknown', ''] if body.bytesize < 4 + + code = body.byteslice(0, 4).unpack1('V') + status = STATUS_CODES[code] || format('0x%08X', code) + + reason = '' + if body.bytesize >= 8 + reason_len = body.byteslice(4, 4).unpack1('V') + if reason_len != 0xFFFFFFFF && reason_len.positive? && body.bytesize >= 8 + reason_len + reason = body.byteslice(8, reason_len).to_s + end + end + + [status, reason] + end + + # --------------------------------------------------------------------------- + # Response parsing + # --------------------------------------------------------------------------- + + # ResponseHeader (Part 4, section 7.29). Returns the ServiceResult. + def parse_response_header(cur) + cur.skip(8) # Timestamp + cur.skip(4) # RequestHandle + service_result = cur.u32 + diagnostics_mask = cur.u8 # ServiceDiagnostics encoding mask + raise UaParseError, 'diagnostic info present but not requested' unless diagnostics_mask.zero? + + cur.array_length.times { cur.skip_string } # StringTable + cur.skip_extension_object # AdditionalHeader + service_result + end + + # The OPN response mirrors the request framing: a plaintext SecureChannelId, + # the asymmetric security header, the sequence header, then the service body. + def parse_open_response(body) + cur = Cursor.new(body) + cur.u32 # SecureChannelId + cur.skip_string # SecurityPolicyUri + cur.skip_string # SenderCertificate + cur.skip_string # ReceiverCertificateThumbprint + cur.u32 # SequenceNumber + cur.u32 # RequestId + cur.skip_node_id # TypeId + service_result = parse_response_header(cur) + cur.u32 # ServerProtocolVersion + + { + service_result: service_result, + channel_id: cur.u32, # SecurityToken.ChannelId + token_id: cur.u32 # SecurityToken.TokenId + } + end + + # GetEndpointsResponse: a ResponseHeader followed by EndpointDescription[]. + # The payload passed in already has the secure conversation prefix stripped. + def parse_get_endpoints(payload) + cur = Cursor.new(payload) + cur.skip_node_id # TypeId + service_result = parse_response_header(cur) + return [nil, format('GetEndpoints ServiceResult=0x%08X', service_result)] unless service_result.zero? + + count = cur.array_length(MAX_ENDPOINTS) + endpoints = Array.new(count) { parse_endpoint_description(cur) } + [endpoints, nil] + end + + # EndpointDescription (Part 4, section 7.10). Field order is fixed; every + # variable length field must be consumed in sequence to keep the cursor + # aligned for the next endpoint in the array. + def parse_endpoint_description(cur) + endpoint_url = cur.string + + # Server: ApplicationDescription (Part 4, section 7.2) + application_uri = cur.string + product_uri = cur.string + application_name = cur.localized_text + cur.u32 # ApplicationType + cur.skip_string # GatewayServerUri + cur.skip_string # DiscoveryProfileUri + cur.array_length.times { cur.skip_string } # DiscoveryUrls + + server_certificate = cur.bytestring + security_mode = cur.u32 + security_policy_uri = cur.string + + # UserIdentityTokens: UserTokenPolicy[] (Part 4, section 7.37) + token_count = cur.array_length + tokens = Array.new(token_count) do + policy_id = cur.string + token_type = cur.u32 + cur.skip_string # IssuedTokenType + cur.skip_string # IssuerEndpointUrl + cur.skip_string # SecurityPolicyUri, per token + { + policy_id: policy_id, + token_type: token_type, + token_type_name: TOKEN_TYPES[token_type] || "Unknown(#{token_type})" + } + end + + cur.skip_string # TransportProfileUri + security_level = cur.u8 + + { + endpoint_url: endpoint_url, + application_uri: application_uri, + product_uri: product_uri, + application_name: application_name, + server_certificate_len: server_certificate.nil? ? 0 : server_certificate.bytesize, + security_mode: security_mode, + security_mode_name: SECURITY_MODES[security_mode] || "Unknown(#{security_mode})", + security_policy_uri: security_policy_uri, + security_policy_name: short_policy(security_policy_uri), + user_tokens: tokens, + security_level: security_level + } + end + + def short_policy(uri) + return 'Unknown' if uri.nil? || uri.empty? + + uri.include?('#') ? uri.rpartition('#').last : uri + end + + # --------------------------------------------------------------------------- + # Reporting + # --------------------------------------------------------------------------- + + def unencrypted?(endpoint) + endpoint[:security_mode_name] == 'None' || endpoint[:security_policy_name] == 'None' + end + + def anonymous?(endpoint) + endpoint[:user_tokens].any? { |t| t[:token_type].zero? } + end + + def report_endpoints(ip, endpoints) + weak = endpoints.count { |ep| unencrypted?(ep) && anonymous?(ep) } + + print_good("OPC-UA server enumerated - #{endpoints.length} endpoint(s), #{weak} unauthenticated and unencrypted") + + fingerprint = endpoints.map { |ep| ep[:application_uri] }.compact.first + product = endpoints.map { |ep| ep[:product_uri] }.compact.first + + endpoints.each_with_index do |ep, idx| + security = "#{ep[:security_policy_name]}/#{ep[:security_mode_name]}" + identity = ep[:user_tokens].map { |t| t[:token_type_name] }.uniq.join(', ') + identity = 'none advertised' if identity.empty? + + print_status(" [#{idx}] #{ep[:endpoint_url]}") + print_status(" security: #{security} identity: #{identity}") + + if unencrypted?(ep) && anonymous?(ep) + print_warning(' endpoint accepts anonymous clients over an unencrypted channel') + end + end + + print_status(" ApplicationUri: #{fingerprint}") if fingerprint + print_status(" ProductUri: #{product}") if product + + info = "OPC-UA server, #{endpoints.length} endpoint(s)" + info << ", ApplicationUri #{fingerprint}" if fingerprint + + report_service( + host: ip, + port: rport, + proto: 'tcp', + name: 'opc-ua', + info: info + ) + + report_note( + host: ip, + port: rport, + proto: 'tcp', + type: 'opcua.endpoints', + data: { endpoints: endpoints }, + update: :unique_data + ) + + return unless weak.positive? + + report_vuln( + host: ip, + port: rport, + proto: 'tcp', + name: 'OPC-UA endpoint accepting anonymous identity without encryption', + info: "#{weak} of #{endpoints.length} advertised endpoint(s) accept the Anonymous user identity token over a channel with MessageSecurityMode None", + refs: references + ) + end + + # --------------------------------------------------------------------------- + # Scanner entry point + # --------------------------------------------------------------------------- + + def run_host(ip) + connect + + endpoint_url = "opc.tcp://#{Rex::Socket.to_authority(ip, rport)}" + + sock.put(build_hello(endpoint_url)) + msg = recv_message + if msg.nil? + vprint_status('No OPC-UA response to HEL') + return + end + + unless msg[0] == 'ACK' + if msg[0] == 'ERR' + status, reason = decode_error(msg[2]) + detail = reason.to_s.empty? ? status : "#{status} - #{reason}" + print_status("OPC-UA server present but refused the Hello - #{detail}") + else + vprint_status("Non-OPC-UA response (type=#{msg[0].inspect})") + end + return + end + + vprint_good('OPC-UA Hello acknowledged, opening secure channel') + + sock.put(build_open_secure_channel) + msg = recv_message + if msg.nil? || msg[0] != 'OPN' + detail = msg.nil? ? 'no response' : "got #{msg[0].inspect}" + print_status("OpenSecureChannel with SecurityPolicy=None failed (#{detail}); endpoints cannot be enumerated") + return + end + + channel = parse_open_response(msg[2]) + unless channel[:service_result].zero? + print_status(format('OpenSecureChannel rejected, ServiceResult=0x%08X', channel[:service_result])) + return + end + + sock.put(build_get_endpoints(channel[:channel_id], channel[:token_id], endpoint_url)) + payload, error = recv_service_response + if payload.nil? + print_error("GetEndpoints failed: #{error}") + return + end + + endpoints, error = parse_get_endpoints(payload) + if endpoints.nil? + print_error(error) + return + end + + if endpoints.empty? + print_status('OPC-UA server returned no endpoints') + return + end + + report_endpoints(ip, endpoints) + + # Release the channel rather than leaving it open until its lifetime expires. + begin + sock.put(build_close_secure_channel(channel[:channel_id], channel[:token_id])) + rescue ::Rex::ConnectionError, ::EOFError, ::Errno::ECONNRESET, ::Errno::EPIPE + nil + end + rescue UaParseError => e + print_error("Malformed OPC-UA response: #{e.message}") + rescue ::Rex::ConnectionError, ::EOFError, ::Errno::ECONNRESET => e + vprint_error("#{e.class}: #{e.message}") + ensure + disconnect + end +end From fdecba98770d67062b696dcc4e7a9518d532f648 Mon Sep 17 00:00:00 2001 From: ethan-thomason Date: Thu, 27 Aug 2026 20:16:36 -0700 Subject: [PATCH 2/8] Add OPC-UA library types and enums with rspec coverage --- lib/rex/proto/opc_ua/enums.rb | 107 +++++++++ lib/rex/proto/opc_ua/types.rb | 174 ++++++++++++++ spec/file_fixtures/opc_ua/README.md | 56 +++++ spec/file_fixtures/opc_ua/ack_node_opcua.bin | Bin 0 -> 28 bytes .../get_endpoints_response_node_opcua.bin | Bin 0 -> 10648 bytes ...pen_secure_channel_response_node_opcua.bin | Bin 0 -> 135 bytes spec/lib/rex/proto/opc_ua/enums_spec.rb | 143 ++++++++++++ .../lib/rex/proto/opc_ua/opc_ua_array_spec.rb | 217 ++++++++++++++++++ spec/lib/rex/proto/opc_ua/types_spec.rb | 213 +++++++++++++++++ 9 files changed, 910 insertions(+) create mode 100644 lib/rex/proto/opc_ua/enums.rb create mode 100644 lib/rex/proto/opc_ua/types.rb create mode 100644 spec/file_fixtures/opc_ua/README.md create mode 100644 spec/file_fixtures/opc_ua/ack_node_opcua.bin create mode 100644 spec/file_fixtures/opc_ua/get_endpoints_response_node_opcua.bin create mode 100644 spec/file_fixtures/opc_ua/open_secure_channel_response_node_opcua.bin create mode 100644 spec/lib/rex/proto/opc_ua/enums_spec.rb create mode 100644 spec/lib/rex/proto/opc_ua/opc_ua_array_spec.rb create mode 100644 spec/lib/rex/proto/opc_ua/types_spec.rb diff --git a/lib/rex/proto/opc_ua/enums.rb b/lib/rex/proto/opc_ua/enums.rb new file mode 100644 index 0000000000000..f5731c9e401fb --- /dev/null +++ b/lib/rex/proto/opc_ua/enums.rb @@ -0,0 +1,107 @@ +# -*- coding: binary -*- + +# Enumerated values and identifiers from the OPC-UA specification. +# +# The numeric tables here are transcribed from the OPC Foundation's own +# machine-readable definitions rather than from prose, and can be re-verified +# against them: +# +# StatusCodes https://github.com/OPCFoundation/UA-Nodeset/blob/latest/Schema/StatusCode.csv +# NodeIds https://github.com/OPCFoundation/UA-Nodeset/blob/latest/Schema/NodeIds.csv +module Rex::Proto::OpcUa::Enums + # SecurityPolicy URI for the None policy. An endpoint offering this applies + # no signing or encryption, so a channel opened under it is readable on the + # wire and needs no key material from the client. + NONE_POLICY_URI = 'http://opcfoundation.org/UA/SecurityPolicy#None' + + # Returned by the name lookups when a value is outside the enumeration. + UNKNOWN_NAME = 'Unknown' + + # NodeId identifiers for the DefaultBinary encodings of the services used + # over this transport. All are in namespace 0. A request and its response + # differ by three, the intervening identifier being the XML encoding. + # + # The OpenSecureChannel and GetEndpoints response identifiers were also read + # back off the wire from the captures in spec/file_fixtures/opc_ua. + module NodeIds + OPEN_SECURE_CHANNEL_REQUEST = 446 + OPEN_SECURE_CHANNEL_RESPONSE = 449 + CLOSE_SECURE_CHANNEL_REQUEST = 452 + CLOSE_SECURE_CHANNEL_RESPONSE = 455 + GET_ENDPOINTS_REQUEST = 428 + GET_ENDPOINTS_RESPONSE = 431 + end + + # MessageSecurityMode (Part 4, section 7.15). + SECURITY_MODES = { + 0 => 'Invalid', + 1 => 'None', + 2 => 'Sign', + 3 => 'SignAndEncrypt' + }.freeze + + # UserTokenType (Part 4, section 7.36). + TOKEN_TYPES = { + 0 => 'Anonymous', + 1 => 'UserName', + 2 => 'Certificate', + 3 => 'IssuedToken' + }.freeze + + # StatusCodes that may appear in an ERR response from the UA TCP transport, + # or as the ServiceResult of a service that failed at the security layer. + STATUS_CODES = { + # Transport specific errors (Part 6, section 7.1.2) + 0x807D0000 => 'Bad_TcpServerTooBusy', + 0x807E0000 => 'Bad_TcpMessageTypeInvalid', + 0x807F0000 => 'Bad_TcpSecureChannelUnknown', + 0x80800000 => 'Bad_TcpMessageTooLarge', + 0x80810000 => 'Bad_TcpNotEnoughResources', + 0x80820000 => 'Bad_TcpInternalError', + 0x80830000 => 'Bad_TcpEndpointUrlInvalid', + # Connection and security errors also seen at the transport layer + 0x80BE0000 => 'Bad_ProtocolVersionUnsupported', + 0x80130000 => 'Bad_SecurityChecksFailed', + 0x80120000 => 'Bad_CertificateInvalid', + 0x80840000 => 'Bad_RequestInterrupted', + 0x80850000 => 'Bad_RequestTimeout', + 0x80860000 => 'Bad_SecureChannelClosed', + 0x80870000 => 'Bad_SecureChannelTokenUnknown', + 0x80AC0000 => 'Bad_ConnectionRejected', + 0x80AE0000 => 'Bad_ConnectionClosed' + }.freeze + + module_function + + # @param code [Integer] a StatusCode as it appears on the wire. + # @return [String] the StatusCode name, or the value in hexadecimal when it + # is not one this table carries. + def status_code_name(code) + STATUS_CODES[code] || format('0x%08X', code) + end + + # @param mode [Integer] a MessageSecurityMode value. + # @return [String] the mode name, or Unknown with the value. + def security_mode_name(mode) + SECURITY_MODES[mode] || "#{UNKNOWN_NAME}(#{mode})" + end + + # @param type [Integer] a UserTokenType value. + # @return [String] the token type name, or Unknown with the value. + def user_token_type_name(type) + TOKEN_TYPES[type] || "#{UNKNOWN_NAME}(#{type})" + end + + # Reduce a SecurityPolicy URI to the fragment that names the policy, so that + # http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 reports as + # Basic256Sha256. A URI carrying no fragment is returned whole rather than + # discarded, since an unrecognised policy is still worth reporting. + # + # @param uri [String, nil] a SecurityPolicyUri. + # @return [String] the policy name. + def security_policy_name(uri) + return UNKNOWN_NAME if uri.nil? || uri.empty? + + uri.include?('#') ? uri.rpartition('#').last : uri + end +end diff --git a/lib/rex/proto/opc_ua/types.rb b/lib/rex/proto/opc_ua/types.rb new file mode 100644 index 0000000000000..651b419d9a967 --- /dev/null +++ b/lib/rex/proto/opc_ua/types.rb @@ -0,0 +1,174 @@ +# -*- coding: binary -*- + +require 'bindata' + +module Rex::Proto::OpcUa::Types + # OPC-UA encodes String and ByteString identically on the wire: a signed + # Int32 length prefix followed by that many bytes. A length of -1 denotes a + # null value, which the specification treats as distinct from a length of 0 + # denoting an empty value. See OPC-UA Specification Part 6, section 5.2.2. + # + # BinData has no native type for a length prefix that doubles as a null + # sentinel, so the read and write paths are implemented directly against + # BasePrimitive. This follows LengthPrefixedString in + # Msf::Util::DotNetDeserialization::Types, which solves the equivalent + # problem for the .NET remoting 7-bit length prefix. + # + # Null is represented in Ruby as nil and empty as an empty string, so the + # distinction survives a decode and re-encode unchanged. + class OpcUaByteString < BinData::BasePrimitive + # The length prefix denoting a null value. + NULL_LENGTH = -1 + + # BasePrimitive#assign rejects nil outright, but null is a legitimate value + # here and is what several request fields are required to carry, such as + # the ClientNonce and SenderCertificate of an OpenSecureChannelRequest sent + # under SecurityPolicy None. Accept nil and store it as the null value. + # + # BinData::Base#initialize skips #assign when constructed with nil, so + # .new(nil) already yields a null; this makes the explicit assignment and + # field setter paths agree with it. + def assign(val) + return @value = nil if val.nil? + + super + end + + private + + def value_to_binary_string(val) + return [NULL_LENGTH].pack('l<') if val.nil? + + raw = val.to_s.dup.force_encoding('BINARY') + [raw.bytesize].pack('l<') + raw + end + + def read_and_return_value(io) + length = io.readbytes(4).unpack1('l<') + # The specification defines only -1, but any negative length is read as + # null. A scanner should not discard an otherwise intact response over an + # out of spec sentinel whose intent is unambiguous. + return nil if length.negative? + + io.readbytes(length) + end + + def sensible_default + nil + end + end + + # A String has the ByteString wire format with UTF-8 content. + # + # The bytes arrive from an unauthenticated server, so invalid sequences are + # scrubbed rather than raised on. Note that scrubbing is not the same as + # transcoding: String#encode from BINARY to UTF-8 would treat every byte at + # or above 0x80 as undefined and replace it, turning a valid "cafe" with an + # e-acute into "caf??". Tagging the bytes UTF-8 and scrubbing preserves valid + # multi-byte text and replaces only what is genuinely malformed. + class OpcUaString < OpcUaByteString + # Substituted for each malformed byte sequence. + REPLACEMENT_CHARACTER = '?' + + private + + def read_and_return_value(io) + raw = super + return nil if raw.nil? + + raw.force_encoding('UTF-8').scrub(REPLACEMENT_CHARACTER) + end + end + + # An array is an Int32 element count followed by that many elements. As with + # String and ByteString a negative count denotes null, which the + # specification treats as distinct from a count of zero denoting an empty + # array. See OPC-UA Specification Part 6, section 5.2.5. + # + # The element type is supplied as the ordinary BinData :type parameter at the + # declaration site, and :max_length caps how many elements will be read: + # + # opc_ua_array :endpoints, type: :opc_ua_endpoint_description, max_length: 64 + # + # The ceiling is load bearing rather than defensive dressing. The count comes + # off the wire from an unauthenticated server and BinData allocates an object + # per element, so without it a claimed count of 2**31 - 1 would be attempted. + # + # A null array presents as empty, so it can be iterated without a nil check; + # #null? preserves the wire distinction so that a decode and re-encode is + # byte for byte unchanged. + class OpcUaArray < BinData::Array + # The element count denoting a null array. + NULL_LENGTH = -1 + + # Applied when a declaration site does not give its own ceiling. There is + # deliberately no way to switch the ceiling off; a declaration site that + # legitimately needs more raises it explicitly. BinData rejects a nil + # parameter value in any case. + DEFAULT_MAX_LENGTH = 512 + + default_parameter max_length: DEFAULT_MAX_LENGTH + + # BinData::Array selects its read strategy in #initialize_shared_instance + # and installs it with #extend, which places it ahead of this class in the + # singleton ancestry. Overriding #do_read as an ordinary instance method is + # therefore not merely wrong but silently wrong: BinData::Array's + # #sanitize_parameters! defaults :initial_length to 0 whenever neither it + # nor :read_until was given, so InitialLengthPlugin is always installed, + # and every read would return an empty array with nothing raised. + # Extending after super is what puts the count prefix ahead of it. + def initialize_shared_instance + super + extend CountPrefixPlugin + end + + def initialize_instance + super + @null = false + end + + # @return [Boolean] whether this array was read as, or assigned, null. + def null? + @null + end + + # BinData::Array#assign rejects nil, so null has to be taken here and + # stored as an empty element list flagged as null. + def assign(array) + @null = array.nil? + super(@null ? [] : array) + end + + # The count prefix. This has to be a module extended onto the instance + # rather than methods on the class; see #initialize_shared_instance. + module CountPrefixPlugin + def do_read(io) + count = io.readbytes(4).unpack1('l<') + @element_list = [] + + if count.negative? + @null = true + return + end + + @null = false + max_length = eval_parameter(:max_length) + if count > max_length + raise BinData::ValidityError, + "array length #{count} exceeds the #{max_length} element ceiling for #{debug_name}" + end + + count.times { append_new_element.do_read(io) } + end + + def do_write(io) + io.writebytes([@null ? NULL_LENGTH : length].pack('l<')) + super unless @null + end + + def do_num_bytes + @null ? 4 : 4 + super + end + end + end +end diff --git a/spec/file_fixtures/opc_ua/README.md b/spec/file_fixtures/opc_ua/README.md new file mode 100644 index 0000000000000..c35dd7f003d2c --- /dev/null +++ b/spec/file_fixtures/opc_ua/README.md @@ -0,0 +1,56 @@ +# OPC-UA wire captures + +Byte-for-byte captures of OPC-UA TCP (`opc.tcp://`) messages, used by the specs +under `spec/lib/rex/proto/opc_ua/`. + +Each file is a **complete message including the 8 byte message header** +(MessageType, ChunkType, MessageSize), exactly as it came off the wire. Nothing +has been trimmed, reordered or reassembled. Specs slice what they need; the +fixtures stay whole, so the same file can serve both the transport specs and the +record specs without any question about what was removed. + +## Provenance + +| | | +|---|---| +| Server | node-opcua 2.175.6 | +| Container | `ua-node` | +| Endpoint | `opc.tcp://ua-node:4840/UA/BackdraftTest` | +| Captured | 2026-08-27 | +| Security policy | None (`http://opcfoundation.org/UA/SecurityPolicy#None`) | +| Framing | Single `F` chunk per message | + +| File | Bytes | Contents | +|---|---|---| +| `ack_node_opcua.bin` | 28 | `ACK` response to a Hello | +| `open_secure_channel_response_node_opcua.bin` | 135 | `OPN` response, SecurityPolicy None | +| `get_endpoints_response_node_opcua.bin` | 10648 | `MSG` GetEndpointsResponse, 7 endpoints including a None/Anonymous endpoint | + +## Coverage limits + +These captures come from one server under one configuration, so several +encodings the parser must handle do not appear in them at all. Specs covering +the following need hand-built frames constructed from OPC-UA Specification +Part 6; that is deliberate, not an oversight: + +- **Chunked responses.** Every message here is a single `F` chunk. No capture + exercises `C`-continuation reassembly or an `A` abort, so the MessageStream + reassembly specs are hand-built in full. +- **NodeId encodings.** Every NodeId in these captures uses encoding byte `0x01` + (FourByte). The TwoByte, Numeric, String, GUID and ByteString branches, and + the `0x40` ServerIndex / `0x80` NamespaceUri flags, have no capture coverage. +- **LocalizedText masks.** Every LocalizedText here has mask `0x03` (both Locale + and Text present). The `0x00`, `0x01` and `0x02` branches have no capture + coverage. + +## Excluded captures + +Equivalent captures from an Inductive Automation Ignition server are +**deliberately excluded and will not be added**. Ignition embeds its server +certificate in the GetEndpoints response, and the certificate's +subjectAltName extension contains the public IP address of the lab host. That +cannot go into a public repository. + +The node-opcua captures were checked for the same class of leak before being +committed: they contain no IP addresses, and the only host identifier in them is +the internal container name `ua-node`. diff --git a/spec/file_fixtures/opc_ua/ack_node_opcua.bin b/spec/file_fixtures/opc_ua/ack_node_opcua.bin new file mode 100644 index 0000000000000000000000000000000000000000..fb253bee8621117e76f9d32984ffc8ca1dc8f950 GIT binary patch literal 28 ZcmZ>C_I8tDfPnx185lqq2pEBo0RUAm1uy^r literal 0 HcmV?d00001 diff --git a/spec/file_fixtures/opc_ua/get_endpoints_response_node_opcua.bin b/spec/file_fixtures/opc_ua/get_endpoints_response_node_opcua.bin new file mode 100644 index 0000000000000000000000000000000000000000..97e634f0345fe9581e4b27ed953862fae894a424 GIT binary patch literal 10648 zcmeI1c}x^n9LHz&bmekcg>~gvv`R$QeS3f$r3Jh&UdXyCUhA+sz#`7T%r5IHsdXFI zBQ3V#Q8lRq1JP&-g4GsE@wV2xv1+tROeL)s{iCRe==KdFSqW|+!XG3v$(y|2o8SC? z``dZ*`M$iFshc|G+p!2DKoA6ljnmOkqyqigcmDm_s5>Y#=6(pF5e^%fXB=-a=EW(M zR!l*VCOl3XqXkNRk}?@HE;mtFre!WpTY{jgl_KKaI?aNfvr{MPlN36fD!{2B@OsxV zE*}o!ghHTagfk^gH3axFhEO7$P37VkL94{Tj~N*(8Kkq|RHnHASIK~s8RB}2JAYnc zM-d<-&_^8&V${)UO;oH_y)e*6!5v(NK6(2?#Xp?rkvK>c9x=wOZmMUlN3`*+EH zC|KpZ;hQ`4ywBijU0_T2j&+B{_UV-;FDzQBxoKLkvFXpNM-{O%t-~TKie8LezQ<+#nuzqIO@2 zzhA<|-S@)BR92RFp$8q?N?i8LbH{9xu%FwuJ#Cw|{q?fyjZ{^>G{`&4HQ`zKy1VHg zgTlg)>`mkAC*3}aExYv9zUB}_JFY&18)wSB3t;sWXv_<~?Q;R61ytVQp*Rkq)*Q@8%SCH%ZS3JRc!ljV+4ISa1F7~x9U)#Zjr6^ zwD7=^h2<5E0f*Ms%vg1%b$>+56Dj!@xpn>Os~Mj){8JuJjuAfNOCH^6D9w7j{(1PW zExWJP+?igNe_T4^>FAKAS4}qs^G>;B6aBM`71|RI8t#b(6%`jXC)6F^@-kACa4YES zhKSZ1Z9humbF|g@WpNK@G>@=9zBGBxnW%Iim+1>GJLfa3Q^J;Qu`mFGD9a?RgbA~l zNn$)nWib@f;YKS3AAKsBYc|?P%_0e$!BQY>RvM=WYz1z}z-Y5k9Tlz1#^9NW>gqUR zdlxttzHsG;Jd(lesKM13M9t@k7_f47dGZ>&k5d=t^!e z^y)%x1Mtjl6}+YI*==Uo&9a+iH_PsU%HF4Z5G-rv{`5mWN0TsH1=-2 z4ZpX#t+$ykuJ2uNYkIKkb E2jF$}G5`Po literal 0 HcmV?d00001 diff --git a/spec/file_fixtures/opc_ua/open_secure_channel_response_node_opcua.bin b/spec/file_fixtures/opc_ua/open_secure_channel_response_node_opcua.bin new file mode 100644 index 0000000000000000000000000000000000000000..a734a7d6908beaec8e93692d01638bacea891367 GIT binary patch literal 135 zcmeYd@N;WtU|?VaVtpXaC@Cqh($~)~NKVTy%}Yrv$;{8w%P&gT4|UWJPE9T?$}Fi2 y$j`}4u2lBR&rAIe1&lzg5PXoa;d$7T-KKXLL40%ovIZnxZ~&s_fI24w$Or%s6)FAz literal 0 HcmV?d00001 diff --git a/spec/lib/rex/proto/opc_ua/enums_spec.rb b/spec/lib/rex/proto/opc_ua/enums_spec.rb new file mode 100644 index 0000000000000..24048a21ccc89 --- /dev/null +++ b/spec/lib/rex/proto/opc_ua/enums_spec.rb @@ -0,0 +1,143 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Rex::Proto::OpcUa::Enums do + # These values are transcribed from the OPC Foundation's machine-readable + # definitions. Pinning them here means a transcription slip fails a test + # rather than silently mislabelling a scan result. + describe 'STATUS_CODES' do + { + 0x807D0000 => 'Bad_TcpServerTooBusy', + 0x807E0000 => 'Bad_TcpMessageTypeInvalid', + 0x807F0000 => 'Bad_TcpSecureChannelUnknown', + 0x80800000 => 'Bad_TcpMessageTooLarge', + 0x80810000 => 'Bad_TcpNotEnoughResources', + 0x80820000 => 'Bad_TcpInternalError', + 0x80830000 => 'Bad_TcpEndpointUrlInvalid', + 0x80BE0000 => 'Bad_ProtocolVersionUnsupported', + 0x80130000 => 'Bad_SecurityChecksFailed', + 0x80120000 => 'Bad_CertificateInvalid', + 0x80840000 => 'Bad_RequestInterrupted', + 0x80850000 => 'Bad_RequestTimeout', + 0x80860000 => 'Bad_SecureChannelClosed', + 0x80870000 => 'Bad_SecureChannelTokenUnknown', + 0x80AC0000 => 'Bad_ConnectionRejected', + 0x80AE0000 => 'Bad_ConnectionClosed' + }.each do |code, name| + it "maps #{format('0x%08X', code)} to #{name}" do + expect(described_class::STATUS_CODES[code]).to eq name + end + end + + it 'carries no duplicate names' do + names = described_class::STATUS_CODES.values + expect(names.uniq.length).to eq names.length + end + + # Every StatusCode here has the severity bits set to Bad and no + # subcode or info bits, per Part 4 section 7.34. + it 'holds only Bad severity codes with an empty low half' do + described_class::STATUS_CODES.each_key do |code| + expect(code & 0xC0000000).to eq 0x80000000 + expect(code & 0x0000FFFF).to eq 0 + end + end + end + + describe '.status_code_name' do + it 'names a known code' do + expect(described_class.status_code_name(0x807D0000)).to eq 'Bad_TcpServerTooBusy' + end + + it 'falls back to hexadecimal for an unknown code' do + expect(described_class.status_code_name(0x80AB0000)).to eq '0x80AB0000' + end + + it 'pads the fallback to eight digits' do + expect(described_class.status_code_name(0)).to eq '0x00000000' + end + end + + describe 'NodeIds' do + # A request and its response differ by three, the intervening identifier + # being the XML encoding. Both response values were also read off the wire + # from the captures in spec/file_fixtures/opc_ua. + { + 'OPEN_SECURE_CHANNEL' => [446, 449], + 'CLOSE_SECURE_CHANNEL' => [452, 455], + 'GET_ENDPOINTS' => [428, 431] + }.each do |service, (request, response)| + it "identifies #{service} as #{request} and #{response}" do + expect(described_class::NodeIds.const_get("#{service}_REQUEST")).to eq request + expect(described_class::NodeIds.const_get("#{service}_RESPONSE")).to eq response + end + end + + it 'matches the OpenSecureChannelResponse TypeId in the capture' do + opn = File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'open_secure_channel_response_node_opcua.bin')) + # FourByte NodeId at offset 79: encoding, namespace, then a UInt16. + expect(opn.byteslice(79, 1).unpack1('C')).to eq 0x01 + expect(opn.byteslice(81, 2).unpack1('v')).to eq described_class::NodeIds::OPEN_SECURE_CHANNEL_RESPONSE + end + + it 'matches the GetEndpointsResponse TypeId in the capture' do + ge = File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'get_endpoints_response_node_opcua.bin')) + expect(ge.byteslice(24, 1).unpack1('C')).to eq 0x01 + expect(ge.byteslice(26, 2).unpack1('v')).to eq described_class::NodeIds::GET_ENDPOINTS_RESPONSE + end + end + + describe '.security_mode_name' do + it { expect(described_class.security_mode_name(0)).to eq 'Invalid' } + it { expect(described_class.security_mode_name(1)).to eq 'None' } + it { expect(described_class.security_mode_name(2)).to eq 'Sign' } + it { expect(described_class.security_mode_name(3)).to eq 'SignAndEncrypt' } + + it 'reports an out of range mode with its value' do + expect(described_class.security_mode_name(9)).to eq 'Unknown(9)' + end + end + + describe '.user_token_type_name' do + it { expect(described_class.user_token_type_name(0)).to eq 'Anonymous' } + it { expect(described_class.user_token_type_name(1)).to eq 'UserName' } + it { expect(described_class.user_token_type_name(2)).to eq 'Certificate' } + it { expect(described_class.user_token_type_name(3)).to eq 'IssuedToken' } + + it 'reports an out of range type with its value' do + expect(described_class.user_token_type_name(9)).to eq 'Unknown(9)' + end + end + + describe '.security_policy_name' do + it 'reduces a policy URI to its fragment' do + expect(described_class.security_policy_name(described_class::NONE_POLICY_URI)).to eq 'None' + end + + it 'reduces the other policies advertised in the capture' do + expect( + described_class.security_policy_name('http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256') + ).to eq 'Basic256Sha256' + end + + it 'returns a URI with no fragment whole' do + expect(described_class.security_policy_name('urn:vendor:policy')).to eq 'urn:vendor:policy' + end + + it 'reports nil as unknown' do + expect(described_class.security_policy_name(nil)).to eq 'Unknown' + end + + it 'reports an empty URI as unknown' do + expect(described_class.security_policy_name('')).to eq 'Unknown' + end + end + + describe 'NONE_POLICY_URI' do + it 'is the URI the captured server advertises for its unsecured endpoint' do + ge = File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'get_endpoints_response_node_opcua.bin')) + expect(ge).to include(described_class::NONE_POLICY_URI) + end + end +end diff --git a/spec/lib/rex/proto/opc_ua/opc_ua_array_spec.rb b/spec/lib/rex/proto/opc_ua/opc_ua_array_spec.rb new file mode 100644 index 0000000000000..16898a2852cd5 --- /dev/null +++ b/spec/lib/rex/proto/opc_ua/opc_ua_array_spec.rb @@ -0,0 +1,217 @@ +# -*- coding: binary -*- + +require 'spec_helper' +# BinData resolves field types when a record's class body is evaluated, so the +# library types have to be registered before SpecUserTokenPolicy below is +# defined. Under Zeitwerk they would otherwise not load until first referenced. +require 'rex/proto/opc_ua/types' + +# A minimal stand-in for UserTokenPolicy (Part 4, section 7.37), defined here so +# that the array can be exercised against real elements from a capture before +# services.rb exists. Named so it cannot collide with the library type that will +# eventually replace it: BinData registers types by their unqualified class +# name, so a bare UserTokenPolicy here would claim the name globally. +class SpecUserTokenPolicy < BinData::Record + endian :little + + opc_ua_string :policy_id + uint32 :token_type + opc_ua_string :issued_token_type + opc_ua_string :issuer_endpoint_url + opc_ua_string :security_policy_uri +end + +RSpec.describe Rex::Proto::OpcUa::Types::OpcUaArray do + let(:null_binary) { [-1].pack('l<') } + let(:empty_binary) { [0].pack('l<') } + + # Three uint16 elements, the simplest thing that shows elements were read + # rather than merely counted. + let(:values) { [10, 20, 30] } + let(:populated_binary) { [values.length].pack('l<') + values.pack('v*') } + + subject(:array) { described_class.new(type: :uint16le) } + + describe '.read' do + it 'decodes a populated array' do + expect(described_class.read(populated_binary, type: :uint16le).snapshot).to eq values + end + + # BinData::Array installs its read strategy with #extend, so a count prefix + # implemented as a plain method override is shadowed and yields an empty + # array with no error raised. This is the guard against that regression. + it 'decodes a populated array to the declared element count, not to empty' do + expect(described_class.read(populated_binary, type: :uint16le).length).to eq values.length + end + + it 'decodes a null array as empty' do + expect(described_class.read(null_binary, type: :uint16le).length).to eq 0 + end + + it 'reports a null array as null' do + expect(described_class.read(null_binary, type: :uint16le)).to be_null + end + + it 'decodes an empty array as empty' do + expect(described_class.read(empty_binary, type: :uint16le).length).to eq 0 + end + + it 'does not report an empty array as null' do + expect(described_class.read(empty_binary, type: :uint16le)).not_to be_null + end + + it 'consumes only the count prefix when the array is null' do + expect(described_class.read(null_binary, type: :uint16le).num_bytes).to eq 4 + end + + it 'treats a negative count other than -1 as null' do + expect(described_class.read([-5].pack('l<'), type: :uint16le)).to be_null + end + + it 'raises when the buffer holds fewer elements than the count claims' do + expect { described_class.read([4].pack('l<') + [1, 2].pack('v*'), type: :uint16le) }.to raise_error(IOError) + end + end + + describe 'max_length' do + it 'rejects a count above the ceiling' do + expect { described_class.read(populated_binary, type: :uint16le, max_length: 2) } + .to raise_error(BinData::ValidityError, /exceeds the 2 element ceiling/) + end + + it 'accepts a count equal to the ceiling' do + expect(described_class.read(populated_binary, type: :uint16le, max_length: 3).length).to eq 3 + end + + it 'rejects before allocating the claimed elements' do + absurd = [2**31 - 1].pack('l<') + expect { described_class.read(absurd, type: :uint16le) }.to raise_error(BinData::ValidityError) + end + + it 'applies a default ceiling when the declaration site gives none' do + over_default = [described_class::DEFAULT_MAX_LENGTH + 1].pack('l<') + expect { described_class.read(over_default, type: :uint16le) }.to raise_error(BinData::ValidityError) + end + + it 'reads beyond the default ceiling when the declaration site raises it' do + long = [600].pack('l<') + ([1] * 600).pack('v*') + expect(described_class.read(long, type: :uint16le, max_length: 600).length).to eq 600 + end + + # There is no way to switch the ceiling off, which is the point of it. + it 'refuses a nil ceiling' do + expect { described_class.read(populated_binary, type: :uint16le, max_length: nil) } + .to raise_error(ArgumentError, /has nil value/) + end + end + + describe '#to_binary_s' do + it 'encodes a populated array' do + array.assign(values) + expect(array.to_binary_s).to eq populated_binary + end + + it 'encodes an empty array as a zero count' do + array.assign([]) + expect(array.to_binary_s).to eq empty_binary + end + + it 'encodes nil as a null count' do + array.assign(nil) + expect(array.to_binary_s).to eq null_binary + end + end + + describe 'round trip' do + it 'preserves a populated array' do + expect(described_class.read(populated_binary, type: :uint16le).to_binary_s).to eq populated_binary + end + + it 'preserves empty' do + expect(described_class.read(empty_binary, type: :uint16le).to_binary_s).to eq empty_binary + end + + # Null and empty both present as an empty array, so this is what keeps the + # two from collapsing into one another across a decode and re-encode. + it 'preserves null rather than degrading it to empty' do + expect(described_class.read(null_binary, type: :uint16le).to_binary_s).to eq null_binary + end + end + + # Offsets into the capture were established by walking the response field by + # field; the walk consumes the message exactly, 10648 of 10648 bytes. See + # spec/file_fixtures/opc_ua/README.md for provenance. + describe 'against a captured GetEndpointsResponse' do + let(:response) do + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'get_endpoints_response_node_opcua.bin')) + end + + # The EndpointDescription array begins after the message header, the secure + # conversation prefix, the TypeId and the ResponseHeader. + let(:endpoint_array_offset) { 52 } + # UserTokenPolicy arrays of the first two endpoints. + let(:five_token_offset) { 1327 } + let(:three_token_offset) { 3141 } + + # EndpointDescription does not exist yet, so the element type here is a + # single byte and only the count is under test. The arrays read with real + # elements below. + it 'decodes the endpoint count' do + expect(described_class.read(response[endpoint_array_offset..], type: :uint8).length).to eq 7 + end + + it 'rejects the endpoint count against a lower ceiling' do + expect { described_class.read(response[endpoint_array_offset..], type: :uint8, max_length: 5) } + .to raise_error(BinData::ValidityError, /array length 7 exceeds/) + end + + context 'the first endpoint, which advertises five token policies' do + subject(:policies) do + described_class.read(response[five_token_offset..], type: :spec_user_token_policy) + end + + it 'decodes five elements' do + expect(policies.length).to eq 5 + end + + it 'decodes each element rather than only counting them' do + expect(policies.map { |p| p.policy_id.snapshot }).to eq %w[ + username_basic256Sha256 + username_aes128Sha256RsaOaep + certificate_basic256Sha256 + certificate_aes128Sha256RsaOaep + anonymous + ] + end + + it 'decodes the token types' do + expect(policies.map { |p| p.token_type.snapshot }).to eq [1, 1, 2, 2, 0] + end + + # Every policy here leaves IssuedTokenType and IssuerEndpointUrl null, so + # this covers a null ByteString nested inside an array element. + it 'decodes the null fields within its elements' do + expect(policies.map { |p| p.issued_token_type.snapshot }).to all(be_nil) + expect(policies.map { |p| p.issuer_endpoint_url.snapshot }).to all(be_nil) + end + + it 'is not null' do + expect(policies).not_to be_null + end + end + + context 'the second endpoint, which advertises three token policies' do + subject(:policies) do + described_class.read(response[three_token_offset..], type: :spec_user_token_policy) + end + + it 'decodes three elements' do + expect(policies.length).to eq 3 + end + + it 'decodes each element' do + expect(policies.map { |p| p.policy_id.snapshot }).to eq %w[usernamePassword certificateX509 anonymous_0] + end + end + end +end diff --git a/spec/lib/rex/proto/opc_ua/types_spec.rb b/spec/lib/rex/proto/opc_ua/types_spec.rb new file mode 100644 index 0000000000000..34028749fdbdd --- /dev/null +++ b/spec/lib/rex/proto/opc_ua/types_spec.rb @@ -0,0 +1,213 @@ +# -*- coding: binary -*- + +require 'spec_helper' +require 'rex/text' + +RSpec.describe 'Rex::Proto::OpcUa length prefixed types' do + # A null value is a length prefix of -1 with no bytes following it. + let(:null_binary) { [-1].pack('l<') } + # An empty value is a length prefix of 0, which the specification treats as + # distinct from null. + let(:empty_binary) { [0].pack('l<') } + + # Both types share the Part 6 section 5.2.2 length prefix semantics and + # differ only in how they present the bytes that follow it. + shared_examples 'a length prefixed OPC-UA type' do + describe '.read' do + it 'decodes a null value as nil' do + expect(described_class.read(null_binary).snapshot).to be_nil + end + + it 'decodes an empty value as an empty string' do + expect(described_class.read(empty_binary).snapshot).to eq '' + end + + it 'decodes a populated value' do + expect(described_class.read([value.bytesize].pack('l<') + value).snapshot).to eq value + end + + it 'distinguishes null from empty' do + expect(described_class.read(null_binary).snapshot).to be_nil + expect(described_class.read(empty_binary).snapshot).not_to be_nil + end + + it 'consumes only the prefix when the value is null' do + expect(described_class.read(null_binary).num_bytes).to eq 4 + end + + it 'treats a negative length other than -1 as null' do + expect(described_class.read([-5].pack('l<')).snapshot).to be_nil + end + + it 'raises when the buffer is shorter than the declared length' do + expect { described_class.read([16].pack('l<') + 'short') }.to raise_error(IOError) + end + + it 'raises when the buffer is shorter than the prefix' do + expect { described_class.read("\x00\x00") }.to raise_error(IOError) + end + end + + describe '#to_binary_s' do + it 'encodes nil as a null length prefix' do + expect(described_class.new(nil).to_binary_s).to eq null_binary + end + + it 'encodes a default constructed value as null' do + expect(described_class.new.to_binary_s).to eq null_binary + end + + it 'encodes an empty string as a zero length prefix' do + expect(described_class.new('').to_binary_s).to eq empty_binary + end + + it 'encodes a populated value' do + expect(described_class.new(value).to_binary_s).to eq [value.bytesize].pack('l<') + value + end + end + + describe '#assign' do + # BasePrimitive#assign rejects nil, so the override is what makes a field + # assignment of null work rather than raise. + it 'accepts nil and encodes it as null' do + subject = described_class.new(value) + subject.assign(nil) + expect(subject.snapshot).to be_nil + expect(subject.to_binary_s).to eq null_binary + end + end + + describe 'round trip' do + it 'preserves null through decode and re-encode' do + expect(described_class.read(null_binary).to_binary_s).to eq null_binary + end + + it 'preserves empty through decode and re-encode' do + expect(described_class.read(empty_binary).to_binary_s).to eq empty_binary + end + + it 'preserves a populated value through decode and re-encode' do + binary = [value.bytesize].pack('l<') + value + expect(described_class.read(binary).to_binary_s).to eq binary + end + + it 'preserves null through encode and re-decode' do + expect(described_class.read(described_class.new(nil).to_binary_s).snapshot).to be_nil + end + + it 'preserves empty through encode and re-decode' do + expect(described_class.read(described_class.new('').to_binary_s).snapshot).to eq '' + end + + it 'preserves a populated value through encode and re-decode' do + expect(described_class.read(described_class.new(value).to_binary_s).snapshot).to eq value + end + end + end + + describe Rex::Proto::OpcUa::Types::OpcUaByteString do + let(:value) { Rex::Text.rand_text_alphanumeric(10).b } + + it_behaves_like 'a length prefixed OPC-UA type' + + it 'decodes bytes that are not valid UTF-8 without altering them' do + raw = "\x00\xFF\xFE\x80".b + expect(described_class.read([raw.bytesize].pack('l<') + raw).snapshot).to eq raw + end + + it 'decodes to a binary string' do + expect(described_class.read([value.bytesize].pack('l<') + value).snapshot.encoding).to eq ::Encoding::ASCII_8BIT + end + + it 'encodes the byte length of a multi-byte value, not its character length' do + raw = "caf\xC3\xA9".b + expect(described_class.new(raw).to_binary_s).to eq [5].pack('l<') + raw + end + end + + describe Rex::Proto::OpcUa::Types::OpcUaString do + let(:value) { Rex::Text.rand_text_alphanumeric(10) } + + # This source file is binary encoded, so a bare literal containing an + # e-acute would be ASCII-8BIT and would never compare equal to the UTF-8 + # string the type produces. The \u escape forces the literal to UTF-8 + # regardless of the file encoding, which is what makes this a real test of + # the decoded encoding rather than of the byte sequence alone. + let(:multi_byte_text) { "caf\u00E9" } + let(:multi_byte_bytes) { "caf\xC3\xA9".b } + + it_behaves_like 'a length prefixed OPC-UA type' + + it 'decodes to UTF-8' do + expect(described_class.read([value.bytesize].pack('l<') + value).snapshot.encoding).to eq ::Encoding::UTF_8 + end + + # Transcoding from BINARY to UTF-8 would treat every byte at or above 0x80 + # as undefined and replace it, so a valid multi-byte value surviving intact + # is what proves the type scrubs rather than transcodes. + it 'preserves valid multi-byte UTF-8' do + binary = [multi_byte_bytes.bytesize].pack('l<') + multi_byte_bytes + expect(described_class.read(binary).snapshot).to eq multi_byte_text + end + + it 'replaces invalid UTF-8 sequences' do + raw = "ab\xFF\xFEcd".b + expect(described_class.read([raw.bytesize].pack('l<') + raw).snapshot).to eq 'ab??cd' + end + + it 'decodes invalid UTF-8 to a string that is itself valid UTF-8' do + raw = "ab\xFF\xFEcd".b + expect(described_class.read([raw.bytesize].pack('l<') + raw).snapshot).to be_valid_encoding + end + + it 'encodes the byte length of a multi-byte value, not its character length' do + expect(multi_byte_text.length).to eq 4 + expect(described_class.new(multi_byte_text).to_binary_s).to eq [5].pack('l<') + multi_byte_bytes + end + + it 'round trips valid multi-byte UTF-8' do + binary = [multi_byte_bytes.bytesize].pack('l<') + multi_byte_bytes + expect(described_class.read(binary).to_binary_s).to eq binary + end + end + + # The synthetic cases above assert the intended behaviour; these assert it + # against bytes a real server put on the wire. + # + # The AsymmetricAlgorithmSecurityHeader of an OpenSecureChannelResponse opens + # with a populated SecurityPolicyUri String followed by two ByteStrings that + # are null under SecurityPolicy None, and the response ends with a null + # ServerNonce. That gives real examples of both the populated and the null + # form, and of one following the other, which is what the record layer will + # depend on. See spec/file_fixtures/opc_ua/README.md for provenance. + describe 'a captured OpenSecureChannelResponse' do + let(:response) do + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'open_secure_channel_response_node_opcua.bin')) + end + + # The security header follows the 8 byte message header and the UInt32 + # SecureChannelId. + let(:security_header) { response[12..] } + + let(:security_policy_uri) { Rex::Proto::OpcUa::Types::OpcUaString.read(security_header) } + + it 'decodes the populated SecurityPolicyUri' do + expect(security_policy_uri.snapshot).to eq 'http://opcfoundation.org/UA/SecurityPolicy#None' + end + + # Advancing by num_bytes is what proves the length prefix was accounted for + # exactly, rather than the next field merely happening to decode. + it 'decodes the SenderCertificate that follows it as null' do + sender_certificate = Rex::Proto::OpcUa::Types::OpcUaByteString.read(security_header[security_policy_uri.num_bytes..]) + expect(sender_certificate.snapshot).to be_nil + end + + it 'decodes the trailing ServerNonce as null' do + expect(Rex::Proto::OpcUa::Types::OpcUaByteString.read(response[-4..]).snapshot).to be_nil + end + + it 're-encodes the trailing ServerNonce to the captured bytes' do + expect(Rex::Proto::OpcUa::Types::OpcUaByteString.read(response[-4..]).to_binary_s).to eq response[-4..] + end + end +end From 61c4cf32f1cb52ad91099387b3fd62eaa8a1f972 Mon Sep 17 00:00:00 2001 From: ethan-thomason Date: Fri, 28 Aug 2026 08:17:23 -0700 Subject: [PATCH 3/8] Add OPC-UA error classes, TCP records and MessageStream --- lib/rex/proto/opc_ua/error.rb | 68 +++++ lib/rex/proto/opc_ua/tcp.rb | 255 +++++++++++++++++ spec/lib/rex/proto/opc_ua/tcp_spec.rb | 391 ++++++++++++++++++++++++++ 3 files changed, 714 insertions(+) create mode 100644 lib/rex/proto/opc_ua/error.rb create mode 100644 lib/rex/proto/opc_ua/tcp.rb create mode 100644 spec/lib/rex/proto/opc_ua/tcp_spec.rb diff --git a/lib/rex/proto/opc_ua/error.rb b/lib/rex/proto/opc_ua/error.rb new file mode 100644 index 0000000000000..e0635c92ba5ef --- /dev/null +++ b/lib/rex/proto/opc_ua/error.rb @@ -0,0 +1,68 @@ +# -*- coding: binary -*- + +# Errors raised by the OPC-UA library. +# +# Everything here descends from OpcUaError, so a caller that only needs to know +# that the conversation failed can rescue the family in one clause, while a +# caller that wants to report why can rescue the individual classes. This +# follows Rex::Proto::Thrift::Error and Rex::Proto::Amqp::Error, which solve the +# same problem for their transports. +# +# The distinction the classes draw is between a fault in our reading of the +# connection and a fault the server reported, because a scanner reports those +# very differently: a TimeoutError against a host that never answers is not +# worth printing, whereas a ServerError is a positive result. +module Rex::Proto::OpcUa::Error + # Base class of OPC-UA specific errors. + class OpcUaError < Rex::RuntimeError; end + + # Raised when a read does not complete before its deadline, either because + # nothing arrived or because only part of a message did. Rex sockets report + # a closed connection by raising EOFError rather than by timing out, and that + # is left to propagate as itself. + class TimeoutError < OpcUaError; end + + # Raised when the UA TCP framing is unusable: a message size outside the + # permitted range, a chunk too short to hold its own headers, a message or + # chunk type that has no meaning here, or a response that ran past the chunk + # ceiling. + class FramingError < OpcUaError; end + + # Raised when the server abandons a response part way through by sending a + # chunk of type A. The response cannot be completed, but the connection + # itself is intact and the server is behaving to specification. + class AbortError < OpcUaError; end + + # Raised when the server answers with an ERR message. This is a report from + # the server rather than a fault in reading it, and the StatusCode it carries + # is the useful part, so it is kept as a field rather than only interpolated + # into the message. + class ServerError < OpcUaError + # @return [Integer, nil] the StatusCode from the ERR message, or nil when + # the body could not be decoded. + attr_reader :status_code + + # @return [String, nil] the Reason from the ERR message. Servers routinely + # leave this null. + attr_reader :reason + + # @param status_code [Integer, nil] the StatusCode from the ERR message. + # @param reason [String, nil] the Reason from the ERR message. + # @param msg [String, nil] overrides the generated message. + def initialize(status_code: nil, reason: nil, msg: nil) + @status_code = status_code + @reason = reason.to_s.empty? ? nil : reason.to_s + + super(msg || generate_message) + end + + private + + # @return [String] the StatusCode by name where it is one the enumeration + # carries, with the Reason appended when the server supplied one. + def generate_message + name = status_code.nil? ? 'an undecodable status' : Rex::Proto::OpcUa::Enums.status_code_name(status_code) + reason.nil? ? "server returned ERR: #{name}" : "server returned ERR: #{name} - #{reason}" + end + end +end diff --git a/lib/rex/proto/opc_ua/tcp.rb b/lib/rex/proto/opc_ua/tcp.rb new file mode 100644 index 0000000000000..2c9e5d0405f4e --- /dev/null +++ b/lib/rex/proto/opc_ua/tcp.rb @@ -0,0 +1,255 @@ +# -*- coding: binary -*- + +require 'bindata' +# BinData resolves field types when a record's class body is evaluated, so the +# library types have to be registered before the records below are defined. +# Under Zeitwerk they would otherwise not load until first referenced. +require 'rex/proto/opc_ua/types' + +# The OPC-UA TCP transport that carries opc.tcp:// (OPC-UA Specification Part 6, +# section 7). This is the framing layer only: it says what a message looks like +# and how a service response is put back together from chunks, and knows nothing +# about the services carried inside one. +module Rex::Proto::OpcUa::Tcp + # Shorthand for the sibling error namespace. The compact module definition + # above puts only this module in lexical scope, so without it every raise site + # would have to name Rex::Proto::OpcUa::Error in full. + Error = Rex::Proto::OpcUa::Error + + # Every message begins with an 8 byte header: MessageType (3 bytes ASCII), + # ChunkType (1 byte ASCII) and MessageSize (UInt32 LE). MessageSize is the + # total length of the message including the header itself. + HEADER_LEN = 8 + + # Each MSG chunk repeats SecureChannelId, TokenId, SequenceNumber and + # RequestId ahead of its slice of the service payload. + SECURE_MSG_PREFIX_LEN = 16 + + # Defensive ceilings. A malformed or hostile response must fail quickly rather + # than allocate without bound. Both values are carried over unchanged from the + # module this library was factored out of. + MAX_MESSAGE_SIZE = 4 * 1024 * 1024 + MAX_CHUNKS = 64 + + # MessageType values, each three ASCII bytes. These are the types this + # transport exchanges; every one of them appears in the captures under + # spec/file_fixtures/opc_ua or is sent to produce them. + module MessageType + HELLO = 'HEL'.freeze + ACKNOWLEDGE = 'ACK'.freeze + ERROR = 'ERR'.freeze + OPEN_SECURE_CHANNEL = 'OPN'.freeze + CLOSE_SECURE_CHANNEL = 'CLO'.freeze + MESSAGE = 'MSG'.freeze + end + + # ChunkType values, one ASCII byte. A message that fits in one chunk is sent + # as a single F. + module ChunkType + # More chunks follow this one. + INTERMEDIATE = 'C'.freeze + # The last chunk of the message. + FINAL = 'F'.freeze + # The server has abandoned the message; nothing further will follow. + ABORT = 'A'.freeze + end + + # The 8 byte header every message opens with. + class MessageHeader < BinData::Record + endian :little + + string :message_type, length: 3 + string :chunk_type, length: 1 + uint32 :message_size + end + + # The Hello a client opens the connection with (Part 6, section 7.1.2). The + # buffer sizes are what the client is willing to receive; a zero + # MaxMessageSize or MaxChunkCount means the client sets no limit of its own, + # which is not the same as accepting anything, since MessageStream applies its + # own ceilings regardless. + class HelloMessage < BinData::Record + endian :little + + uint32 :protocol_version + uint32 :receive_buffer_size + uint32 :send_buffer_size + uint32 :max_message_size + uint32 :max_chunk_count + opc_ua_string :endpoint_url + end + + # The server's answer to a Hello (Part 6, section 7.1.2), carrying the same + # five fields from the server's side. The buffer sizes it returns are the ones + # that then govern the connection. + class AcknowledgeMessage < BinData::Record + endian :little + + uint32 :protocol_version + uint32 :receive_buffer_size + uint32 :send_buffer_size + uint32 :max_message_size + uint32 :max_chunk_count + end + + # The body of an ERR message (Part 6, section 7.1.2): a StatusCode and a + # Reason string, which servers routinely leave null. + class ErrorMessage < BinData::Record + endian :little + + uint32 :status_code + opc_ua_string :reason + end + + # One framed message as it came off the wire. The body excludes the header. + Message = Struct.new(:message_type, :chunk_type, :body) do + def error? + message_type == MessageType::ERROR + end + + def abort? + chunk_type == ChunkType::ABORT + end + + def final? + chunk_type == ChunkType::FINAL + end + + def intermediate? + chunk_type == ChunkType::INTERMEDIATE + end + end + + # Frames and reassembles OPC-UA TCP messages over a socket. + # + # The only thing required of the socket is get_once(length, timeout), which is + # what makes this testable without a network: Msf::Exploit::Remote::Tcp#sock + # satisfies it and so does a test double. Writing is deliberately not part of + # this class, since building a request is the business of the layer above. + class MessageStream + # Seconds allowed per read when the caller gives no timeout of its own. + DEFAULT_TIMEOUT = 5 + + # @return [Integer, Float] seconds allowed for satisfying one read. The + # header and the body of a message are each read under a fresh deadline. + attr_reader :timeout + + # @param sock [#get_once] the socket to read from. + # @param timeout [Integer, Float] seconds allowed per read. + def initialize(sock, timeout: DEFAULT_TIMEOUT) + @sock = sock + @timeout = timeout + end + + # Read exactly len bytes, accumulating across reads. A single read is not + # guaranteed to return the full amount, and a GetEndpoints response carrying + # server certificates routinely spans several segments. + # + # The deadline is monotonic rather than wall clock, so that a clock step + # part way through a read cannot either cut it short or extend it + # indefinitely. + # + # @param len [Integer] the number of bytes to read. + # @return [String] exactly len bytes. + # @raise [Error::TimeoutError] if the bytes did not arrive in time. + def read_exact(len) + return ''.b unless len.positive? + + buf = ''.b + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout + while buf.bytesize < len + left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + unless left.positive? + raise Error::TimeoutError, "read of #{len} bytes timed out after #{timeout}s with #{buf.bytesize} read" + end + + # A Rex socket returns nil from get_once when nothing arrived before the + # timeout, and raises EOFError when the peer closed the connection. Only + # the first of those is ours to translate; a close is left to propagate + # as itself. + chunk = @sock.get_once(len - buf.bytesize, left) + if chunk.nil? || chunk.empty? + raise Error::TimeoutError, "read of #{len} bytes returned no data with #{buf.bytesize} read" + end + + buf << chunk.b + end + + buf + end + + # Read one framed message. + # + # @return [Message] the message type, chunk type and body. + # @raise [Error::TimeoutError] if the message did not arrive in time. + # @raise [Error::FramingError] if the declared size is unusable. + def recv_message + header = MessageHeader.read(read_exact(HEADER_LEN)) + size = header.message_size.snapshot + if size < HEADER_LEN || size > MAX_MESSAGE_SIZE + raise Error::FramingError, "message size #{size} outside #{HEADER_LEN}..#{MAX_MESSAGE_SIZE}" + end + + Message.new( + header.message_type.snapshot, + header.chunk_type.snapshot, + read_exact(size - HEADER_LEN) + ) + end + + # Read a complete service response, reassembling it where the server split + # it across chunks. Continuation chunks repeat the SecureChannelId, TokenId + # and SequenceHeader ahead of their payload slice, so those bytes are + # stripped before concatenation. The returned buffer therefore starts at the + # response TypeId, not at the SecureChannelId. + # + # @return [String] the reassembled service payload. + # @raise [Error::ServerError] if the server answered with ERR. + # @raise [Error::AbortError] if the server abandoned the response. + # @raise [Error::FramingError] if the framing is unusable or the response + # ran past the chunk ceiling. + # @raise [Error::TimeoutError] if a chunk did not arrive in time. + def recv_service_response + payload = ''.b + + MAX_CHUNKS.times do + msg = recv_message + raise server_error(msg.body) if msg.error? + + unless msg.message_type == MessageType::MESSAGE + raise Error::FramingError, "unexpected message type #{msg.message_type.inspect}" + end + raise Error::AbortError, 'server aborted the response' if msg.abort? + raise Error::FramingError, "unknown chunk type #{msg.chunk_type.inspect}" unless msg.final? || msg.intermediate? + + if msg.body.bytesize < SECURE_MSG_PREFIX_LEN + raise Error::FramingError, + "chunk of #{msg.body.bytesize} bytes is shorter than its #{SECURE_MSG_PREFIX_LEN} byte header" + end + + payload << msg.body.byteslice(SECURE_MSG_PREFIX_LEN..-1) + return payload if msg.final? + end + + raise Error::FramingError, "response exceeded the #{MAX_CHUNKS} chunk ceiling" + end + + private + + # Build the exception for an ERR message. + # + # An ERR arrives only once the server has decided the connection is + # unusable, so a body that will not decode is still reported as the failure + # it is rather than being replaced by a complaint about the decode; the + # StatusCode is simply left unknown. + # + # @param body [String] the ERR message body. + # @return [Error::ServerError] + def server_error(body) + err = ErrorMessage.read(body) + Error::ServerError.new(status_code: err.status_code.snapshot, reason: err.reason.snapshot) + rescue ::IOError, ::BinData::Error + Error::ServerError.new + end + end +end diff --git a/spec/lib/rex/proto/opc_ua/tcp_spec.rb b/spec/lib/rex/proto/opc_ua/tcp_spec.rb new file mode 100644 index 0000000000000..288bcfd296604 --- /dev/null +++ b/spec/lib/rex/proto/opc_ua/tcp_spec.rb @@ -0,0 +1,391 @@ +# -*- coding: binary -*- + +require 'spec_helper' +# BinData resolves field types when a record's class body is evaluated, so the +# transport records have to be registered before the examples reference them. +# Under Zeitwerk they would otherwise not load until first referenced. +require 'rex/proto/opc_ua/tcp' + +# A stand-in for a Rex socket. MessageStream asks nothing of its socket but +# get_once(length, timeout), so that is all this provides. Defined at the top +# level because a class defined inside a describe block is defined on Object +# anyway; the OpcUaSpec prefix keeps it from colliding with anything. +class OpcUaSpecSocket + # @param data [String] the bytes to hand out. + # @param segment [Integer, nil] the most bytes to return from a single read, + # so that a response can be split the way a real network splits one. + def initialize(data, segment: nil) + @data = data.dup.b + @segment = segment + end + + # @return [String, nil] nil once the bytes run out, which is what a Rex socket + # returns when nothing arrived before its timeout. + def get_once(length, _timeout) + return nil if @data.empty? + + @data.slice!(0, @segment ? [length, @segment].min : length) + end +end + +# A socket that answers every read by consuming the whole timeout it was given +# and then returning a single byte. A caller that reads under a deadline makes +# no progress against it and must give up rather than read forever. +class OpcUaSpecStallingSocket + # @return [Integer] how many reads have been served. + attr_reader :reads + + def initialize + @reads = 0 + end + + def get_once(_length, timeout) + @reads += 1 + sleep(timeout) + "\x00".b + end +end + +RSpec.describe 'Rex::Proto::OpcUa TCP transport' do + # The single captured ACK, whole and including its 8 byte message header. See + # spec/file_fixtures/opc_ua/README.md for provenance. + let(:ack) { File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'ack_node_opcua.bin')) } + + # No capture contains a chunked response - every message under + # spec/file_fixtures/opc_ua is a single F chunk - so the multi-chunk frames + # below are hand-built from the framing given in OPC-UA Specification Part 6 + # rather than taken from the wire. That is deliberate and is recorded under + # Coverage limits in spec/file_fixtures/opc_ua/README.md. + def frame(message_type, chunk_type, body) + (message_type + chunk_type).b + [Rex::Proto::OpcUa::Tcp::HEADER_LEN + body.bytesize].pack('V') + body + end + + # A MSG chunk: the secure conversation prefix (SecureChannelId, TokenId, + # SequenceNumber, RequestId) followed by this chunk's slice of the payload. + # The prefix values are arbitrary; only their length matters to the framing. + def msg_chunk(chunk_type, payload, sequence: 1) + frame('MSG', chunk_type, [0x2A, 0x01, sequence, 0x07].pack('V4') + payload) + end + + # An ERR message: StatusCode then a Reason string, null when reason is nil. + def err_frame(status_code, reason = nil) + body = [status_code].pack('V') + body << (reason.nil? ? [-1].pack('l<') : [reason.bytesize].pack('l<') + reason.b) + frame('ERR', 'F', body) + end + + describe 'ceilings' do + # These are carried over unchanged from the module the library was factored + # out of. Pinning them means a change to either is a deliberate one. + it 'caps a single message at 4 MiB' do + expect(Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE).to eq 4 * 1024 * 1024 + end + + it 'caps a reassembled response at 64 chunks' do + expect(Rex::Proto::OpcUa::Tcp::MAX_CHUNKS).to eq 64 + end + end + + describe Rex::Proto::OpcUa::Tcp::MessageHeader do + subject(:header) { described_class.read(ack) } + + it 'decodes the MessageType of the captured ACK' do + expect(header.message_type.snapshot).to eq 'ACK' + end + + it 'decodes the ChunkType of the captured ACK' do + expect(header.chunk_type.snapshot).to eq 'F' + end + + # MessageSize counts the header itself, so it is the whole capture. + it 'decodes a MessageSize that covers the whole message' do + expect(header.message_size.snapshot).to eq ack.bytesize + end + + it 'consumes exactly the header length' do + expect(header.num_bytes).to eq Rex::Proto::OpcUa::Tcp::HEADER_LEN + end + + it 're-encodes to the captured bytes' do + expect(header.to_binary_s).to eq ack.byteslice(0, 8) + end + end + + describe Rex::Proto::OpcUa::Tcp::AcknowledgeMessage do + # The body follows the 8 byte message header. + subject(:acknowledge) { described_class.read(ack[8..]) } + + it 'decodes the ProtocolVersion' do + expect(acknowledge.protocol_version.snapshot).to eq 0 + end + + it 'decodes the ReceiveBufferSize' do + expect(acknowledge.receive_buffer_size.snapshot).to eq 65_535 + end + + it 'decodes the SendBufferSize' do + expect(acknowledge.send_buffer_size.snapshot).to eq 65_535 + end + + it 'decodes the MaxMessageSize' do + expect(acknowledge.max_message_size.snapshot).to eq 16_777_216 + end + + it 'decodes the MaxChunkCount' do + expect(acknowledge.max_chunk_count.snapshot).to eq 256 + end + + # Five UInt32 fields and nothing else. Checking that the record accounts for + # the capture exactly is what proves no field was skipped or double counted, + # since five wrong offsets can still each decode to some number. + it 'accounts for the whole captured body' do + expect(acknowledge.num_bytes).to eq ack.bytesize - Rex::Proto::OpcUa::Tcp::HEADER_LEN + end + + it 're-encodes to the captured bytes' do + expect(acknowledge.to_binary_s).to eq ack[8..] + end + end + + describe Rex::Proto::OpcUa::Tcp::HelloMessage do + # The captures are server responses, so there is no captured client Hello to + # test against; this covers the field order and the length prefixed + # EndpointUrl by round trip instead. + subject(:hello) do + described_class.new( + protocol_version: 0, + receive_buffer_size: 65_535, + send_buffer_size: 65_535, + max_message_size: 0, + max_chunk_count: 0, + endpoint_url: 'opc.tcp://192.0.2.1:4840' + ) + end + + it 'encodes the five UInt32 fields ahead of the EndpointUrl' do + expect(hello.to_binary_s.byteslice(0, 20)).to eq [0, 65_535, 65_535, 0, 0].pack('V5') + end + + it 'encodes the EndpointUrl with its length prefix' do + url = 'opc.tcp://192.0.2.1:4840' + expect(hello.to_binary_s.byteslice(20..)).to eq [url.bytesize].pack('l<') + url + end + + it 'round trips' do + expect(described_class.read(hello.to_binary_s).snapshot).to eq hello.snapshot + end + end + + describe Rex::Proto::OpcUa::Tcp::ErrorMessage do + it 'decodes the StatusCode' do + body = err_frame(0x807D0000, 'too busy')[8..] + expect(described_class.read(body).status_code.snapshot).to eq 0x807D0000 + end + + it 'decodes the Reason' do + body = err_frame(0x807D0000, 'too busy')[8..] + expect(described_class.read(body).reason.snapshot).to eq 'too busy' + end + + # Servers routinely send ERR with no Reason at all, which is a null String + # rather than an empty one. + it 'decodes a null Reason as nil' do + body = err_frame(0x80820000)[8..] + expect(described_class.read(body).reason.snapshot).to be_nil + end + end + + describe Rex::Proto::OpcUa::Tcp::MessageStream do + # Short enough that the examples covering the deadline do not stall the + # suite, long enough that the examples reading from memory never reach it. + let(:timeout) { 0.5 } + + def stream_over(data, segment: nil) + described_class.new(OpcUaSpecSocket.new(data, segment: segment), timeout: timeout) + end + + describe '#read_exact' do + it 'returns exactly the requested bytes and leaves the rest' do + stream = stream_over('abcdefgh') + expect(stream.read_exact(3)).to eq 'abc' + expect(stream.read_exact(5)).to eq 'defgh' + end + + # The reason read_exact exists at all: one read is not one message. + it 'accumulates across reads that return less than was asked for' do + expect(stream_over('abcdefgh', segment: 1).read_exact(8)).to eq 'abcdefgh' + end + + it 'returns binary encoded bytes' do + expect(stream_over('abc').read_exact(3).encoding).to eq ::Encoding::BINARY + end + + it 'reads nothing when asked for nothing' do + expect(stream_over('abc').read_exact(0)).to eq '' + end + + it 'raises when the peer sends nothing at all' do + expect { stream_over('').read_exact(4) } + .to raise_error(Rex::Proto::OpcUa::Error::TimeoutError, /returned no data with 0 read/) + end + + it 'raises when the peer stops part way through and reports how far it got' do + expect { stream_over('ab').read_exact(4) } + .to raise_error(Rex::Proto::OpcUa::Error::TimeoutError, /returned no data with 2 read/) + end + + # A peer that dribbles out bytes slowly enough would otherwise hold the + # read open indefinitely, since every individual read makes progress. + it 'gives up once the deadline has passed even while bytes are arriving' do + socket = OpcUaSpecStallingSocket.new + stream = described_class.new(socket, timeout: 0.05) + + expect { stream.read_exact(4) } + .to raise_error(Rex::Proto::OpcUa::Error::TimeoutError, /timed out after 0.05s with 1 read/) + expect(socket.reads).to eq 1 + end + end + + describe '#recv_message' do + subject(:message) { stream_over(ack).recv_message } + + it 'returns the MessageType of the captured ACK' do + expect(message.message_type).to eq 'ACK' + end + + it 'returns the ChunkType of the captured ACK' do + expect(message.chunk_type).to eq 'F' + end + + it 'returns the body with the header removed' do + expect(message.body).to eq ack[8..] + end + + it 'reassembles a message split across reads' do + expect(stream_over(ack, segment: 3).recv_message.body).to eq ack[8..] + end + + # A MessageSize below the header length would make the body length + # negative; there is no message this can describe. + it 'rejects a MessageSize smaller than the header' do + expect { stream_over("MSGF\x07\x00\x00\x00".b).recv_message } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /message size 7 outside/) + end + + # The ceiling matters most here: the size is believed only far enough to + # reject it, so nothing is allocated on the strength of it. + it 'rejects a MessageSize above the ceiling' do + oversize = 'MSGF'.b + [Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE + 1].pack('V') + + expect { stream_over(oversize).recv_message } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /message size 4194305 outside/) + end + end + + describe '#recv_service_response' do + it 'returns the payload of a single final chunk' do + expect(stream_over(msg_chunk('F', 'service-payload')).recv_service_response).to eq 'service-payload' + end + + it 'strips the secure conversation prefix from the payload it returns' do + expect(stream_over(msg_chunk('F', 'service-payload')).recv_service_response.bytesize).to eq 15 + end + + it 'reassembles a response split across continuation chunks' do + data = msg_chunk('C', 'one-', sequence: 1) + + msg_chunk('C', 'two-', sequence: 2) + + msg_chunk('F', 'three', sequence: 3) + + expect(stream_over(data).recv_service_response).to eq 'one-two-three' + end + + # Chunk reassembly and read accumulation are separate concerns and a + # server that chunks a response is exactly the one likely to trip both. + it 'reassembles chunks that are themselves split across reads' do + data = msg_chunk('C', 'one-') + msg_chunk('F', 'two') + + expect(stream_over(data, segment: 2).recv_service_response).to eq 'one-two' + end + + it 'stops at the final chunk and leaves anything after it unread' do + stream = stream_over(msg_chunk('F', 'first') + msg_chunk('F', 'second')) + + expect(stream.recv_service_response).to eq 'first' + expect(stream.recv_service_response).to eq 'second' + end + + it 'raises when the server aborts part way through' do + data = msg_chunk('C', 'one-') + msg_chunk('A', 'discard me') + + expect { stream_over(data).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::AbortError, /aborted/) + end + + it 'raises when the server answers with ERR part way through' do + data = msg_chunk('C', 'one-') + err_frame(0x80820000, 'internal error') + + expect { stream_over(data).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::ServerError, /Bad_TcpInternalError - internal error/) + end + + it 'carries the StatusCode from an ERR on the exception' do + expect { stream_over(err_frame(0x807D0000)).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::ServerError) { |e| + expect(e.status_code).to eq 0x807D0000 + expect(e.reason).to be_nil + } + end + + # An ERR is a report that the server has given up on the connection, so a + # body too short to decode is still reported as the failure it is. + it 'still raises a ServerError when the ERR body will not decode' do + expect { stream_over(frame('ERR', 'F', "\x01\x02".b)).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::ServerError) { |e| + expect(e.status_code).to be_nil + } + end + + # The prefix is stripped by length, so a chunk shorter than the prefix + # would otherwise silently contribute nothing or slice past its own end. + it 'raises on a chunk shorter than its own secure conversation prefix' do + short = frame('MSG', 'F', 'x' * (Rex::Proto::OpcUa::Tcp::SECURE_MSG_PREFIX_LEN - 1)) + + expect { stream_over(short).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /15 bytes is shorter than its 16 byte header/) + end + + it 'raises on a message that is not part of a service response' do + expect { stream_over(frame('OPN', 'F', 'x' * 20)).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /unexpected message type "OPN"/) + end + + # C, F and A are the only chunk types the specification defines. Treating + # anything else as a continuation would spend the whole chunk budget on a + # frame already known to be malformed. + it 'raises on a chunk type the specification does not define' do + expect { stream_over(msg_chunk('X', 'payload')).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /unknown chunk type "X"/) + end + + # A server that never sends a final chunk must not be able to hold the + # scanner open, whether by accident or deliberately. + it 'raises once the chunk ceiling is reached with no final chunk' do + data = msg_chunk('C', 'x') * Rex::Proto::OpcUa::Tcp::MAX_CHUNKS + + expect { stream_over(data).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /exceeded the 64 chunk ceiling/) + end + + it 'accepts a response that reaches the chunk ceiling exactly' do + data = msg_chunk('C', 'x') * (Rex::Proto::OpcUa::Tcp::MAX_CHUNKS - 1) + msg_chunk('F', 'x') + + expect(stream_over(data).recv_service_response).to eq 'x' * Rex::Proto::OpcUa::Tcp::MAX_CHUNKS + end + + it 'raises when a chunk never arrives' do + expect { stream_over(msg_chunk('C', 'one-')).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::TimeoutError) + end + end + end +end From 6b76b4766d38e40945c43c8a28469c4920b2eff1 Mon Sep 17 00:00:00 2001 From: ethan-thomason Date: Fri, 28 Aug 2026 08:37:11 -0700 Subject: [PATCH 4/8] Add OPC-UA built-in types and secure channel records --- lib/rex/proto/opc_ua/secure_channel.rb | 150 ++++++++ lib/rex/proto/opc_ua/types.rb | 206 +++++++++++ .../rex/proto/opc_ua/built_in_types_spec.rb | 248 ++++++++++++++ spec/lib/rex/proto/opc_ua/date_time_spec.rb | 96 ++++++ .../rex/proto/opc_ua/secure_channel_spec.rb | 319 ++++++++++++++++++ 5 files changed, 1019 insertions(+) create mode 100644 lib/rex/proto/opc_ua/secure_channel.rb create mode 100644 spec/lib/rex/proto/opc_ua/built_in_types_spec.rb create mode 100644 spec/lib/rex/proto/opc_ua/date_time_spec.rb create mode 100644 spec/lib/rex/proto/opc_ua/secure_channel_spec.rb diff --git a/lib/rex/proto/opc_ua/secure_channel.rb b/lib/rex/proto/opc_ua/secure_channel.rb new file mode 100644 index 0000000000000..0a3376835102c --- /dev/null +++ b/lib/rex/proto/opc_ua/secure_channel.rb @@ -0,0 +1,150 @@ +# -*- coding: binary -*- + +require 'bindata' +# BinData resolves field types when a record's class body is evaluated, so the +# library types have to be registered before the records below are defined. +# Under Zeitwerk they would otherwise not load until first referenced. +require 'rex/proto/opc_ua/types' + +# The SecureChannel layer: the headers that wrap every message on a channel, and +# the services that open and close one. +# +# A channel is opened with OpenSecureChannel and identified from then on by the +# SecureChannelId and TokenId it returns. Under SecurityPolicy None nothing here +# is signed or encrypted, so the headers are the whole of the security layer as +# far as this library is concerned. +# +# The service records hold the service structures alone. The TypeId NodeId that +# precedes one in a message is part of the message encoding rather than part of +# the service, and is read and written separately. +module Rex::Proto::OpcUa::SecureChannel + # The security header of an OPN message. Under SecurityPolicy None both + # certificate fields are null, which is what makes an OPN exchange readable on + # the wire and lets a client open a channel with no key material of its own. + # See OPC-UA Specification Part 6. + class AsymmetricSecurityHeader < BinData::Record + endian :little + + opc_ua_string :security_policy_uri + opc_ua_byte_string :sender_certificate + opc_ua_byte_string :receiver_certificate_thumbprint + end + + # The security header of every message sent on an open channel, naming the + # token the message is secured with. This is the whole of it: a single UInt32. + # + # A MSG chunk carries the SecureChannelId, then this, then a SequenceHeader + # ahead of its slice of the payload, which is the 16 bytes that + # Rex::Proto::OpcUa::Tcp::SECURE_MSG_PREFIX_LEN accounts for. + class SymmetricSecurityHeader < BinData::Record + endian :little + + uint32 :token_id + end + + # Follows the security header of every message. The RequestId is what pairs a + # response with the request that asked for it. + class SequenceHeader < BinData::Record + endian :little + + uint32 :sequence_number + uint32 :request_id + end + + # The token an OpenSecureChannel response issues. ChannelId and TokenId are + # what subsequent messages quote; CreatedAt and RevisedLifetime say when the + # server will stop honouring them, the revised lifetime being the server's + # answer to the lifetime the client asked for rather than the client's request + # granted. See OPC-UA Specification Part 4, section 7.6. + class ChannelSecurityToken < BinData::Record + endian :little + + uint32 :channel_id + uint32 :token_id + opc_ua_date_time :created_at + uint32 :revised_lifetime + end + + # The header every service request opens with. See OPC-UA Specification + # Part 4, section 7.28. + # + # ReturnDiagnostics is sent as zero throughout this library, which is what + # entitles Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo to model only the + # empty form of the diagnostics a response carries back. + class RequestHeader < BinData::Record + endian :little + + # The AuthenticationToken of a request sent without a session, which is the + # null NodeId. It is also the default, so a RequestHeader built here is + # already sessionless. + opc_ua_node_id :authentication_token + opc_ua_date_time :timestamp + uint32 :request_handle + uint32 :return_diagnostics + opc_ua_string :audit_entry_id + uint32 :timeout_hint + opc_ua_extension_object :additional_header + end + + # The header every service response opens with. See OPC-UA Specification + # Part 4, section 7.29. + # + # ServiceResult is the StatusCode for the service call itself, and is the + # field that says whether the response body means anything: a service can fail + # while the message carrying the failure is perfectly well formed. + class ResponseHeader < BinData::Record + endian :little + + opc_ua_date_time :timestamp + uint32 :request_handle + uint32 :service_result + opc_ua_diagnostic_info :service_diagnostics + opc_ua_array :string_table, type: :opc_ua_string + opc_ua_extension_object :additional_header + end + + # OpenSecureChannelRequest. See OPC-UA Specification Part 4, section 5.5.2. + # + # RequestType selects between issuing a new token and renewing an existing + # one; SecurityMode is a MessageSecurityMode, for which see + # Rex::Proto::OpcUa::Enums::SECURITY_MODES. Under the None policy the + # ClientNonce is null rather than empty, since there is no key material to + # derive. + class OpenSecureChannelRequest < BinData::Record + endian :little + + # SecurityTokenRequestType (Part 4, section 7.35). + ISSUE = 0 + RENEW = 1 + + request_header :request_header + uint32 :client_protocol_version + uint32 :request_type + uint32 :security_mode + opc_ua_byte_string :client_nonce + uint32 :requested_lifetime + end + + # OpenSecureChannelResponse. See OPC-UA Specification Part 4, section 5.5.2. + # + # The ServerNonce pairs with the ClientNonce and is null under the None + # policy. Reading it is what makes the record account for the whole response + # rather than stopping at the last field the caller happens to want. + class OpenSecureChannelResponse < BinData::Record + endian :little + + response_header :response_header + uint32 :server_protocol_version + channel_security_token :security_token + opc_ua_byte_string :server_nonce + end + + # CloseSecureChannelRequest. See OPC-UA Specification Part 4, section 5.5.3. + # The channel being closed is the one the message is sent on, so the request + # carries nothing beyond its header. + class CloseSecureChannelRequest < BinData::Record + endian :little + + request_header :request_header + end +end diff --git a/lib/rex/proto/opc_ua/types.rb b/lib/rex/proto/opc_ua/types.rb index 651b419d9a967..d5e8f39aa0edf 100644 --- a/lib/rex/proto/opc_ua/types.rb +++ b/lib/rex/proto/opc_ua/types.rb @@ -171,4 +171,210 @@ def do_num_bytes end end end + + # OPC-UA DateTime: a signed Int64 count of 100 nanosecond ticks since + # 1601-01-01 00:00:00 UTC, the same epoch and resolution as a Windows + # FILETIME. See OPC-UA Specification Part 6, section 5.2.2.5. + # + # The Ruby value is the raw tick count rather than a Time, so that a decode + # and re-encode is byte for byte unchanged whatever the server sent, including + # values a Time cannot hold. #to_time converts on demand for the callers that + # want a date rather than a number. + class OpcUaDateTime < BinData::Int64le + # Ticks in one second. + TICKS_PER_SECOND = 10_000_000 + + # Seconds between the OPC-UA epoch of 1601-01-01 and the Unix epoch of + # 1970-01-01. This is the 134774 days between the two dates in seconds, + # which is worth stating because the number is otherwise unverifiable by + # inspection: + # + # (Date.new(1970, 1, 1) - Date.new(1601, 1, 1)).to_i * 86400 == 11_644_473_600 + UNIX_EPOCH_SECONDS = 11_644_473_600 + + # The same offset in ticks. + UNIX_EPOCH_TICKS = UNIX_EPOCH_SECONDS * TICKS_PER_SECOND + + # @param time [Time] the time to encode. + # @return [Integer] the tick count for that time. + def self.from_time(time) + ((time.to_r * TICKS_PER_SECOND) + UNIX_EPOCH_TICKS).to_i + end + + # @return [Integer] the tick count for the current time, for the Timestamp + # of a request being built. + def self.now + from_time(::Time.now) + end + + # @return [Time] this timestamp as a UTC Time. The conversion goes through + # a Rational rather than a Float, so no tick is lost on the way. + def to_time + ::Time.at(Rational(snapshot - UNIX_EPOCH_TICKS, TICKS_PER_SECOND)).utc + end + end + + # NodeId, which names a node in a server's address space and is also how every + # service names its own encoding. See OPC-UA Specification Part 6, + # section 5.2.2.9. + # + # The leading byte selects the identifier form in its low nibble. Bits 0x80 + # and 0x40 add a trailing NamespaceUri and ServerIndex; those belong to the + # ExpandedNodeId form, and are accepted here because the two forms are not + # distinguishable from the bytes alone, so a reader that rejected them would + # fail against a server that sends one where this expects a NodeId. + # + # Only the TwoByte and FourByte forms appear in the captures under + # spec/file_fixtures/opc_ua. The other four, and both flags, are carried over + # unchanged from the reader in the module this library replaces; their specs + # are hand-built and are marked as such. + class OpcUaNodeId < BinData::Record + endian :little + + # Identifier forms, held in the low nibble of the encoding byte. + TWO_BYTE = 0x00 + FOUR_BYTE = 0x01 + NUMERIC = 0x02 + STRING = 0x03 + GUID = 0x04 + BYTE_STRING = 0x05 + + # Selects the identifier form. + FORM_MASK = 0x0F + # Set when a NamespaceUri String follows the identifier. + NAMESPACE_URI_FLAG = 0x80 + # Set when a ServerIndex UInt32 follows. + SERVER_INDEX_FLAG = 0x40 + + # A GUID identifier is 16 raw bytes. + GUID_LEN = 16 + + # The identifier forms this understands. An encoding byte naming anything + # else is rejected here rather than at the choice below, so that it fails as + # a BinData::ValidityError alongside every other decode failure instead of + # as a bare IndexError from the choice. + FORMS = [TWO_BYTE, FOUR_BYTE, NUMERIC, STRING, GUID, BYTE_STRING].freeze + + uint8 :encoding_byte, assert: -> { FORMS.include?(value & FORM_MASK) } + + choice :body, selection: -> { encoding_byte & FORM_MASK } do + # TwoByte carries no NamespaceIndex at all: it is namespace 0 by + # definition, which is why it can encode a NodeId in two bytes. + struct TWO_BYTE do + uint8 :identifier + end + struct FOUR_BYTE do + uint8 :namespace_index + uint16 :identifier + end + struct NUMERIC do + uint16 :namespace_index + uint32 :identifier + end + struct STRING do + uint16 :namespace_index + opc_ua_string :identifier + end + struct GUID do + uint16 :namespace_index + string :identifier, length: GUID_LEN + end + struct BYTE_STRING do + uint16 :namespace_index + opc_ua_byte_string :identifier + end + end + + opc_ua_string :namespace_uri, onlyif: -> { (encoding_byte & NAMESPACE_URI_FLAG) != 0 } + uint32 :server_index, onlyif: -> { (encoding_byte & SERVER_INDEX_FLAG) != 0 } + + # Build the FourByte form, which is what every service identifier in this + # library uses. The default instance is already the null NodeId, the TwoByte + # form with identifier 0, so there is no helper for that. + # + # @param identifier [Integer] the numeric identifier. + # @param namespace_index [Integer] the namespace, 0 for the standard set. + # @return [OpcUaNodeId] + def self.four_byte(identifier, namespace_index: 0) + new(encoding_byte: FOUR_BYTE, body: { namespace_index: namespace_index, identifier: identifier }) + end + + # @return [Integer] the NamespaceIndex, which the TwoByte form leaves + # implicit at 0. + def namespace_index + body.respond_to?(:namespace_index) ? body.namespace_index.snapshot : 0 + end + + # @return [Integer, String, nil] the identifier. Its type follows the form: + # an Integer for the three numeric forms, a String for the others. + def identifier + body.identifier.snapshot + end + end + + # ExtensionObject: a NodeId naming the encoding of the body, an encoding byte, + # and the body itself where there is one. See OPC-UA Specification Part 6, + # section 5.2.2.15. + # + # Every ExtensionObject in the captures is the empty form, a null TypeId with + # encoding 0x00, which is how an absent AdditionalHeader is sent. + class OpcUaExtensionObject < BinData::Record + endian :little + + # No body follows the encoding byte. + NO_BODY = 0x00 + # The body is a ByteString. + BYTE_STRING_BODY = 0x01 + # The body is an XmlElement, which has the ByteString wire format. + XML_ELEMENT_BODY = 0x02 + + # The encodings this understands. Anything else is rejected on read rather + # than being skipped, because the length of an unknown body is unknown and + # guessing at it would desynchronise everything that follows. + ENCODINGS = [NO_BODY, BYTE_STRING_BODY, XML_ELEMENT_BODY].freeze + + opc_ua_node_id :type_id + uint8 :encoding, assert: -> { ENCODINGS.include?(value) } + + choice :body, selection: :encoding do + string NO_BODY, length: 0 + opc_ua_byte_string BYTE_STRING_BODY + opc_ua_byte_string XML_ELEMENT_BODY + end + end + + # DiagnosticInfo, in the only form this library will read: empty. See OPC-UA + # Specification Part 6, section 5.2.2.12. + # + # Every request sent from here sets ReturnDiagnostics to zero, so a server + # that returns a populated DiagnosticInfo has answered a question it was not + # asked. The seven optional fields it would then carry, one of them a nested + # DiagnosticInfo, cannot be exercised against any capture, so reading one + # raises rather than being decoded against a structure taken on trust. + class OpcUaDiagnosticInfo < BinData::BasePrimitive + # The encoding mask of an empty DiagnosticInfo: no optional field present. + EMPTY = 0x00 + + private + + def value_to_binary_string(val) + raise BinData::ValidityError, format('DiagnosticInfo can only be written empty, not 0x%02X', val) unless val == EMPTY + + [EMPTY].pack('C') + end + + def read_and_return_value(io) + mask = io.readbytes(1).unpack1('C') + unless mask == EMPTY + raise BinData::ValidityError, + format('DiagnosticInfo encoding mask 0x%02X, but no diagnostics were requested', mask) + end + + mask + end + + def sensible_default + EMPTY + end + end end diff --git a/spec/lib/rex/proto/opc_ua/built_in_types_spec.rb b/spec/lib/rex/proto/opc_ua/built_in_types_spec.rb new file mode 100644 index 0000000000000..dc1f9a9c99bdb --- /dev/null +++ b/spec/lib/rex/proto/opc_ua/built_in_types_spec.rb @@ -0,0 +1,248 @@ +# -*- coding: binary -*- + +require 'spec_helper' +require 'rex/proto/opc_ua/types' + +# The structured built-in types of OPC-UA Specification Part 6, section 5.2.2. +# The length prefixed built-ins are covered in types_spec.rb and the array in +# opc_ua_array_spec.rb. +RSpec.describe 'Rex::Proto::OpcUa structured built-in types' do + # See spec/file_fixtures/opc_ua/README.md for provenance. Offsets into the + # capture were established by walking the response field by field; the walk + # consumes the message exactly, 135 of 135 bytes. + let(:response) do + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'open_secure_channel_response_node_opcua.bin')) + end + + # The TypeId of the response, which follows the SequenceHeader. + let(:type_id_offset) { 0x4F } + # The AdditionalHeader of the ResponseHeader, an empty ExtensionObject whose + # own TypeId is the null NodeId. + let(:additional_header_offset) { 0x68 } + # The ServiceDiagnostics of the ResponseHeader. + let(:service_diagnostics_offset) { 0x63 } + + describe Rex::Proto::OpcUa::Types::OpcUaNodeId do + describe 'the FourByte form, against the captured response TypeId' do + subject(:node_id) { described_class.read(response[type_id_offset..]) } + + it 'decodes the encoding byte' do + expect(node_id.encoding_byte.snapshot).to eq described_class::FOUR_BYTE + end + + it 'decodes the NamespaceIndex' do + expect(node_id.namespace_index).to eq 0 + end + + # 449 is the DefaultBinary encoding of OpenSecureChannelResponse, which is + # what makes this NodeId the thing that says what the message is. + it 'decodes the identifier' do + expect(node_id.identifier).to eq Rex::Proto::OpcUa::Enums::NodeIds::OPEN_SECURE_CHANNEL_RESPONSE + end + + it 'occupies four bytes' do + expect(node_id.num_bytes).to eq 4 + end + + it 're-encodes to the captured bytes' do + expect(node_id.to_binary_s).to eq response.byteslice(type_id_offset, 4) + end + + it 'is what .four_byte builds' do + expect(described_class.four_byte(449).to_binary_s).to eq response.byteslice(type_id_offset, 4) + end + end + + describe 'the TwoByte form, against the captured AdditionalHeader TypeId' do + subject(:node_id) { described_class.read(response[additional_header_offset..]) } + + it 'occupies two bytes' do + expect(node_id.num_bytes).to eq 2 + end + + it 'decodes the identifier' do + expect(node_id.identifier).to eq 0 + end + + # TwoByte carries no NamespaceIndex on the wire at all. Reporting 0 rather + # than nil is what lets a caller compare namespaces without first asking + # which form it is looking at. + it 'reports the namespace the form leaves implicit' do + expect(node_id.namespace_index).to eq 0 + end + + # Null NodeId is how a sessionless AuthenticationToken and an empty + # ExtensionObject TypeId are both sent, so it needs to be what a fresh + # instance already is. + it 'is what a default instance encodes to' do + expect(described_class.new.to_binary_s).to eq "\x00\x00".b + end + end + + # No capture contains any of the remaining forms or either flag; every + # NodeId in spec/file_fixtures/opc_ua uses encoding byte 0x01. The bytes + # below are hand-built from the identifier table in OPC-UA Specification + # Part 6, carried over unchanged from the reader in the module this library + # replaces. This is recorded under Coverage limits in + # spec/file_fixtures/opc_ua/README.md. + describe 'the forms with no capture coverage' do + it 'decodes the Numeric form' do + node_id = described_class.read("\x02\x05\x00\x39\x30\x00\x00".b) + + expect(node_id.namespace_index).to eq 5 + expect(node_id.identifier).to eq 12_345 + expect(node_id.num_bytes).to eq 7 + end + + it 'decodes the String form' do + node_id = described_class.read("\x03\x02\x00".b + [6].pack('l<') + 'MyNode') + + expect(node_id.namespace_index).to eq 2 + expect(node_id.identifier).to eq 'MyNode' + expect(node_id.num_bytes).to eq 13 + end + + it 'decodes the GUID form' do + guid = (0..15).to_a.pack('C*') + node_id = described_class.read("\x04\x01\x00".b + guid) + + expect(node_id.namespace_index).to eq 1 + expect(node_id.identifier).to eq guid + expect(node_id.num_bytes).to eq 19 + end + + it 'decodes the ByteString form' do + node_id = described_class.read("\x05\x00\x00".b + [2].pack('l<') + "\xDE\xAD".b) + + expect(node_id.identifier).to eq "\xDE\xAD".b + expect(node_id.num_bytes).to eq 9 + end + + it 'reads the trailing NamespaceUri when the 0x80 flag is set' do + node_id = described_class.read("\x81\x00\xC1\x01".b + [3].pack('l<') + 'uri') + + expect(node_id.identifier).to eq 449 + expect(node_id.namespace_uri.snapshot).to eq 'uri' + expect(node_id.num_bytes).to eq 11 + end + + it 'reads the trailing ServerIndex when the 0x40 flag is set' do + node_id = described_class.read("\x41\x00\xC1\x01".b + [7].pack('V')) + + expect(node_id.server_index.snapshot).to eq 7 + expect(node_id.num_bytes).to eq 8 + end + + # The NamespaceUri precedes the ServerIndex, so a reader that had them the + # wrong way round would still consume the right number of bytes and hand + # back two wrong values. + it 'reads both trailing fields in order when both flags are set' do + node_id = described_class.read("\xC1\x00\xC1\x01".b + [3].pack('l<') + 'uri' + [7].pack('V')) + + expect(node_id.namespace_uri.snapshot).to eq 'uri' + expect(node_id.server_index.snapshot).to eq 7 + expect(node_id.num_bytes).to eq 15 + end + + it 'omits the trailing fields when neither flag is set' do + node_id = described_class.read("\x01\x00\xC1\x01".b) + + expect(node_id.snapshot).not_to have_key(:namespace_uri) + expect(node_id.snapshot).not_to have_key(:server_index) + end + end + + # The length of an identifier form this does not know is itself unknown, so + # there is nothing to skip past and no way to keep reading. + it 'rejects an identifier form it does not know' do + expect { described_class.read("\x06\x00\x00".b).num_bytes } + .to raise_error(BinData::ValidityError, /encoding_byte/) + end + end + + describe Rex::Proto::OpcUa::Types::OpcUaExtensionObject do + describe 'against the captured empty AdditionalHeader' do + subject(:extension_object) { described_class.read(response[additional_header_offset..]) } + + # A null TypeId, an encoding of 0x00 and nothing after it. + it 'occupies three bytes' do + expect(extension_object.num_bytes).to eq 3 + end + + it 'decodes the encoding as carrying no body' do + expect(extension_object.encoding.snapshot).to eq described_class::NO_BODY + end + + it 're-encodes to the captured bytes' do + expect(extension_object.to_binary_s).to eq response.byteslice(additional_header_offset, 3) + end + + it 'is what a default instance encodes to' do + expect(described_class.new.to_binary_s).to eq "\x00\x00\x00".b + end + end + + # Neither body form appears in any capture; both are hand-built from OPC-UA + # Specification Part 6. + describe 'the body forms with no capture coverage' do + it 'decodes a ByteString body' do + raw = "\x01\x00\xC1\x01\x01".b + [2].pack('l<') + "\xDE\xAD".b + + expect(described_class.read(raw).body.snapshot).to eq "\xDE\xAD".b + expect(described_class.read(raw).num_bytes).to eq raw.bytesize + end + + # An XmlElement has the ByteString wire format, so the difference between + # the two encodings is what the bytes mean, not how they are framed. + it 'decodes an XmlElement body' do + raw = "\x01\x00\xC1\x01\x02".b + [4].pack('l<') + '' + + expect(described_class.read(raw).body.snapshot).to eq '' + expect(described_class.read(raw).num_bytes).to eq raw.bytesize + end + end + + it 'rejects a body encoding it does not know' do + expect { described_class.read("\x00\x00\x03".b).num_bytes } + .to raise_error(BinData::ValidityError, /encoding/) + end + end + + describe Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo do + describe 'against the captured ServiceDiagnostics' do + subject(:diagnostics) { described_class.read(response[service_diagnostics_offset..]) } + + it 'occupies the single mask byte' do + expect(diagnostics.num_bytes).to eq 1 + end + + it 'decodes as empty' do + expect(diagnostics.snapshot).to eq described_class::EMPTY + end + + it 're-encodes to the captured byte' do + expect(diagnostics.to_binary_s).to eq "\x00".b + end + end + + # Every request this library sends asks for no diagnostics, so a populated + # DiagnosticInfo is a server answering a question it was not asked. The + # seven optional fields it would carry are deliberately not modelled, and + # skipping past a structure of unknown length is not possible, so this has + # to fail rather than continue. + it 'refuses to read a populated DiagnosticInfo' do + expect { described_class.read("\x01".b) } + .to raise_error(BinData::ValidityError, /mask 0x01, but no diagnostics were requested/) + end + + it 'names the mask it refused' do + expect { described_class.read("\x7F".b) } + .to raise_error(BinData::ValidityError, /mask 0x7F/) + end + + it 'refuses to write anything but empty' do + expect { described_class.new(1).to_binary_s } + .to raise_error(BinData::ValidityError, /can only be written empty/) + end + end +end diff --git a/spec/lib/rex/proto/opc_ua/date_time_spec.rb b/spec/lib/rex/proto/opc_ua/date_time_spec.rb new file mode 100644 index 0000000000000..07c395fd57e93 --- /dev/null +++ b/spec/lib/rex/proto/opc_ua/date_time_spec.rb @@ -0,0 +1,96 @@ +# -*- coding: binary -*- + +require 'spec_helper' +require 'rex/proto/opc_ua/types' + +RSpec.describe Rex::Proto::OpcUa::Types::OpcUaDateTime do + # See spec/file_fixtures/opc_ua/README.md for provenance. + let(:response) do + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'open_secure_channel_response_node_opcua.bin')) + end + + # The CreatedAt of the ChannelSecurityToken, which is the third field of the + # token and so begins 0x77 bytes into the message. Its position was + # established by walking the response field by field; the walk consumes the + # message exactly, 135 of 135 bytes. + let(:created_at_offset) { 0x77 } + let(:created_at_binary) { response.byteslice(created_at_offset, 8) } + + subject(:created_at) { described_class.read(created_at_binary) } + + describe 'the epoch' do + # The offset between the two epochs is the one number here that cannot be + # checked by inspection, and getting it wrong shifts every timestamp the + # scanner reports by 369 years while everything still decodes. Both + # directions are pinned against the epochs themselves. + it 'places tick zero at 1601-01-01 UTC' do + expect(described_class.new(0).to_time).to eq ::Time.utc(1601, 1, 1) + end + + # Written out rather than compared against UNIX_EPOCH_TICKS, which is the + # constant under test: comparing it to itself would pass whatever it held. + it 'places the Unix epoch at 116444736000000000 ticks' do + expect(described_class.from_time(::Time.utc(1970, 1, 1))).to eq 116_444_736_000_000_000 + end + + it 'counts ten million ticks to the second' do + expect(described_class.new(described_class::UNIX_EPOCH_TICKS + 10_000_000).to_time) + .to eq ::Time.utc(1970, 1, 1, 0, 0, 1) + end + end + + describe 'against the CreatedAt of the captured OPN response' do + it 'occupies eight bytes' do + expect(created_at.num_bytes).to eq 8 + end + + it 'decodes the tick count' do + expect(created_at.snapshot).to eq 134_322_644_997_030_000 + end + + # The captures were taken on 2026-08-27, which is recorded independently of + # the bytes in spec/file_fixtures/opc_ua/README.md. A decode that landed on + # any other date would mean the epoch or the resolution is wrong. + it 'converts to the date the capture was taken' do + expect(created_at.to_time).to eq ::Time.utc(2026, 8, 27, 0, 34, 59) + Rational(703, 1000) + end + + it 'converts to a UTC time rather than a local one' do + expect(created_at.to_time.utc?).to be true + end + + it 're-encodes to the captured bytes' do + expect(created_at.to_binary_s).to eq created_at_binary + end + + # Time cannot hold a 100 nanosecond tick as a Float, so the conversion goes + # through a Rational. This is what proves it: a round trip out to a Time and + # back has to land on the same tick, not merely a nearby one. + it 'round trips through a Time without losing a tick' do + expect(described_class.from_time(created_at.to_time)).to eq created_at.snapshot + end + end + + describe '.now' do + it 'returns a tick count for the current time' do + before = described_class.from_time(::Time.now) + now = described_class.now + after = described_class.from_time(::Time.now) + + expect(now).to be_between(before, after) + end + end + + describe 'the wire format' do + it 'is little endian' do + expect(described_class.read("\x01\x00\x00\x00\x00\x00\x00\x00".b).snapshot).to eq 1 + end + + # DateTime is a signed Int64, so a server that sends a value below the + # epoch is decoding to a date before 1601 rather than to a huge positive + # number. + it 'is signed' do + expect(described_class.read([-1].pack('q<')).snapshot).to eq(-1) + end + end +end diff --git a/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb b/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb new file mode 100644 index 0000000000000..1f6e57fcbfd66 --- /dev/null +++ b/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb @@ -0,0 +1,319 @@ +# -*- coding: binary -*- + +require 'spec_helper' +require 'rex/proto/opc_ua/secure_channel' + +RSpec.describe 'Rex::Proto::OpcUa::SecureChannel' do + # The captured OpenSecureChannelResponse, whole and including its 8 byte + # message header. See spec/file_fixtures/opc_ua/README.md for provenance. + let(:response) do + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'open_secure_channel_response_node_opcua.bin')) + end + + # Everything after the message header, which is what a caller has once + # Rex::Proto::OpcUa::Tcp::MessageStream has framed the message. + let(:message_body) { response[Rex::Proto::OpcUa::Tcp::HEADER_LEN..] } + + # Offsets into the capture, all measured from the start of the file. They were + # established by walking the response field by field; the walk consumes the + # message exactly, which the end to end example below is the assertion of. + let(:security_header_offset) { 0x0C } + let(:sequence_header_offset) { 0x47 } + let(:type_id_offset) { 0x4F } + let(:response_body_offset) { 0x53 } + let(:security_token_offset) { 0x6F } + + describe Rex::Proto::OpcUa::SecureChannel::AsymmetricSecurityHeader do + subject(:security_header) { described_class.read(response[security_header_offset..]) } + + it 'decodes the SecurityPolicyUri' do + expect(security_header.security_policy_uri.snapshot).to eq Rex::Proto::OpcUa::Enums::NONE_POLICY_URI + end + + # Under the None policy there is no certificate to send, and null is not the + # same as an empty ByteString; a re-encode has to preserve which one it was. + it 'decodes the SenderCertificate as null' do + expect(security_header.sender_certificate.snapshot).to be_nil + end + + it 'decodes the ReceiverCertificateThumbprint as null' do + expect(security_header.receiver_certificate_thumbprint.snapshot).to be_nil + end + + it 'accounts for the whole header' do + expect(security_header.num_bytes).to eq sequence_header_offset - security_header_offset + end + + it 're-encodes to the captured bytes' do + expect(security_header.to_binary_s).to eq response[security_header_offset...sequence_header_offset] + end + end + + describe Rex::Proto::OpcUa::SecureChannel::SequenceHeader do + subject(:sequence_header) { described_class.read(response[sequence_header_offset..]) } + + it 'decodes the SequenceNumber' do + expect(sequence_header.sequence_number.snapshot).to eq 1 + end + + it 'decodes the RequestId' do + expect(sequence_header.request_id.snapshot).to eq 1 + end + + it 'occupies eight bytes' do + expect(sequence_header.num_bytes).to eq 8 + end + end + + describe Rex::Proto::OpcUa::SecureChannel::SymmetricSecurityHeader do + it 'is a single TokenId' do + expect(described_class.new(token_id: 1).to_binary_s).to eq [1].pack('V') + end + + # A MSG chunk repeats the SecureChannelId, this header and a SequenceHeader + # ahead of its slice of the payload. That is the prefix + # Rex::Proto::OpcUa::Tcp strips by length, so the two files have to agree on + # what it adds up to. + it 'accounts for the stripped MSG prefix together with the SequenceHeader' do + prefix = 4 + described_class.new.num_bytes + + Rex::Proto::OpcUa::SecureChannel::SequenceHeader.new.num_bytes + + expect(prefix).to eq Rex::Proto::OpcUa::Tcp::SECURE_MSG_PREFIX_LEN + end + end + + describe Rex::Proto::OpcUa::SecureChannel::ChannelSecurityToken do + subject(:token) { described_class.read(response[security_token_offset..]) } + + it 'decodes the ChannelId' do + expect(token.channel_id.snapshot).to eq 6 + end + + # The ChannelId inside the token is the same channel as the plaintext + # SecureChannelId the message opens with. A record that had the token + # starting a field early or late would break that agreement. + it 'decodes the ChannelId the message header already named' do + expect(token.channel_id.snapshot).to eq message_body.byteslice(0, 4).unpack1('V') + end + + it 'decodes the TokenId' do + expect(token.token_id.snapshot).to eq 1 + end + + it 'decodes the CreatedAt' do + expect(token.created_at.to_time).to eq ::Time.utc(2026, 8, 27, 0, 34, 59) + Rational(703, 1000) + end + + # Ten minutes, where the client asked for an hour. Reading it is the point + # of modelling the whole token: it is the server's answer, not the client's + # request granted. + it 'decodes the RevisedLifetime' do + expect(token.revised_lifetime.snapshot).to eq 600_000 + end + + it 'occupies twenty bytes' do + expect(token.num_bytes).to eq 20 + end + + it 're-encodes to the captured bytes' do + expect(token.to_binary_s).to eq response.byteslice(security_token_offset, 20) + end + end + + describe Rex::Proto::OpcUa::SecureChannel::ResponseHeader do + subject(:response_header) { described_class.read(response[response_body_offset..]) } + + it 'decodes the Timestamp' do + expect(response_header.timestamp.to_time).to eq ::Time.utc(2026, 8, 27, 0, 34, 59) + Rational(704, 1000) + end + + it 'decodes the RequestHandle' do + expect(response_header.request_handle.snapshot).to eq 1 + end + + # Zero is Good. A service can fail while the message carrying the failure is + # perfectly well formed, so this is the field that says whether the rest of + # the response means anything. + it 'decodes the ServiceResult' do + expect(response_header.service_result.snapshot).to eq 0 + end + + it 'decodes the ServiceDiagnostics as empty' do + expect(response_header.service_diagnostics.snapshot) + .to eq Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo::EMPTY + end + + it 'decodes the StringTable as empty' do + expect(response_header.string_table).to be_empty + end + + # The server sent a count of zero, not the -1 that would mean null, and the + # two re-encode differently. + it 'decodes the StringTable as empty rather than null' do + expect(response_header.string_table).not_to be_null + end + + it 'decodes the AdditionalHeader as an empty ExtensionObject' do + expect(response_header.additional_header.encoding.snapshot) + .to eq Rex::Proto::OpcUa::Types::OpcUaExtensionObject::NO_BODY + end + end + + describe Rex::Proto::OpcUa::SecureChannel::OpenSecureChannelResponse do + subject(:open_response) { described_class.read(response[response_body_offset..]) } + + it 'decodes the ServerProtocolVersion' do + expect(open_response.server_protocol_version.snapshot).to eq 0 + end + + it 'decodes the SecurityToken' do + expect(open_response.security_token.token_id.snapshot).to eq 1 + expect(open_response.security_token.revised_lifetime.snapshot).to eq 600_000 + end + + # The ServerNonce is null under the None policy, there being no key material + # to derive. It is the last field of the response, so reading it is what + # makes the record account for the message rather than stop at the last + # field a caller happens to want. + it 'decodes the ServerNonce as null' do + expect(open_response.server_nonce.snapshot).to be_nil + end + + it 'accounts for the rest of the message' do + expect(open_response.num_bytes).to eq response.bytesize - response_body_offset + end + end + + # The records above each cover one structure. This covers the claim that they + # tile the message: the whole 135 byte capture is consumed by the sequence of + # them, with nothing skipped, nothing counted twice and nothing left over. + # + # The message body is not itself a record. The envelope of a SecureChannel + # message is the same for a request and a response, so what follows the + # SequenceHeader is decided by the TypeId that comes next, which is why the + # caller reads it and dispatches rather than a record doing it. + describe 'the captured message end to end' do + subject(:decoded) do + offset = 0 + parts = {} + + parts[:secure_channel_id] = message_body.byteslice(offset, 4).unpack1('V') + offset += 4 + + %i[security_header sequence_header type_id response].zip( + [ + Rex::Proto::OpcUa::SecureChannel::AsymmetricSecurityHeader, + Rex::Proto::OpcUa::SecureChannel::SequenceHeader, + Rex::Proto::OpcUa::Types::OpcUaNodeId, + Rex::Proto::OpcUa::SecureChannel::OpenSecureChannelResponse + ] + ).each do |name, klass| + parts[name] = klass.read(message_body[offset..]) + offset += parts[name].num_bytes + end + + parts.merge(consumed: offset) + end + + it 'consumes the whole message with nothing left over' do + expect(decoded[:consumed]).to eq message_body.bytesize + end + + it 'consumes all 135 bytes of the capture once the message header is counted' do + expect(decoded[:consumed] + Rex::Proto::OpcUa::Tcp::HEADER_LEN).to eq 135 + end + + it 'identifies the message from its TypeId' do + expect(decoded[:type_id].identifier).to eq Rex::Proto::OpcUa::Enums::NodeIds::OPEN_SECURE_CHANNEL_RESPONSE + end + + it 're-encodes byte for byte to the captured message body' do + rebuilt = [decoded[:secure_channel_id]].pack('V') + + decoded[:security_header].to_binary_s + + decoded[:sequence_header].to_binary_s + + decoded[:type_id].to_binary_s + + decoded[:response].to_binary_s + + expect(rebuilt).to eq message_body + end + end + + describe Rex::Proto::OpcUa::SecureChannel::OpenSecureChannelRequest do + # A timestamp taken from the capture rather than the clock, so that the + # expected bytes below are fixed. + let(:timestamp) { 134_322_644_997_030_000 } + + subject(:request) do + described_class.new( + request_header: { + timestamp: timestamp, + request_handle: 1, + return_diagnostics: 0, + timeout_hint: 10_000 + }, + client_protocol_version: 0, + request_type: described_class::ISSUE, + security_mode: 1, + requested_lifetime: 3_600_000 + ) + end + + # Byte for byte what the shipped module builds for the same call. Pinning it + # here is what says the record layer produces the same wire form as the code + # it replaces, rather than merely something that decodes. + let(:expected) do + [0x00, 0x00].pack('CC') + # AuthenticationToken: null NodeId + [timestamp].pack('q<') + # Timestamp + [1].pack('V') + # RequestHandle + [0].pack('V') + # ReturnDiagnostics: none + [-1].pack('l<') + # AuditEntryId: null + [10_000].pack('V') + # TimeoutHint in milliseconds + [0x00, 0x00, 0x00].pack('CCC') + # AdditionalHeader: null ExtensionObject + [0].pack('V') + # ClientProtocolVersion + [0].pack('V') + # RequestType: Issue + [1].pack('V') + # MessageSecurityMode: None + [-1].pack('l<') + # ClientNonce: null under the None policy + [3_600_000].pack('V') # RequestedLifetime in milliseconds + end + + it 'encodes to the bytes an OpenSecureChannel for SecurityPolicy None needs' do + expect(request.to_binary_s).to eq expected + end + + # The defaults carry the two fields that have to be null rather than empty, + # so a caller that names neither still sends a legal request. + it 'defaults the AuditEntryId to null' do + expect(request.request_header.audit_entry_id.snapshot).to be_nil + end + + it 'defaults the ClientNonce to null' do + expect(request.client_nonce.snapshot).to be_nil + end + + it 'defaults the AuthenticationToken to the null NodeId of a sessionless request' do + expect(request.request_header.authentication_token.to_binary_s).to eq "\x00\x00".b + end + + it 'round trips' do + expect(described_class.read(request.to_binary_s).snapshot).to eq request.snapshot + end + end + + describe Rex::Proto::OpcUa::SecureChannel::CloseSecureChannelRequest do + # The channel being closed is the one the message is sent on, so the request + # is its header and nothing else. + subject(:request) do + described_class.new(request_header: { timestamp: 0, request_handle: 3, timeout_hint: 10_000 }) + end + + it 'encodes to a RequestHeader alone' do + expect(request.to_binary_s) + .to eq Rex::Proto::OpcUa::SecureChannel::RequestHeader + .new(timestamp: 0, request_handle: 3, timeout_hint: 10_000).to_binary_s + end + + it 'round trips' do + expect(described_class.read(request.to_binary_s).snapshot).to eq request.snapshot + end + end +end From 584102929afc9230811f01d59e4faf4a3054190f Mon Sep 17 00:00:00 2001 From: ethan-thomason Date: Fri, 28 Aug 2026 09:13:56 -0700 Subject: [PATCH 5/8] Add OPC-UA service records and complete the library layer --- lib/rex/proto/opc_ua/enums.rb | 23 +- lib/rex/proto/opc_ua/error.rb | 32 +- lib/rex/proto/opc_ua/secure_channel.rb | 102 +++-- lib/rex/proto/opc_ua/services.rb | 179 +++++++++ lib/rex/proto/opc_ua/tcp.rb | 118 ++++-- lib/rex/proto/opc_ua/types.rb | 152 ++++++- .../rex/proto/opc_ua/built_in_types_spec.rb | 96 +++++ .../lib/rex/proto/opc_ua/opc_ua_array_spec.rb | 28 +- .../rex/proto/opc_ua/secure_channel_spec.rb | 41 +- spec/lib/rex/proto/opc_ua/services_spec.rb | 371 ++++++++++++++++++ 10 files changed, 952 insertions(+), 190 deletions(-) create mode 100644 lib/rex/proto/opc_ua/services.rb create mode 100644 spec/lib/rex/proto/opc_ua/services_spec.rb diff --git a/lib/rex/proto/opc_ua/enums.rb b/lib/rex/proto/opc_ua/enums.rb index f5731c9e401fb..6af01f298aa7c 100644 --- a/lib/rex/proto/opc_ua/enums.rb +++ b/lib/rex/proto/opc_ua/enums.rb @@ -1,4 +1,5 @@ # -*- coding: binary -*- +# frozen_string_literal: true # Enumerated values and identifiers from the OPC-UA specification. # @@ -6,8 +7,14 @@ # machine-readable definitions rather than from prose, and can be re-verified # against them: # -# StatusCodes https://github.com/OPCFoundation/UA-Nodeset/blob/latest/Schema/StatusCode.csv -# NodeIds https://github.com/OPCFoundation/UA-Nodeset/blob/latest/Schema/NodeIds.csv +# StatusCodes reference/opcua/StatusCode.csv +# NodeIds reference/opcua/NodeIds.csv +# +# Both are also published at +# https://github.com/OPCFoundation/UA-Nodeset/blob/latest/Schema/. Every value +# in this file has been checked against the local copies; note that +# StatusCode.csv spells the names without the underscore used here and in the +# specification prose, so Bad_TcpServerTooBusy is BadTcpServerTooBusy there. module Rex::Proto::OpcUa::Enums # SecurityPolicy URI for the None policy. An endpoint offering this applies # no signing or encryption, so a channel opened under it is readable on the @@ -21,8 +28,10 @@ module Rex::Proto::OpcUa::Enums # over this transport. All are in namespace 0. A request and its response # differ by three, the intervening identifier being the XML encoding. # - # The OpenSecureChannel and GetEndpoints response identifiers were also read - # back off the wire from the captures in spec/file_fixtures/opc_ua. + # All six were checked against reference/opcua/NodeIds.csv, where each appears + # as _Encoding_DefaultBinary. The OpenSecureChannel and + # GetEndpoints response identifiers were also read back off the wire from the + # captures in spec/file_fixtures/opc_ua. module NodeIds OPEN_SECURE_CHANNEL_REQUEST = 446 OPEN_SECURE_CHANNEL_RESPONSE = 449 @@ -32,7 +41,8 @@ module NodeIds GET_ENDPOINTS_RESPONSE = 431 end - # MessageSecurityMode (Part 4, section 7.15). + # MessageSecurityMode (Part 4, section 7.15), matching the enumeration of the + # same name in reference/opcua/Opc.Ua.Types.bsd. SECURITY_MODES = { 0 => 'Invalid', 1 => 'None', @@ -40,7 +50,8 @@ module NodeIds 3 => 'SignAndEncrypt' }.freeze - # UserTokenType (Part 4, section 7.36). + # UserTokenType (Part 4, section 7.36), matching the enumeration of the same + # name in reference/opcua/Opc.Ua.Types.bsd. TOKEN_TYPES = { 0 => 'Anonymous', 1 => 'UserName', diff --git a/lib/rex/proto/opc_ua/error.rb b/lib/rex/proto/opc_ua/error.rb index e0635c92ba5ef..ccd36cb663cff 100644 --- a/lib/rex/proto/opc_ua/error.rb +++ b/lib/rex/proto/opc_ua/error.rb @@ -1,4 +1,5 @@ # -*- coding: binary -*- +# frozen_string_literal: true # Errors raised by the OPC-UA library. # @@ -8,6 +9,11 @@ # follows Rex::Proto::Thrift::Error and Rex::Proto::Amqp::Error, which solve the # same problem for their transports. # +# OpcUaError descends from Rex::RuntimeError, which means every error here is +# both a StandardError and a Rex::Exception without any of them saying so +# individually: Rex::RuntimeError includes the Rex::Exception marker module, so +# `rescue Rex::Exception` catches these alongside Rex's own. +# # The distinction the classes draw is between a fault in our reading of the # connection and a fault the server reported, because a scanner reports those # very differently: a TimeoutError against a host that never answers is not @@ -17,26 +23,28 @@ module Rex::Proto::OpcUa::Error class OpcUaError < Rex::RuntimeError; end # Raised when a read does not complete before its deadline, either because - # nothing arrived or because only part of a message did. Rex sockets report - # a closed connection by raising EOFError rather than by timing out, and that - # is left to propagate as itself. + # nothing arrived or because only part of a message did. Rex sockets report a + # closed connection by raising EOFError rather than by timing out, and that is + # left to propagate as itself. class TimeoutError < OpcUaError; end # Raised when the UA TCP framing is unusable: a message size outside the # permitted range, a chunk too short to hold its own headers, a message or # chunk type that has no meaning here, or a response that ran past the chunk - # ceiling. + # ceiling. See OPC-UA Specification Part 6, section 7.1. class FramingError < OpcUaError; end # Raised when the server abandons a response part way through by sending a - # chunk of type A. The response cannot be completed, but the connection - # itself is intact and the server is behaving to specification. + # chunk of type A, per OPC-UA Specification Part 6, section 6.7.2. The + # response cannot be completed, but the connection itself is intact and the + # server is behaving to specification. class AbortError < OpcUaError; end - # Raised when the server answers with an ERR message. This is a report from - # the server rather than a fault in reading it, and the StatusCode it carries - # is the useful part, so it is kept as a field rather than only interpolated - # into the message. + # Raised when the server answers with an ERR message, per OPC-UA + # Specification Part 6, section 7.1.2.5. This is a report from the server + # rather than a fault in reading it, and the StatusCode it carries is the + # useful part, so it is kept as a field rather than only interpolated into the + # message. class ServerError < OpcUaError # @return [Integer, nil] the StatusCode from the ERR message, or nil when # the body could not be decoded. @@ -47,8 +55,10 @@ class ServerError < OpcUaError attr_reader :reason # @param status_code [Integer, nil] the StatusCode from the ERR message. - # @param reason [String, nil] the Reason from the ERR message. + # @param reason [String, nil] the Reason from the ERR message. An empty + # reason is stored as nil, since the two say the same thing. # @param msg [String, nil] overrides the generated message. + # @return [ServerError] def initialize(status_code: nil, reason: nil, msg: nil) @status_code = status_code @reason = reason.to_s.empty? ? nil : reason.to_s diff --git a/lib/rex/proto/opc_ua/secure_channel.rb b/lib/rex/proto/opc_ua/secure_channel.rb index 0a3376835102c..864610653ba23 100644 --- a/lib/rex/proto/opc_ua/secure_channel.rb +++ b/lib/rex/proto/opc_ua/secure_channel.rb @@ -1,10 +1,13 @@ # -*- coding: binary -*- +# frozen_string_literal: true require 'bindata' # BinData resolves field types when a record's class body is evaluated, so the -# library types have to be registered before the records below are defined. -# Under Zeitwerk they would otherwise not load until first referenced. +# library types and the shared service headers have to be registered before the +# records below are defined. Under Zeitwerk they would otherwise not load until +# first referenced. require 'rex/proto/opc_ua/types' +require 'rex/proto/opc_ua/services' # The SecureChannel layer: the headers that wrap every message on a channel, and # the services that open and close one. @@ -17,11 +20,23 @@ # The service records hold the service structures alone. The TypeId NodeId that # precedes one in a message is part of the message encoding rather than part of # the service, and is read and written separately. +# +# Field order throughout is taken from reference/opcua/Opc.Ua.Types.bsd rather +# than inferred from a capture. Each record names the StructuredType it was +# checked against, and every one of them was also walked byte for byte through +# spec/file_fixtures/opc_ua/open_secure_channel_response_node_opcua.bin. module Rex::Proto::OpcUa::SecureChannel - # The security header of an OPN message. Under SecurityPolicy None both - # certificate fields are null, which is what makes an OPN exchange readable on - # the wire and lets a client open a channel with no key material of its own. - # See OPC-UA Specification Part 6. + # The security header of an OPN message, carrying the policy the channel is + # being opened under and the certificates that policy needs. + # + # See OPC-UA Specification Part 6, section 6.7.2, which names the three fields + # of the AsymmetricAlgorithmSecurityHeader in this order. It is not a + # StructuredType in reference/opcua/Opc.Ua.Types.bsd, which describes the + # service structures rather than the channel framing that carries them. + # + # Under SecurityPolicy None both certificate fields are null, which is what + # makes an OPN exchange readable on the wire and lets a client open a channel + # with no key material of its own. class AsymmetricSecurityHeader < BinData::Record endian :little @@ -33,6 +48,10 @@ class AsymmetricSecurityHeader < BinData::Record # The security header of every message sent on an open channel, naming the # token the message is secured with. This is the whole of it: a single UInt32. # + # See OPC-UA Specification Part 6, section 6.7.2, for the + # SymmetricAlgorithmSecurityHeader. Like the asymmetric header above it is + # channel framing rather than a StructuredType in the schema. + # # A MSG chunk carries the SecureChannelId, then this, then a SequenceHeader # ahead of its slice of the payload, which is the 16 bytes that # Rex::Proto::OpcUa::Tcp::SECURE_MSG_PREFIX_LEN accounts for. @@ -44,6 +63,8 @@ class SymmetricSecurityHeader < BinData::Record # Follows the security header of every message. The RequestId is what pairs a # response with the request that asked for it. + # + # See OPC-UA Specification Part 6, section 6.7.2, for the SequenceHeader. class SequenceHeader < BinData::Record endian :little @@ -55,7 +76,10 @@ class SequenceHeader < BinData::Record # what subsequent messages quote; CreatedAt and RevisedLifetime say when the # server will stop honouring them, the revised lifetime being the server's # answer to the lifetime the client asked for rather than the client's request - # granted. See OPC-UA Specification Part 4, section 7.6. + # granted. + # + # See OPC-UA Specification Part 4, section 7.6, and the ChannelSecurityToken + # StructuredType in reference/opcua/Opc.Ua.Types.bsd. class ChannelSecurityToken < BinData::Record endian :little @@ -65,55 +89,22 @@ class ChannelSecurityToken < BinData::Record uint32 :revised_lifetime end - # The header every service request opens with. See OPC-UA Specification - # Part 4, section 7.28. + # OpenSecureChannelRequest. # - # ReturnDiagnostics is sent as zero throughout this library, which is what - # entitles Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo to model only the - # empty form of the diagnostics a response carries back. - class RequestHeader < BinData::Record - endian :little - - # The AuthenticationToken of a request sent without a session, which is the - # null NodeId. It is also the default, so a RequestHeader built here is - # already sessionless. - opc_ua_node_id :authentication_token - opc_ua_date_time :timestamp - uint32 :request_handle - uint32 :return_diagnostics - opc_ua_string :audit_entry_id - uint32 :timeout_hint - opc_ua_extension_object :additional_header - end - - # The header every service response opens with. See OPC-UA Specification - # Part 4, section 7.29. - # - # ServiceResult is the StatusCode for the service call itself, and is the - # field that says whether the response body means anything: a service can fail - # while the message carrying the failure is perfectly well formed. - class ResponseHeader < BinData::Record - endian :little - - opc_ua_date_time :timestamp - uint32 :request_handle - uint32 :service_result - opc_ua_diagnostic_info :service_diagnostics - opc_ua_array :string_table, type: :opc_ua_string - opc_ua_extension_object :additional_header - end - - # OpenSecureChannelRequest. See OPC-UA Specification Part 4, section 5.5.2. + # See OPC-UA Specification Part 4, section 5.5.2, and the + # OpenSecureChannelRequest StructuredType in + # reference/opcua/Opc.Ua.Types.bsd. # - # RequestType selects between issuing a new token and renewing an existing - # one; SecurityMode is a MessageSecurityMode, for which see + # RequestType is a SecurityTokenRequestType and SecurityMode a + # MessageSecurityMode; for the latter see # Rex::Proto::OpcUa::Enums::SECURITY_MODES. Under the None policy the # ClientNonce is null rather than empty, since there is no key material to # derive. class OpenSecureChannelRequest < BinData::Record endian :little - # SecurityTokenRequestType (Part 4, section 7.35). + # SecurityTokenRequestType, from the enumeration of the same name in + # reference/opcua/Opc.Ua.Types.bsd. ISSUE = 0 RENEW = 1 @@ -125,7 +116,11 @@ class OpenSecureChannelRequest < BinData::Record uint32 :requested_lifetime end - # OpenSecureChannelResponse. See OPC-UA Specification Part 4, section 5.5.2. + # OpenSecureChannelResponse. + # + # See OPC-UA Specification Part 4, section 5.5.2, and the + # OpenSecureChannelResponse StructuredType in + # reference/opcua/Opc.Ua.Types.bsd. # # The ServerNonce pairs with the ClientNonce and is null under the None # policy. Reading it is what makes the record account for the whole response @@ -139,9 +134,12 @@ class OpenSecureChannelResponse < BinData::Record opc_ua_byte_string :server_nonce end - # CloseSecureChannelRequest. See OPC-UA Specification Part 4, section 5.5.3. - # The channel being closed is the one the message is sent on, so the request - # carries nothing beyond its header. + # CloseSecureChannelRequest. The channel being closed is the one the message + # is sent on, so the request carries nothing beyond its header. + # + # See OPC-UA Specification Part 4, section 5.5.3, and the + # CloseSecureChannelRequest StructuredType in + # reference/opcua/Opc.Ua.Types.bsd, which is a RequestHeader and nothing else. class CloseSecureChannelRequest < BinData::Record endian :little diff --git a/lib/rex/proto/opc_ua/services.rb b/lib/rex/proto/opc_ua/services.rb new file mode 100644 index 0000000000000..73e28fd139029 --- /dev/null +++ b/lib/rex/proto/opc_ua/services.rb @@ -0,0 +1,179 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +require 'bindata' +# BinData resolves field types when a record's class body is evaluated, so the +# library types have to be registered before the records below are defined. +# Under Zeitwerk they would otherwise not load until first referenced. +require 'rex/proto/opc_ua/types' + +# The service layer: the structures every OPC-UA service request and response is +# built from, independent of the channel they travel on. +# +# The headers here are shared by every service, which is why they live at this +# level rather than with the SecureChannel services that were the first to need +# them. A service that never opens a channel of its own still sends a +# RequestHeader. +# +# Field order throughout is taken from reference/opcua/Opc.Ua.Types.bsd, the OPC +# Foundation's own machine-readable type definitions, rather than inferred from +# a capture. Each record names the StructuredType it was checked against. +module Rex::Proto::OpcUa::Services + # The header every service request opens with. + # + # See OPC-UA Specification Part 4, section 7.28, and the RequestHeader + # StructuredType in reference/opcua/Opc.Ua.Types.bsd, which gives the seven + # fields below in this order. + # + # ReturnDiagnostics is sent as zero throughout this library, which is what + # entitles Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo to model only the + # empty form of the diagnostics a response carries back. + class RequestHeader < BinData::Record + endian :little + + # The AuthenticationToken of a request sent without a session is the null + # NodeId. That is also the default, so a RequestHeader built here is already + # sessionless. + opc_ua_node_id :authentication_token + opc_ua_date_time :timestamp + uint32 :request_handle + uint32 :return_diagnostics + opc_ua_string :audit_entry_id + uint32 :timeout_hint + opc_ua_extension_object :additional_header + end + + # The header every service response opens with. + # + # See OPC-UA Specification Part 4, section 7.29, and the ResponseHeader + # StructuredType in reference/opcua/Opc.Ua.Types.bsd. The schema splits the + # StringTable into a NoOfStringTable Int32 and the elements it counts, which + # is the ordinary OPC-UA array encoding that + # Rex::Proto::OpcUa::Types::OpcUaArray implements. + # + # ServiceResult is the StatusCode for the service call itself, and is the + # field that says whether the response body means anything: a service can fail + # while the message carrying the failure is perfectly well formed. + class ResponseHeader < BinData::Record + endian :little + + opc_ua_date_time :timestamp + uint32 :request_handle + uint32 :service_result + opc_ua_diagnostic_info :service_diagnostics + opc_ua_array :string_table, type: :opc_ua_string + opc_ua_extension_object :additional_header + end + + # Defensive ceilings on the arrays a GetEndpoints response carries. + # + # These bound allocation, and the number that matters is the product rather + # than any one of them: every ceiling here sits inside the endpoint array, so + # a server claiming the maximum everywhere costs MAX_ENDPOINTS multiplied by + # the inner ceiling. Leaving the inner arrays on the 512 element default of + # Rex::Proto::OpcUa::Types::OpcUaArray would allow 32768 UserTokenPolicy + # objects, each of them five length prefixed strings, from one response. + # + # MAX_ENDPOINTS is carried over unchanged from the module this library + # replaces. The two inner ceilings are set to match it: one number to reason + # about, and each is more than ten times the largest count in any capture, + # where the busiest endpoint advertises five token policies. + MAX_ENDPOINTS = 64 + MAX_USER_TOKENS = 64 + MAX_DISCOVERY_URLS = 64 + + # ApplicationDescription, the server's description of itself. See OPC-UA + # Specification Part 4, section 7.2, and the ApplicationDescription + # StructuredType in reference/opcua/Opc.Ua.Types.bsd. + # + # ApplicationUri and ProductUri are the fingerprint worth reporting: they name + # the product and installation rather than the host that answered. + class ApplicationDescription < BinData::Record + endian :little + + opc_ua_string :application_uri + opc_ua_string :product_uri + opc_ua_localized_text :application_name + # ApplicationType: Server 0, Client 1, ClientAndServer 2, DiscoveryServer 3, + # from the enumeration of that name in reference/opcua/Opc.Ua.Types.bsd. + uint32 :application_type + opc_ua_string :gateway_server_uri + opc_ua_string :discovery_profile_uri + opc_ua_array :discovery_urls, type: :opc_ua_string, max_length: MAX_DISCOVERY_URLS + end + + # UserTokenPolicy, one way of proving identity that an endpoint will accept. + # See OPC-UA Specification Part 4, section 7.37, and the UserTokenPolicy + # StructuredType in reference/opcua/Opc.Ua.Types.bsd. + # + # TokenType is a UserTokenType; see Rex::Proto::OpcUa::Enums::TOKEN_TYPES. A + # policy of type Anonymous is the one that makes an endpoint reachable without + # credentials at all. + # + # SecurityPolicyUri here is per token and is not the endpoint's own security + # policy; a token may name a stronger one than the channel it arrives on. + class UserTokenPolicy < BinData::Record + endian :little + + opc_ua_string :policy_id + uint32 :token_type + opc_ua_string :issued_token_type + opc_ua_string :issuer_endpoint_url + opc_ua_string :security_policy_uri + end + + # EndpointDescription, one way of connecting to a server. See OPC-UA + # Specification Part 4, section 7.10, and the EndpointDescription + # StructuredType in reference/opcua/Opc.Ua.Types.bsd. + # + # SecurityMode is a MessageSecurityMode; see + # Rex::Proto::OpcUa::Enums::SECURITY_MODES. The pair that matters to a scan is + # a SecurityMode of None with an Anonymous UserIdentityToken, which together + # mean an unauthenticated client can read process data over a channel nothing + # is protecting. + class EndpointDescription < BinData::Record + endian :little + + opc_ua_string :endpoint_url + application_description :server + opc_ua_byte_string :server_certificate + uint32 :security_mode + opc_ua_string :security_policy_uri + opc_ua_array :user_identity_tokens, type: :user_token_policy, max_length: MAX_USER_TOKENS + opc_ua_string :transport_profile_uri + uint8 :security_level + end + + # GetEndpointsRequest. See the GetEndpointsRequest StructuredType in + # reference/opcua/Opc.Ua.Types.bsd, and the GetEndpoints service in OPC-UA + # Specification Part 4. + # + # The specification requires this service to be available without + # authentication, so that a client can discover how it is expected to connect + # before it has any way of connecting. That is what makes it enumerable. + # + # Both arrays are filters. They default to null rather than empty, which is + # what asks for everything and what the module this library replaces sends; + # an empty array encodes as a count of 0 and is a different request on the + # wire. + class GetEndpointsRequest < BinData::Record + endian :little + + request_header :request_header + opc_ua_string :endpoint_url + opc_ua_array :locale_ids, type: :opc_ua_string, null_default: true + opc_ua_array :profile_uris, type: :opc_ua_string, null_default: true + end + + # GetEndpointsResponse. See the GetEndpointsResponse StructuredType in + # reference/opcua/Opc.Ua.Types.bsd. + # + # The endpoint array carries an explicit ceiling rather than the OpcUaArray + # default; see MAX_ENDPOINTS above for why the inner arrays are capped too. + class GetEndpointsResponse < BinData::Record + endian :little + + response_header :response_header + opc_ua_array :endpoints, type: :endpoint_description, max_length: MAX_ENDPOINTS + end +end diff --git a/lib/rex/proto/opc_ua/tcp.rb b/lib/rex/proto/opc_ua/tcp.rb index 2c9e5d0405f4e..28d5ee0774390 100644 --- a/lib/rex/proto/opc_ua/tcp.rb +++ b/lib/rex/proto/opc_ua/tcp.rb @@ -1,4 +1,5 @@ # -*- coding: binary -*- +# frozen_string_literal: true require 'bindata' # BinData resolves field types when a record's class body is evaluated, so the @@ -6,10 +7,20 @@ # Under Zeitwerk they would otherwise not load until first referenced. require 'rex/proto/opc_ua/types' -# The OPC-UA TCP transport that carries opc.tcp:// (OPC-UA Specification Part 6, -# section 7). This is the framing layer only: it says what a message looks like -# and how a service response is put back together from chunks, and knows nothing -# about the services carried inside one. +# The OPC-UA TCP transport that carries opc.tcp://. This is the framing layer +# only: it says what a message looks like and how a service response is put back +# together from chunks, and knows nothing about the services carried inside one. +# +# See OPC-UA Specification Part 6, section 7.1, the UA Connection Protocol, +# which defines the message header and the Hello, Acknowledge and Error messages +# below, and section 6.7 for the chunking MessageStream reassembles. +# +# Unlike the service structures elsewhere in this library, none of this framing +# appears in reference/opcua/Opc.Ua.Types.bsd: the schema describes the types +# services exchange, not the transport that carries them. The records here were +# therefore checked against the captures under spec/file_fixtures/opc_ua and +# against the reader in the module this library replaces, not against the +# schema. module Rex::Proto::OpcUa::Tcp # Shorthand for the sibling error namespace. The compact module definition # above puts only this module in lexical scope, so without it every raise site @@ -22,7 +33,9 @@ module Rex::Proto::OpcUa::Tcp HEADER_LEN = 8 # Each MSG chunk repeats SecureChannelId, TokenId, SequenceNumber and - # RequestId ahead of its slice of the service payload. + # RequestId ahead of its slice of the service payload. See + # Rex::Proto::OpcUa::SecureChannel::SymmetricSecurityHeader, whose spec + # asserts that those structures add up to this. SECURE_MSG_PREFIX_LEN = 16 # Defensive ceilings. A malformed or hostile response must fail quickly rather @@ -31,30 +44,33 @@ module Rex::Proto::OpcUa::Tcp MAX_MESSAGE_SIZE = 4 * 1024 * 1024 MAX_CHUNKS = 64 - # MessageType values, each three ASCII bytes. These are the types this - # transport exchanges; every one of them appears in the captures under - # spec/file_fixtures/opc_ua or is sent to produce them. + # MessageType values, each three ASCII bytes, from OPC-UA Specification + # Part 6, section 7.1.2, which defines the messages named below in its + # subsections 7.1.2.2 to 7.1.2.5. These are the types this transport exchanges; every + # one of them appears in the captures under spec/file_fixtures/opc_ua or is + # sent to produce them. module MessageType - HELLO = 'HEL'.freeze - ACKNOWLEDGE = 'ACK'.freeze - ERROR = 'ERR'.freeze - OPEN_SECURE_CHANNEL = 'OPN'.freeze - CLOSE_SECURE_CHANNEL = 'CLO'.freeze - MESSAGE = 'MSG'.freeze + HELLO = 'HEL' + ACKNOWLEDGE = 'ACK' + ERROR = 'ERR' + OPEN_SECURE_CHANNEL = 'OPN' + CLOSE_SECURE_CHANNEL = 'CLO' + MESSAGE = 'MSG' end - # ChunkType values, one ASCII byte. A message that fits in one chunk is sent - # as a single F. + # ChunkType values, one ASCII byte, from OPC-UA Specification Part 6, + # section 6.7.2. A message that fits in one chunk is sent as a single F. module ChunkType # More chunks follow this one. - INTERMEDIATE = 'C'.freeze + INTERMEDIATE = 'C' # The last chunk of the message. - FINAL = 'F'.freeze + FINAL = 'F' # The server has abandoned the message; nothing further will follow. - ABORT = 'A'.freeze + ABORT = 'A' end - # The 8 byte header every message opens with. + # The 8 byte header every message opens with. See OPC-UA Specification + # Part 6, section 7.1.2.2. class MessageHeader < BinData::Record endian :little @@ -63,8 +79,10 @@ class MessageHeader < BinData::Record uint32 :message_size end - # The Hello a client opens the connection with (Part 6, section 7.1.2). The - # buffer sizes are what the client is willing to receive; a zero + # The Hello a client opens the connection with. See OPC-UA Specification + # Part 6, section 7.1.2.3. + # + # The buffer sizes are what the client is willing to receive; a zero # MaxMessageSize or MaxChunkCount means the client sets no limit of its own, # which is not the same as accepting anything, since MessageStream applies its # own ceilings regardless. @@ -79,9 +97,9 @@ class HelloMessage < BinData::Record opc_ua_string :endpoint_url end - # The server's answer to a Hello (Part 6, section 7.1.2), carrying the same - # five fields from the server's side. The buffer sizes it returns are the ones - # that then govern the connection. + # The server's answer to a Hello, carrying the same five fields from the + # server's side. See OPC-UA Specification Part 6, section 7.1.2.4. The buffer + # sizes it returns are the ones that then govern the connection. class AcknowledgeMessage < BinData::Record endian :little @@ -92,8 +110,10 @@ class AcknowledgeMessage < BinData::Record uint32 :max_chunk_count end - # The body of an ERR message (Part 6, section 7.1.2): a StatusCode and a - # Reason string, which servers routinely leave null. + # The body of an ERR message: a StatusCode and a Reason string, which servers + # routinely leave null. See OPC-UA Specification Part 6, section 7.1.2.5; the + # StatusCode itself is section 5.2.2.11, and the values are named in + # Rex::Proto::OpcUa::Enums::STATUS_CODES. class ErrorMessage < BinData::Record endian :little @@ -103,18 +123,23 @@ class ErrorMessage < BinData::Record # One framed message as it came off the wire. The body excludes the header. Message = Struct.new(:message_type, :chunk_type, :body) do + # @return [Boolean] whether this is an ERR message, which a server sends in + # place of the response that was asked for. def error? message_type == MessageType::ERROR end + # @return [Boolean] whether this chunk abandons the message it belongs to. def abort? chunk_type == ChunkType::ABORT end + # @return [Boolean] whether this is the last chunk of its message. def final? chunk_type == ChunkType::FINAL end + # @return [Boolean] whether another chunk follows this one. def intermediate? chunk_type == ChunkType::INTERMEDIATE end @@ -134,8 +159,10 @@ class MessageStream # header and the body of a message are each read under a fresh deadline. attr_reader :timeout - # @param sock [#get_once] the socket to read from. + # @param sock [#get_once] the socket to read from. Only + # get_once(length, timeout) is called on it. # @param timeout [Integer, Float] seconds allowed per read. + # @return [MessageStream] def initialize(sock, timeout: DEFAULT_TIMEOUT) @sock = sock @timeout = timeout @@ -147,11 +174,15 @@ def initialize(sock, timeout: DEFAULT_TIMEOUT) # # The deadline is monotonic rather than wall clock, so that a clock step # part way through a read cannot either cut it short or extend it - # indefinitely. + # indefinitely. Rex::Stopwatch.elapsed_time measures a completed block + # rather than exposing a remaining budget, so it does not fit a loop that + # has to shorten each successive read. # - # @param len [Integer] the number of bytes to read. - # @return [String] exactly len bytes. - # @raise [Error::TimeoutError] if the bytes did not arrive in time. + # @param len [Integer] the number of bytes to read. Zero or fewer reads + # nothing. + # @return [String] exactly len bytes, binary encoded. + # @raise [Rex::Proto::OpcUa::Error::TimeoutError] if the bytes did not + # arrive in time. def read_exact(len) return ''.b unless len.positive? @@ -181,8 +212,10 @@ def read_exact(len) # Read one framed message. # # @return [Message] the message type, chunk type and body. - # @raise [Error::TimeoutError] if the message did not arrive in time. - # @raise [Error::FramingError] if the declared size is unusable. + # @raise [Rex::Proto::OpcUa::Error::TimeoutError] if the message did not + # arrive in time. + # @raise [Rex::Proto::OpcUa::Error::FramingError] if the declared size is + # unusable. def recv_message header = MessageHeader.read(read_exact(HEADER_LEN)) size = header.message_size.snapshot @@ -203,12 +236,15 @@ def recv_message # stripped before concatenation. The returned buffer therefore starts at the # response TypeId, not at the SecureChannelId. # - # @return [String] the reassembled service payload. - # @raise [Error::ServerError] if the server answered with ERR. - # @raise [Error::AbortError] if the server abandoned the response. - # @raise [Error::FramingError] if the framing is unusable or the response - # ran past the chunk ceiling. - # @raise [Error::TimeoutError] if a chunk did not arrive in time. + # @return [String] the reassembled service payload, binary encoded. + # @raise [Rex::Proto::OpcUa::Error::ServerError] if the server answered with + # ERR. + # @raise [Rex::Proto::OpcUa::Error::AbortError] if the server abandoned the + # response. + # @raise [Rex::Proto::OpcUa::Error::FramingError] if the framing is unusable + # or the response ran past the chunk ceiling. + # @raise [Rex::Proto::OpcUa::Error::TimeoutError] if a chunk did not arrive + # in time. def recv_service_response payload = ''.b @@ -244,7 +280,7 @@ def recv_service_response # StatusCode is simply left unknown. # # @param body [String] the ERR message body. - # @return [Error::ServerError] + # @return [Rex::Proto::OpcUa::Error::ServerError] def server_error(body) err = ErrorMessage.read(body) Error::ServerError.new(status_code: err.status_code.snapshot, reason: err.reason.snapshot) diff --git a/lib/rex/proto/opc_ua/types.rb b/lib/rex/proto/opc_ua/types.rb index d5e8f39aa0edf..8adbabc7cbaee 100644 --- a/lib/rex/proto/opc_ua/types.rb +++ b/lib/rex/proto/opc_ua/types.rb @@ -1,12 +1,21 @@ # -*- coding: binary -*- +# frozen_string_literal: true require 'bindata' +# The OPC-UA built-in types, from OPC-UA Specification Part 6, section 5.2.2. +# +# The structured built-ins below were checked against +# reference/opcua/Opc.Ua.Types.bsd, the OPC Foundation's own machine-readable +# type definitions, which each one names. The length prefixed built-ins are +# described in Part 6 prose rather than in the schema, so they carry a section +# reference alone. module Rex::Proto::OpcUa::Types # OPC-UA encodes String and ByteString identically on the wire: a signed # Int32 length prefix followed by that many bytes. A length of -1 denotes a # null value, which the specification treats as distinct from a length of 0 - # denoting an empty value. See OPC-UA Specification Part 6, section 5.2.2. + # denoting an empty value. See OPC-UA Specification Part 6, sections 5.2.2.4 + # (String) and 5.2.2.7 (ByteString). # # BinData has no native type for a length prefix that doubles as a null # sentinel, so the read and write paths are implemented directly against @@ -28,6 +37,9 @@ class OpcUaByteString < BinData::BasePrimitive # BinData::Base#initialize skips #assign when constructed with nil, so # .new(nil) already yields a null; this makes the explicit assignment and # field setter paths agree with it. + # + # @param val [String, nil] the value, where nil is the null value. + # @return [String, nil] the assigned value. def assign(val) return @value = nil if val.nil? @@ -58,7 +70,8 @@ def sensible_default end end - # A String has the ByteString wire format with UTF-8 content. + # A String has the ByteString wire format with UTF-8 content. See OPC-UA + # Specification Part 6, section 5.2.2.4. # # The bytes arrive from an unauthenticated server, so invalid sequences are # scrubbed rather than raised on. Note that scrubbing is not the same as @@ -83,7 +96,10 @@ def read_and_return_value(io) # An array is an Int32 element count followed by that many elements. As with # String and ByteString a negative count denotes null, which the # specification treats as distinct from a count of zero denoting an empty - # array. See OPC-UA Specification Part 6, section 5.2.5. + # array. See OPC-UA Specification Part 6, section 5.2.5. The schema spells the + # same encoding out as a NoOf Int32 field followed by the elements it + # counts; see for instance the ResponseHeader StructuredType in + # reference/opcua/Opc.Ua.Types.bsd. # # The element type is supplied as the ordinary BinData :type parameter at the # declaration site, and :max_length caps how many elements will be read: @@ -109,6 +125,14 @@ class OpcUaArray < BinData::Array default_parameter max_length: DEFAULT_MAX_LENGTH + # Whether a freshly built array is null rather than empty. The two encode + # differently, so a request field that is required to be null needs to say + # so at its declaration site: building the record and leaving the field + # alone would otherwise send a count of 0 where the server was to be sent + # -1. BinData drops nil values when a record is built from a hash, so + # passing null in at construction is not an alternative. + default_parameter null_default: false + # BinData::Array selects its read strategy in #initialize_shared_instance # and installs it with #extend, which places it ahead of this class in the # singleton ancestry. Overriding #do_read as an ordinary instance method is @@ -117,14 +141,18 @@ class OpcUaArray < BinData::Array # nor :read_until was given, so InitialLengthPlugin is always installed, # and every read would return an empty array with nothing raised. # Extending after super is what puts the count prefix ahead of it. + # + # @return [void] def initialize_shared_instance super extend CountPrefixPlugin end + # @return [void] + # @return [void] def initialize_instance super - @null = false + @null = eval_parameter(:null_default) end # @return [Boolean] whether this array was read as, or assigned, null. @@ -134,6 +162,9 @@ def null? # BinData::Array#assign rejects nil, so null has to be taken here and # stored as an empty element list flagged as null. + # + # @param array [Array, nil] the elements, where nil is the null array. + # @return [Array] the assigned elements. def assign(array) @null = array.nil? super(@null ? [] : array) @@ -142,6 +173,10 @@ def assign(array) # The count prefix. This has to be a module extended onto the instance # rather than methods on the class; see #initialize_shared_instance. module CountPrefixPlugin + # @param io [BinData::IO::Read] the stream to read the count and elements + # from. + # @return [void] + # @raise [BinData::ValidityError] if the count exceeds :max_length. def do_read(io) count = io.readbytes(4).unpack1('l<') @element_list = [] @@ -161,11 +196,15 @@ def do_read(io) count.times { append_new_element.do_read(io) } end + # @param io [BinData::IO::Write] the stream to write the count and + # elements to. + # @return [void] def do_write(io) io.writebytes([@null ? NULL_LENGTH : length].pack('l<')) super unless @null end + # @return [Integer] the encoded length, the count prefix included. def do_num_bytes @null ? 4 : 4 + super end @@ -216,18 +255,29 @@ def to_time # NodeId, which names a node in a server's address space and is also how every # service names its own encoding. See OPC-UA Specification Part 6, - # section 5.2.2.9. + # section 5.2.2.9, whose Table 17 lists the six identifier forms as 0x00 to + # 0x05 and calls out NamespaceUri 0x80 and ServerIndex 0x40 separately, and + # section 5.2.2.10 for the ExpandedNodeId those two flags belong to. + # + # reference/opcua/Opc.Ua.Types.bsd agrees and adds the widths: + # + # NodeIdType is LengthInBits="6", and NodeId follows it with Reserved1 of + # Length="2". ExpandedNodeId replaces those two reserved bits with + # ServerIndexSpecified and then NamespaceURISpecified, which is bit 0x40 and + # bit 0x80 respectively, and appends NamespaceURI before ServerIndex. # - # The leading byte selects the identifier form in its low nibble. Bits 0x80 - # and 0x40 add a trailing NamespaceUri and ServerIndex; those belong to the - # ExpandedNodeId form, and are accepted here because the two forms are not - # distinguishable from the bytes alone, so a reader that rejected them would - # fail against a server that sends one where this expects a NodeId. + # The six identifier forms are the TwoByteNodeId, FourByteNodeId, + # NumericNodeId, StringNodeId, GuidNodeId and ByteStringNodeId + # StructuredTypes, whose field types and order are reproduced in the choice + # below. + # + # The ExpandedNodeId flags are accepted on a NodeId because the two forms are + # not distinguishable from the bytes alone, so a reader that rejected them + # would fail against a server that sends one where this expects a NodeId. # # Only the TwoByte and FourByte forms appear in the captures under - # spec/file_fixtures/opc_ua. The other four, and both flags, are carried over - # unchanged from the reader in the module this library replaces; their specs - # are hand-built and are marked as such. + # spec/file_fixtures/opc_ua; the other four and both flags have hand-built + # specs, marked as such. class OpcUaNodeId < BinData::Record endian :little @@ -239,8 +289,12 @@ class OpcUaNodeId < BinData::Record GUID = 0x04 BYTE_STRING = 0x05 - # Selects the identifier form. - FORM_MASK = 0x0F + # Selects the identifier form. Six bits wide, not four: the schema declares + # NodeIdType with LengthInBits="6" and gives a plain NodeId two reserved + # bits above it, which ExpandedNodeId replaces with the two flags below. + # Masking with 0x0F would silently read an encoding byte of 0x11 as the + # FourByte form instead of rejecting it. + FORM_MASK = 0x3F # Set when a NamespaceUri String follows the identifier. NAMESPACE_URI_FLAG = 0x80 # Set when a ServerIndex UInt32 follows. @@ -316,6 +370,20 @@ def identifier # and the body itself where there is one. See OPC-UA Specification Part 6, # section 5.2.2.15. # + # Table 25 in that section gives the fields as TypeId (NodeId), Encoding + # (Byte), Length (Int32) and Body, and states that a null ExtensionObject is a + # TypeId of i=0 with an Encoding of 0. That is exactly the three bytes 00 00 + # 00 that the captured OpenSecureChannelResponse carries as its + # AdditionalHeader, and the message accounts for its declared 135 bytes on + # that reading and on no other. + # + # The ExtensionObject StructuredType in reference/opcua/Opc.Ua.Types.bsd + # describes the same structure abstractly rather than as a byte layout, + # rendering the Encoding byte as a TypeIdSpecified, BinaryBody and XmlBody bit + # field and naming the body length separately. It is not a second, conflicting + # encoding; where the two are read differently, Table 25 is the one that + # describes the bytes. + # # Every ExtensionObject in the captures is the empty form, a null TypeId with # encoding 0x00, which is how an absent AdditionalHeader is sent. class OpcUaExtensionObject < BinData::Record @@ -343,14 +411,60 @@ class OpcUaExtensionObject < BinData::Record end end + # LocalizedText: a mask byte saying which of Locale and Text follow, then those + # that do. See OPC-UA Specification Part 6, section 5.2.2.14, and the + # LocalizedText StructuredType in reference/opcua/Opc.Ua.Types.bsd, which + # gives LocaleSpecified and TextSpecified as the low two bits followed by six + # reserved ones, and encodes Locale ahead of Text. + # + # Every LocalizedText in the captures under spec/file_fixtures/opc_ua has mask + # 0x03, both fields present. The three sparser masks have hand-built specs. + # + # A field left out by the mask reads as nil, which is the same as a String + # that was present but null, so the two are not distinguished here. The + # distinction does not survive into anything a scanner reports, and #text is + # what callers actually want. + class OpcUaLocalizedText < BinData::Record + endian :little + + # Set when a Locale String follows the mask. + LOCALE_FLAG = 0x01 + # Set when a Text String follows. + TEXT_FLAG = 0x02 + + uint8 :encoding_mask + opc_ua_string :locale, onlyif: -> { (encoding_mask & LOCALE_FLAG) != 0 } + opc_ua_string :text, onlyif: -> { (encoding_mask & TEXT_FLAG) != 0 } + + # @return [String, nil] the Text, or nil when the mask omitted it. This is + # the human readable half and the only one a scan report wants. + def to_s + text? ? text.snapshot.to_s : '' + end + + # @return [Boolean] whether the mask says a Locale is present. + def locale? + (encoding_mask.snapshot & LOCALE_FLAG) != 0 + end + + # @return [Boolean] whether the mask says a Text is present. + def text? + (encoding_mask.snapshot & TEXT_FLAG) != 0 + end + end + # DiagnosticInfo, in the only form this library will read: empty. See OPC-UA - # Specification Part 6, section 5.2.2.12. + # Specification Part 6, section 5.2.2.12, and the DiagnosticInfo + # StructuredType in reference/opcua/Opc.Ua.Types.bsd. # # Every request sent from here sets ReturnDiagnostics to zero, so a server # that returns a populated DiagnosticInfo has answered a question it was not - # asked. The seven optional fields it would then carry, one of them a nested - # DiagnosticInfo, cannot be exercised against any capture, so reading one - # raises rather than being decoded against a structure taken on trust. + # asked. The schema gives it seven presence bits and a reserved eighth, + # followed by the fields they select, the last of which is a nested + # DiagnosticInfo; note also that the schema orders the Locale field ahead of + # LocalizedText while their presence bits run the other way round. None of + # that can be exercised against any capture, so reading a populated one raises + # rather than being decoded against a structure with no coverage. class OpcUaDiagnosticInfo < BinData::BasePrimitive # The encoding mask of an empty DiagnosticInfo: no optional field present. EMPTY = 0x00 diff --git a/spec/lib/rex/proto/opc_ua/built_in_types_spec.rb b/spec/lib/rex/proto/opc_ua/built_in_types_spec.rb index dc1f9a9c99bdb..b9af650eab6f3 100644 --- a/spec/lib/rex/proto/opc_ua/built_in_types_spec.rb +++ b/spec/lib/rex/proto/opc_ua/built_in_types_spec.rb @@ -22,6 +22,15 @@ # The ServiceDiagnostics of the ResponseHeader. let(:service_diagnostics_offset) { 0x63 } + # The GetEndpoints response is the only capture carrying a LocalizedText. Its + # first endpoint's ApplicationName begins 151 bytes in; the offset was + # established by walking the response field by field, a walk that consumes the + # message exactly and which services_spec.rb asserts. + let(:get_endpoints_response) do + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'get_endpoints_response_node_opcua.bin')) + end + let(:application_name_offset) { 151 } + describe Rex::Proto::OpcUa::Types::OpcUaNodeId do describe 'the FourByte form, against the captured response TypeId' do subject(:node_id) { described_class.read(response[type_id_offset..]) } @@ -158,6 +167,25 @@ expect { described_class.read("\x06\x00\x00".b).num_bytes } .to raise_error(BinData::ValidityError, /encoding_byte/) end + + # The form field is six bits wide, not four. Masking with 0x0F would drop + # bits 4 and 5 rather than object to them, so an encoding byte of 0x11 would + # read as the FourByte form and go on to decode three bytes that mean + # nothing. Both masks agree on every byte a conforming server can send, so + # this reserved case is the only thing that distinguishes them. + it 'masks the identifier form with six bits rather than four' do + expect(described_class::FORM_MASK).to eq 0x3F + end + + it 'rejects an encoding byte with a reserved bit set above the form' do + expect { described_class.read("\x11\x00\xC1\x01".b).num_bytes } + .to raise_error(BinData::ValidityError, /encoding_byte/) + end + + it 'rejects a reserved bit even alongside a form it does know' do + expect { described_class.read("\x21\x00\xC1\x01".b).num_bytes } + .to raise_error(BinData::ValidityError, /encoding_byte/) + end end describe Rex::Proto::OpcUa::Types::OpcUaExtensionObject do @@ -208,6 +236,74 @@ end end + describe Rex::Proto::OpcUa::Types::OpcUaLocalizedText do + describe 'against the captured ApplicationName' do + subject(:localized_text) { described_class.read(get_endpoints_response[application_name_offset..]) } + + it 'decodes the Text' do + expect(localized_text.to_s).to eq 'NodeOPCUA' + end + + it 'decodes the Locale' do + expect(localized_text.locale.snapshot).to eq 'en-US' + end + + it 'decodes the mask as carrying both fields' do + expect(localized_text.encoding_mask.snapshot).to eq 0x03 + end + + # Locale is encoded ahead of Text. A reader with them the wrong way round + # consumes the same number of bytes and returns both values swapped, which + # is why the two are asserted separately rather than only by length. + it 'accounts for the mask and both strings' do + expect(localized_text.num_bytes).to eq 23 + end + + it 're-encodes to the captured bytes' do + expect(localized_text.to_binary_s).to eq get_endpoints_response.byteslice(application_name_offset, 23) + end + end + + # Every LocalizedText in the captures has mask 0x03. The sparser masks are + # hand-built from the LocalizedText StructuredType in + # reference/opcua/Opc.Ua.Types.bsd, and are recorded under Coverage limits + # in spec/file_fixtures/opc_ua/README.md. + describe 'the masks with no capture coverage' do + it 'decodes an empty LocalizedText as the mask alone' do + localized_text = described_class.read("\x00".b) + + expect(localized_text.num_bytes).to eq 1 + expect(localized_text.to_s).to eq '' + expect(localized_text).not_to be_text + end + + it 'decodes a Locale with no Text' do + localized_text = described_class.read("\x01".b + [2].pack('l<') + 'en') + + expect(localized_text.locale.snapshot).to eq 'en' + expect(localized_text).not_to be_text + expect(localized_text.num_bytes).to eq 7 + end + + # The common sparse case: a server that gives the text without saying what + # language it is in. The Text has to be read from the byte after the mask, + # not from where it would sit if a Locale had been present. + it 'decodes a Text with no Locale' do + localized_text = described_class.read("\x02".b + [3].pack('l<') + 'abc') + + expect(localized_text.to_s).to eq 'abc' + expect(localized_text).not_to be_locale + expect(localized_text.num_bytes).to eq 8 + end + + it 'round trips each mask' do + %W[\x00 \x01#{[2].pack('l<')}en \x02#{[3].pack('l<')}abc].each do |raw| + expect(described_class.read(raw.b).to_binary_s).to eq raw.b + end + end + end + end + describe Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo do describe 'against the captured ServiceDiagnostics' do subject(:diagnostics) { described_class.read(response[service_diagnostics_offset..]) } diff --git a/spec/lib/rex/proto/opc_ua/opc_ua_array_spec.rb b/spec/lib/rex/proto/opc_ua/opc_ua_array_spec.rb index 16898a2852cd5..2252bf44ed76d 100644 --- a/spec/lib/rex/proto/opc_ua/opc_ua_array_spec.rb +++ b/spec/lib/rex/proto/opc_ua/opc_ua_array_spec.rb @@ -1,25 +1,11 @@ # -*- coding: binary -*- require 'spec_helper' -# BinData resolves field types when a record's class body is evaluated, so the -# library types have to be registered before SpecUserTokenPolicy below is -# defined. Under Zeitwerk they would otherwise not load until first referenced. -require 'rex/proto/opc_ua/types' - -# A minimal stand-in for UserTokenPolicy (Part 4, section 7.37), defined here so -# that the array can be exercised against real elements from a capture before -# services.rb exists. Named so it cannot collide with the library type that will -# eventually replace it: BinData registers types by their unqualified class -# name, so a bare UserTokenPolicy here would claim the name globally. -class SpecUserTokenPolicy < BinData::Record - endian :little - - opc_ua_string :policy_id - uint32 :token_type - opc_ua_string :issued_token_type - opc_ua_string :issuer_endpoint_url - opc_ua_string :security_policy_uri -end +# BinData resolves field types when a record's class body is evaluated, so both +# the library types and the UserTokenPolicy the examples below read have to be +# registered first. Under Zeitwerk they would otherwise not load until first +# referenced. +require 'rex/proto/opc_ua/services' RSpec.describe Rex::Proto::OpcUa::Types::OpcUaArray do let(:null_binary) { [-1].pack('l<') } @@ -167,7 +153,7 @@ class SpecUserTokenPolicy < BinData::Record context 'the first endpoint, which advertises five token policies' do subject(:policies) do - described_class.read(response[five_token_offset..], type: :spec_user_token_policy) + described_class.read(response[five_token_offset..], type: :user_token_policy) end it 'decodes five elements' do @@ -202,7 +188,7 @@ class SpecUserTokenPolicy < BinData::Record context 'the second endpoint, which advertises three token policies' do subject(:policies) do - described_class.read(response[three_token_offset..], type: :spec_user_token_policy) + described_class.read(response[three_token_offset..], type: :user_token_policy) end it 'decodes three elements' do diff --git a/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb b/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb index 1f6e57fcbfd66..96ca20b6965be 100644 --- a/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb +++ b/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb @@ -120,45 +120,6 @@ end end - describe Rex::Proto::OpcUa::SecureChannel::ResponseHeader do - subject(:response_header) { described_class.read(response[response_body_offset..]) } - - it 'decodes the Timestamp' do - expect(response_header.timestamp.to_time).to eq ::Time.utc(2026, 8, 27, 0, 34, 59) + Rational(704, 1000) - end - - it 'decodes the RequestHandle' do - expect(response_header.request_handle.snapshot).to eq 1 - end - - # Zero is Good. A service can fail while the message carrying the failure is - # perfectly well formed, so this is the field that says whether the rest of - # the response means anything. - it 'decodes the ServiceResult' do - expect(response_header.service_result.snapshot).to eq 0 - end - - it 'decodes the ServiceDiagnostics as empty' do - expect(response_header.service_diagnostics.snapshot) - .to eq Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo::EMPTY - end - - it 'decodes the StringTable as empty' do - expect(response_header.string_table).to be_empty - end - - # The server sent a count of zero, not the -1 that would mean null, and the - # two re-encode differently. - it 'decodes the StringTable as empty rather than null' do - expect(response_header.string_table).not_to be_null - end - - it 'decodes the AdditionalHeader as an empty ExtensionObject' do - expect(response_header.additional_header.encoding.snapshot) - .to eq Rex::Proto::OpcUa::Types::OpcUaExtensionObject::NO_BODY - end - end - describe Rex::Proto::OpcUa::SecureChannel::OpenSecureChannelResponse do subject(:open_response) { described_class.read(response[response_body_offset..]) } @@ -308,7 +269,7 @@ it 'encodes to a RequestHeader alone' do expect(request.to_binary_s) - .to eq Rex::Proto::OpcUa::SecureChannel::RequestHeader + .to eq Rex::Proto::OpcUa::Services::RequestHeader .new(timestamp: 0, request_handle: 3, timeout_hint: 10_000).to_binary_s end diff --git a/spec/lib/rex/proto/opc_ua/services_spec.rb b/spec/lib/rex/proto/opc_ua/services_spec.rb new file mode 100644 index 0000000000000..1396c2660fa3d --- /dev/null +++ b/spec/lib/rex/proto/opc_ua/services_spec.rb @@ -0,0 +1,371 @@ +# -*- coding: binary -*- + +require 'spec_helper' +require 'rex/proto/opc_ua/services' + +RSpec.describe 'Rex::Proto::OpcUa::Services' do + # The headers here are exercised against the OpenSecureChannelResponse + # capture, which is the smallest captured message that carries a complete + # ResponseHeader. That the header belongs to a SecureChannel service is + # incidental: it is the same header every service uses, which is the reason it + # lives at this level. See spec/file_fixtures/opc_ua/README.md for provenance. + let(:response) do + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'open_secure_channel_response_node_opcua.bin')) + end + + # The ResponseHeader begins after the message header, the SecureChannelId, the + # asymmetric security header, the SequenceHeader and the TypeId. The offset + # was established by walking the response field by field; the walk consumes + # the message exactly, which secure_channel_spec.rb asserts. + let(:response_header_offset) { 0x53 } + + describe Rex::Proto::OpcUa::Services::ResponseHeader do + subject(:response_header) { described_class.read(response[response_header_offset..]) } + + it 'decodes the Timestamp' do + expect(response_header.timestamp.to_time).to eq ::Time.utc(2026, 8, 27, 0, 34, 59) + Rational(704, 1000) + end + + it 'decodes the RequestHandle' do + expect(response_header.request_handle.snapshot).to eq 1 + end + + # Zero is Good. A service can fail while the message carrying the failure is + # perfectly well formed, so this is the field that says whether the rest of + # the response means anything. + it 'decodes the ServiceResult' do + expect(response_header.service_result.snapshot).to eq 0 + end + + it 'decodes the ServiceDiagnostics as empty' do + expect(response_header.service_diagnostics.snapshot) + .to eq Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo::EMPTY + end + + it 'decodes the StringTable as empty' do + expect(response_header.string_table).to be_empty + end + + # The server sent a count of zero, not the -1 that would mean null, and the + # two re-encode differently. + it 'decodes the StringTable as empty rather than null' do + expect(response_header.string_table).not_to be_null + end + + it 'decodes the AdditionalHeader as an empty ExtensionObject' do + expect(response_header.additional_header.encoding.snapshot) + .to eq Rex::Proto::OpcUa::Types::OpcUaExtensionObject::NO_BODY + end + + it 'accounts for the bytes between the TypeId and the ServerProtocolVersion' do + expect(response_header.num_bytes).to eq 24 + end + + it 're-encodes to the captured bytes' do + expect(response_header.to_binary_s).to eq response.byteslice(response_header_offset, 24) + end + end + + describe Rex::Proto::OpcUa::Services::RequestHeader do + # No capture contains a request; every message under + # spec/file_fixtures/opc_ua is a server response. The expected bytes are + # hand-built from the RequestHeader StructuredType in + # reference/opcua/Opc.Ua.Types.bsd, and are what the shipped module builds + # in build_request_header. + subject(:request_header) do + described_class.new(timestamp: 0, request_handle: 2, return_diagnostics: 0, timeout_hint: 10_000) + end + + let(:expected) do + [0x00, 0x00].pack('CC') + # AuthenticationToken: null NodeId + [0].pack('q<') + # Timestamp + [2].pack('V') + # RequestHandle + [0].pack('V') + # ReturnDiagnostics: none + [-1].pack('l<') + # AuditEntryId: null + [10_000].pack('V') + # TimeoutHint in milliseconds + [0x00, 0x00, 0x00].pack('CCC') # AdditionalHeader: null ExtensionObject + end + + it 'encodes its seven fields in schema order' do + expect(request_header.to_binary_s).to eq expected + end + + # A sessionless request carries the null NodeId as its AuthenticationToken, + # which is what GetEndpoints and OpenSecureChannel both send. Getting that + # from the default is what lets a caller build one without naming it. + it 'defaults the AuthenticationToken to the null NodeId' do + expect(request_header.authentication_token.to_binary_s).to eq "\x00\x00".b + end + + it 'defaults the AuditEntryId to null rather than empty' do + expect(request_header.audit_entry_id.snapshot).to be_nil + end + + it 'round trips' do + expect(described_class.read(request_header.to_binary_s).snapshot).to eq request_header.snapshot + end + end + + # The GetEndpoints capture: a MSG message whose single F chunk carries the + # response. See spec/file_fixtures/opc_ua/README.md for provenance. + let(:get_endpoints) do + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', 'get_endpoints_response_node_opcua.bin')) + end + + # What Rex::Proto::OpcUa::Tcp::MessageStream#recv_service_response hands back: + # the message with its header and the secure conversation prefix removed, so + # it begins at the response TypeId. + let(:service_payload) do + get_endpoints[(Rex::Proto::OpcUa::Tcp::HEADER_LEN + Rex::Proto::OpcUa::Tcp::SECURE_MSG_PREFIX_LEN)..] + end + + let(:type_id) { Rex::Proto::OpcUa::Types::OpcUaNodeId.read(service_payload) } + let(:get_endpoints_response) do + Rex::Proto::OpcUa::Services::GetEndpointsResponse.read(service_payload[type_id.num_bytes..]) + end + let(:endpoints) { get_endpoints_response.endpoints } + + describe 'the array ceilings' do + # Carried over unchanged from the module this library replaces. + it 'caps the endpoint array at 64' do + expect(Rex::Proto::OpcUa::Services::MAX_ENDPOINTS).to eq 64 + end + + # The endpoint ceiling alone does not bound allocation, because each + # endpoint carries arrays of its own. These are what stop the product + # running away. + it 'caps the user token array below the OpcUaArray default' do + expect(Rex::Proto::OpcUa::Services::MAX_USER_TOKENS) + .to be < Rex::Proto::OpcUa::Types::OpcUaArray::DEFAULT_MAX_LENGTH + end + + it 'caps the discovery url array below the OpcUaArray default' do + expect(Rex::Proto::OpcUa::Services::MAX_DISCOVERY_URLS) + .to be < Rex::Proto::OpcUa::Types::OpcUaArray::DEFAULT_MAX_LENGTH + end + + # Patching the count in the captured bytes is what proves the declaration + # site carries the ceiling, rather than that OpcUaArray can enforce one. + it 'rejects an endpoint count above the ceiling before reading any element' do + body = get_endpoints_response.to_binary_s.dup + body[get_endpoints_response.endpoints.rel_offset, 4] = + [Rex::Proto::OpcUa::Services::MAX_ENDPOINTS + 1].pack('l<') + + expect { Rex::Proto::OpcUa::Services::GetEndpointsResponse.read(body).num_bytes } + .to raise_error(BinData::ValidityError, /array length 65 exceeds the 64 element ceiling/) + end + + it 'rejects a user token count above the ceiling' do + endpoint = endpoints.first + body = endpoint.to_binary_s.dup + body[endpoint.user_identity_tokens.rel_offset, 4] = + [Rex::Proto::OpcUa::Services::MAX_USER_TOKENS + 1].pack('l<') + + expect { Rex::Proto::OpcUa::Services::EndpointDescription.read(body).num_bytes } + .to raise_error(BinData::ValidityError, /exceeds the 64 element ceiling for obj.user_identity_tokens/) + end + end + + describe Rex::Proto::OpcUa::Services::GetEndpointsResponse do + it 'is identified by the response TypeId that precedes it' do + expect(type_id.identifier).to eq Rex::Proto::OpcUa::Enums::NodeIds::GET_ENDPOINTS_RESPONSE + end + + it 'decodes a successful ServiceResult' do + expect(get_endpoints_response.response_header.service_result.snapshot).to eq 0 + end + + it 'decodes seven endpoints' do + expect(endpoints.length).to eq 7 + end + + # The first endpoint advertises five token policies and the rest three. A + # reader that lost its place inside one endpoint would still produce seven + # of something, so the per-endpoint counts are what show the walk stayed + # aligned across the whole array. + it 'decodes the token policy count of every endpoint' do + expect(endpoints.map { |ep| ep.user_identity_tokens.length }).to eq [5, 3, 3, 3, 3, 3, 3] + end + + # This is the assertion the whole record layer exists to support: the + # response is consumed exactly, so no field was skipped, none was counted + # twice, and nothing was left dangling for the next read to trip over. + it 'consumes the service payload with no bytes left over' do + expect(type_id.num_bytes + get_endpoints_response.num_bytes).to eq service_payload.bytesize + end + + it 're-encodes byte for byte to the captured payload' do + expect(type_id.to_binary_s + get_endpoints_response.to_binary_s).to eq service_payload + end + + it 'accounts for the whole 10648 byte capture once the framing is counted' do + framing = Rex::Proto::OpcUa::Tcp::HEADER_LEN + Rex::Proto::OpcUa::Tcp::SECURE_MSG_PREFIX_LEN + expect(framing + type_id.num_bytes + get_endpoints_response.num_bytes).to eq 10_648 + end + end + + describe Rex::Proto::OpcUa::Services::EndpointDescription do + subject(:endpoint) { endpoints.first } + + it 'decodes the EndpointUrl' do + expect(endpoint.endpoint_url.snapshot).to eq 'opc.tcp://ua-node:4840/UA/BackdraftTest' + end + + # Asserted across the whole array rather than for one endpoint: seven + # distinct policies in the right order is what shows the walk stayed + # aligned through seven variable length records, six of which carry a + # server certificate of over a kilobyte. + it 'decodes the SecurityPolicyUri of every endpoint' do + expect(endpoints.map { |ep| Rex::Proto::OpcUa::Enums.security_policy_name(ep.security_policy_uri.snapshot) }) + .to eq %w[ + None + Basic256Sha256 + Aes128_Sha256_RsaOaep + Aes256_Sha256_RsaPss + Basic256Sha256 + Aes128_Sha256_RsaOaep + Aes256_Sha256_RsaPss + ] + end + + it 'decodes the MessageSecurityMode of every endpoint' do + expect(endpoints.map { |ep| Rex::Proto::OpcUa::Enums.security_mode_name(ep.security_mode.snapshot) }) + .to eq %w[None Sign Sign Sign SignAndEncrypt SignAndEncrypt SignAndEncrypt] + end + + it 'decodes the ServerCertificate as a ByteString rather than a String' do + expect(endpoint.server_certificate.snapshot.encoding).to eq ::Encoding::BINARY + end + + # SecurityLevel is the last field of the record, a single byte after a + # length prefixed TransportProfileUri. Reading the right value for every + # endpoint is what shows each record ended where the next one began. + it 'decodes the SecurityLevel that follows the TransportProfileUri' do + expect(endpoints.map { |ep| ep.security_level.snapshot }).to eq [1, 106, 105, 107, 206, 205, 207] + end + + # The None/Anonymous endpoint is the finding this whole scanner exists to + # report: no encryption on the channel and no credential required. + it 'includes an endpoint offering MessageSecurityMode None' do + none = endpoints.select { |ep| Rex::Proto::OpcUa::Enums.security_mode_name(ep.security_mode.snapshot) == 'None' } + + expect(none).not_to be_empty + end + + it 'includes an endpoint accepting the Anonymous token over an unencrypted channel' do + weak = endpoints.select do |ep| + Rex::Proto::OpcUa::Enums.security_mode_name(ep.security_mode.snapshot) == 'None' && + ep.user_identity_tokens.any? { |token| token.token_type.snapshot.zero? } + end + + expect(weak).not_to be_empty + end + end + + describe Rex::Proto::OpcUa::Services::ApplicationDescription do + subject(:server) { endpoints.first.server } + + it 'decodes the ApplicationUri' do + expect(server.application_uri.snapshot).to eq 'urn:ua-node:NodeOPCUA-Server' + end + + it 'decodes the ProductUri' do + expect(server.product_uri.snapshot).to eq 'NodeOPCUA-Server' + end + + it 'decodes the ApplicationName as a LocalizedText' do + expect(server.application_name.to_s).to eq 'NodeOPCUA' + end + + # Server, from the ApplicationType enumeration in the schema. + it 'decodes the ApplicationType' do + expect(server.application_type.snapshot).to eq 0 + end + + it 'decodes the DiscoveryUrls array' do + expect(server.discovery_urls.map(&:snapshot)).to all(be_a(String)) + end + end + + describe Rex::Proto::OpcUa::Services::UserTokenPolicy do + subject(:policies) { endpoints.first.user_identity_tokens } + + it 'decodes each PolicyId rather than only counting the elements' do + expect(policies.map { |policy| policy.policy_id.snapshot }).to eq %w[ + username_basic256Sha256 + username_aes128Sha256RsaOaep + certificate_basic256Sha256 + certificate_aes128Sha256RsaOaep + anonymous + ] + end + + it 'decodes the TokenTypes' do + expect(policies.map { |policy| policy.token_type.snapshot }).to eq [1, 1, 2, 2, 0] + end + + # Every policy here leaves both of these null, which covers a null String + # nested inside an array element inside another array element. + it 'decodes the null fields within its elements' do + expect(policies.map { |policy| policy.issued_token_type.snapshot }).to all(be_nil) + expect(policies.map { |policy| policy.issuer_endpoint_url.snapshot }).to all(be_nil) + end + + # The per token SecurityPolicyUri is not the endpoint's own. This endpoint + # is None/None, yet four of its five policies demand a real policy for the + # credential itself; only the anonymous one, which has no credential to + # protect, leaves the field null. + it 'decodes the per token SecurityPolicyUri' do + expect(policies.map { |policy| policy.security_policy_uri.snapshot }) + .to eq [ + 'http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256', + 'http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep', + 'http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256', + 'http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep', + nil + ] + end + end + + describe Rex::Proto::OpcUa::Services::GetEndpointsRequest do + # No capture contains a request. The expected bytes are hand-built from the + # GetEndpointsRequest StructuredType in reference/opcua/Opc.Ua.Types.bsd, + # and are what the shipped module builds in build_get_endpoints. + subject(:request) do + described_class.new( + request_header: { timestamp: 0, request_handle: 2, timeout_hint: 10_000 }, + endpoint_url: 'opc.tcp://192.0.2.1:4840' + ) + end + + let(:url) { 'opc.tcp://192.0.2.1:4840' } + + it 'encodes the EndpointUrl after the RequestHeader' do + header_length = Rex::Proto::OpcUa::Services::RequestHeader + .new(timestamp: 0, request_handle: 2, timeout_hint: 10_000).num_bytes + + expect(request.to_binary_s.byteslice(header_length, 4 + url.bytesize)) + .to eq [url.bytesize].pack('l<') + url + end + + # Both filters are sent null, which asks for every endpoint. Null and empty + # encode differently, and this comes from the declaration rather than from + # the caller: BinData drops nil values when a record is built from a hash, + # so a record that only accepted null by assignment would quietly send an + # empty array to any caller that named neither field. + it 'encodes both filters as null arrays without being told to' do + expect(request.to_binary_s.byteslice(-8, 8)).to eq [-1, -1].pack('l<2') + end + + it 'still reads an empty filter back as empty rather than null' do + empty = request.to_binary_s.dup + empty[-8, 8] = [0, 0].pack('l<2') + + expect(described_class.read(empty).locale_ids).not_to be_null + end + + it 'round trips' do + expect(described_class.read(request.to_binary_s).snapshot).to eq request.snapshot + end + end +end From 4b8cea11fecd21c7261f5898c28796dc5a0616e9 Mon Sep 17 00:00:00 2001 From: ethan-thomason Date: Fri, 28 Aug 2026 09:28:21 -0700 Subject: [PATCH 6/8] Add OPC-UA service records and verify spec citations --- lib/rex/proto/opc_ua/enums.rb | 18 ++++-- lib/rex/proto/opc_ua/error.rb | 77 ++++++++++++++++++-------- lib/rex/proto/opc_ua/secure_channel.rb | 28 ++++++---- lib/rex/proto/opc_ua/services.rb | 35 ++++++------ lib/rex/proto/opc_ua/tcp.rb | 67 +++++++++++++++++----- spec/lib/rex/proto/opc_ua/tcp_spec.rb | 68 ++++++++++++++++++++++- 6 files changed, 219 insertions(+), 74 deletions(-) diff --git a/lib/rex/proto/opc_ua/enums.rb b/lib/rex/proto/opc_ua/enums.rb index 6af01f298aa7c..ea345cb4d117a 100644 --- a/lib/rex/proto/opc_ua/enums.rb +++ b/lib/rex/proto/opc_ua/enums.rb @@ -41,8 +41,8 @@ module NodeIds GET_ENDPOINTS_RESPONSE = 431 end - # MessageSecurityMode (Part 4, section 7.15), matching the enumeration of the - # same name in reference/opcua/Opc.Ua.Types.bsd. + # MessageSecurityMode (Part 4, section 7.20, Table 139), matching the + # enumeration of the same name in reference/opcua/Opc.Ua.Types.bsd. SECURITY_MODES = { 0 => 'Invalid', 1 => 'None', @@ -50,8 +50,8 @@ module NodeIds 3 => 'SignAndEncrypt' }.freeze - # UserTokenType (Part 4, section 7.36), matching the enumeration of the same - # name in reference/opcua/Opc.Ua.Types.bsd. + # UserTokenType (Part 4, section 7.42, Table 193), matching the enumeration + # of the same name in reference/opcua/Opc.Ua.Types.bsd. TOKEN_TYPES = { 0 => 'Anonymous', 1 => 'UserName', @@ -62,7 +62,10 @@ module NodeIds # StatusCodes that may appear in an ERR response from the UA TCP transport, # or as the ServiceResult of a service that failed at the security layer. STATUS_CODES = { - # Transport specific errors (Part 6, section 7.1.2) + # The Connection Protocol error codes of Table 79 in OPC-UA Specification + # Part 6, section 7.1.5. That table names the codes; their numeric values + # are in Part 6 Annex A.2, and every one below was checked against + # reference/opcua/StatusCode.csv. 0x807D0000 => 'Bad_TcpServerTooBusy', 0x807E0000 => 'Bad_TcpMessageTypeInvalid', 0x807F0000 => 'Bad_TcpSecureChannelUnknown', @@ -70,7 +73,10 @@ module NodeIds 0x80810000 => 'Bad_TcpNotEnoughResources', 0x80820000 => 'Bad_TcpInternalError', 0x80830000 => 'Bad_TcpEndpointUrlInvalid', - # Connection and security errors also seen at the transport layer + # Not in Table 79, but seen at this layer all the same. + # Bad_ProtocolVersionUnsupported is named in the Hello Message text of + # section 7.1.2.3; the rest arrive as the ServiceResult of a service that + # failed at the security layer. 0x80BE0000 => 'Bad_ProtocolVersionUnsupported', 0x80130000 => 'Bad_SecurityChecksFailed', 0x80120000 => 'Bad_CertificateInvalid', diff --git a/lib/rex/proto/opc_ua/error.rb b/lib/rex/proto/opc_ua/error.rb index ccd36cb663cff..df61590ea4778 100644 --- a/lib/rex/proto/opc_ua/error.rb +++ b/lib/rex/proto/opc_ua/error.rb @@ -34,31 +34,28 @@ class TimeoutError < OpcUaError; end # ceiling. See OPC-UA Specification Part 6, section 7.1. class FramingError < OpcUaError; end - # Raised when the server abandons a response part way through by sending a - # chunk of type A, per OPC-UA Specification Part 6, section 6.7.2. The - # response cannot be completed, but the connection itself is intact and the - # server is behaving to specification. - class AbortError < OpcUaError; end - - # Raised when the server answers with an ERR message, per OPC-UA - # Specification Part 6, section 7.1.2.5. This is a report from the server - # rather than a fault in reading it, and the StatusCode it carries is the - # useful part, so it is kept as a field rather than only interpolated into the - # message. - class ServerError < OpcUaError - # @return [Integer, nil] the StatusCode from the ERR message, or nil when - # the body could not be decoded. + # Base of the two errors that carry a StatusCode and a Reason the server put + # on the wire. Both bodies have the same two fields in the same order: an ERR + # message body is Table 76 of OPC-UA Specification Part 6, section 7.1.2.5, + # and an abort chunk body is Table 63 of section 6.7.3. + # + # These are reports from the server rather than faults in reading it, and the + # StatusCode is the useful part, so it is kept as a field rather than only + # interpolated into the message. + class StatusReportError < OpcUaError + # @return [Integer, nil] the StatusCode the server sent, or nil when the + # body could not be decoded. attr_reader :status_code - # @return [String, nil] the Reason from the ERR message. Servers routinely - # leave this null. + # @return [String, nil] the Reason the server sent. Servers routinely leave + # this null. attr_reader :reason - # @param status_code [Integer, nil] the StatusCode from the ERR message. - # @param reason [String, nil] the Reason from the ERR message. An empty - # reason is stored as nil, since the two say the same thing. + # @param status_code [Integer, nil] the StatusCode from the body. + # @param reason [String, nil] the Reason from the body. An empty reason is + # stored as nil, since the two say the same thing. # @param msg [String, nil] overrides the generated message. - # @return [ServerError] + # @return [StatusReportError] def initialize(status_code: nil, reason: nil, msg: nil) @status_code = status_code @reason = reason.to_s.empty? ? nil : reason.to_s @@ -68,11 +65,45 @@ def initialize(status_code: nil, reason: nil, msg: nil) private - # @return [String] the StatusCode by name where it is one the enumeration - # carries, with the Reason appended when the server supplied one. + # @return [String] what happened, then the StatusCode by name where it is + # one the enumeration carries, with the Reason appended when the server + # supplied one. def generate_message name = status_code.nil? ? 'an undecodable status' : Rex::Proto::OpcUa::Enums.status_code_name(status_code) - reason.nil? ? "server returned ERR: #{name}" : "server returned ERR: #{name} - #{reason}" + reason.nil? ? "#{summary}: #{name}" : "#{summary}: #{name} - #{reason}" + end + + # @return [String] the leading clause of the generated message. + def summary + raise ::NotImplementedError, "#{self.class} must supply a summary" + end + end + + # Raised when the server abandons a response part way through by sending a + # chunk of type A, per OPC-UA Specification Part 6, section 6.7.3. The + # response cannot be completed, but the connection itself is intact and the + # server is behaving to specification. + # + # The chunk carries the same StatusCode and Reason an ERR message would; + # Table 63 in that section gives the body as an Error UInt32 followed by a + # Reason String. No capture under spec/file_fixtures/opc_ua contains an abort, + # so that decode is specified rather than observed, and a body that will not + # decode leaves both fields nil rather than failing the abort report. + class AbortError < StatusReportError + private + + def summary + 'server aborted the response' + end + end + + # Raised when the server answers with an ERR message, per OPC-UA + # Specification Part 6, section 7.1.2.5. + class ServerError < StatusReportError + private + + def summary + 'server returned ERR' end end end diff --git a/lib/rex/proto/opc_ua/secure_channel.rb b/lib/rex/proto/opc_ua/secure_channel.rb index 864610653ba23..6769b173d109b 100644 --- a/lib/rex/proto/opc_ua/secure_channel.rb +++ b/lib/rex/proto/opc_ua/secure_channel.rb @@ -29,8 +29,8 @@ module Rex::Proto::OpcUa::SecureChannel # The security header of an OPN message, carrying the policy the channel is # being opened under and the certificates that policy needs. # - # See OPC-UA Specification Part 6, section 6.7.2, which names the three fields - # of the AsymmetricAlgorithmSecurityHeader in this order. It is not a + # See Table 58 in OPC-UA Specification Part 6, section 6.7.2.3, which names + # the three fields in this order, each as a length prefixed pair. It is not a # StructuredType in reference/opcua/Opc.Ua.Types.bsd, which describes the # service structures rather than the channel framing that carries them. # @@ -48,9 +48,12 @@ class AsymmetricSecurityHeader < BinData::Record # The security header of every message sent on an open channel, naming the # token the message is secured with. This is the whole of it: a single UInt32. # - # See OPC-UA Specification Part 6, section 6.7.2, for the - # SymmetricAlgorithmSecurityHeader. Like the asymmetric header above it is - # channel framing rather than a StructuredType in the schema. + # Defined in OPC-UA Specification Part 6, section 6.7.2.3, the same section as + # the asymmetric header above; the two are alternatives chosen by the type of + # security applied to the message. Its table follows Table 58 there and is the + # one uncaptioned table in the section, so it is cited by section rather than + # by number. Like the asymmetric header, this is channel framing rather than a + # StructuredType in the schema. # # A MSG chunk carries the SecureChannelId, then this, then a SequenceHeader # ahead of its slice of the payload, which is the 16 bytes that @@ -64,7 +67,7 @@ class SymmetricSecurityHeader < BinData::Record # Follows the security header of every message. The RequestId is what pairs a # response with the request that asked for it. # - # See OPC-UA Specification Part 6, section 6.7.2, for the SequenceHeader. + # See Table 60 in OPC-UA Specification Part 6, section 6.7.2.4. class SequenceHeader < BinData::Record endian :little @@ -78,8 +81,11 @@ class SequenceHeader < BinData::Record # answer to the lifetime the client asked for rather than the client's request # granted. # - # See OPC-UA Specification Part 4, section 7.6, and the ChannelSecurityToken - # StructuredType in reference/opcua/Opc.Ua.Types.bsd. + # ChannelSecurityToken has no section of its own in OPC-UA Specification + # Part 4: it is defined inline among the OpenSecureChannel response parameters + # in section 5.6.2.2. See also the ChannelSecurityToken StructuredType in + # reference/opcua/Opc.Ua.Types.bsd, which gives the four fields in this + # order. class ChannelSecurityToken < BinData::Record endian :little @@ -91,7 +97,7 @@ class ChannelSecurityToken < BinData::Record # OpenSecureChannelRequest. # - # See OPC-UA Specification Part 4, section 5.5.2, and the + # See OPC-UA Specification Part 4, section 5.6.2, and the # OpenSecureChannelRequest StructuredType in # reference/opcua/Opc.Ua.Types.bsd. # @@ -118,7 +124,7 @@ class OpenSecureChannelRequest < BinData::Record # OpenSecureChannelResponse. # - # See OPC-UA Specification Part 4, section 5.5.2, and the + # See OPC-UA Specification Part 4, section 5.6.2, and the # OpenSecureChannelResponse StructuredType in # reference/opcua/Opc.Ua.Types.bsd. # @@ -137,7 +143,7 @@ class OpenSecureChannelResponse < BinData::Record # CloseSecureChannelRequest. The channel being closed is the one the message # is sent on, so the request carries nothing beyond its header. # - # See OPC-UA Specification Part 4, section 5.5.3, and the + # See OPC-UA Specification Part 4, section 5.6.3, and the # CloseSecureChannelRequest StructuredType in # reference/opcua/Opc.Ua.Types.bsd, which is a RequestHeader and nothing else. class CloseSecureChannelRequest < BinData::Record diff --git a/lib/rex/proto/opc_ua/services.rb b/lib/rex/proto/opc_ua/services.rb index 73e28fd139029..08ea2e569b146 100644 --- a/lib/rex/proto/opc_ua/services.rb +++ b/lib/rex/proto/opc_ua/services.rb @@ -21,9 +21,9 @@ module Rex::Proto::OpcUa::Services # The header every service request opens with. # - # See OPC-UA Specification Part 4, section 7.28, and the RequestHeader - # StructuredType in reference/opcua/Opc.Ua.Types.bsd, which gives the seven - # fields below in this order. + # See OPC-UA Specification Part 4, section 7.32, Table 171, and the + # RequestHeader StructuredType in reference/opcua/Opc.Ua.Types.bsd, which + # gives the seven fields below in this order. # # ReturnDiagnostics is sent as zero throughout this library, which is what # entitles Rex::Proto::OpcUa::Types::OpcUaDiagnosticInfo to model only the @@ -45,8 +45,9 @@ class RequestHeader < BinData::Record # The header every service response opens with. # - # See OPC-UA Specification Part 4, section 7.29, and the ResponseHeader - # StructuredType in reference/opcua/Opc.Ua.Types.bsd. The schema splits the + # See OPC-UA Specification Part 4, section 7.33, Table 172, and the + # ResponseHeader StructuredType in reference/opcua/Opc.Ua.Types.bsd. The + # schema splits the # StringTable into a NoOfStringTable Int32 and the elements it counts, which # is the ordinary OPC-UA array encoding that # Rex::Proto::OpcUa::Types::OpcUaArray implements. @@ -83,8 +84,8 @@ class ResponseHeader < BinData::Record MAX_DISCOVERY_URLS = 64 # ApplicationDescription, the server's description of itself. See OPC-UA - # Specification Part 4, section 7.2, and the ApplicationDescription - # StructuredType in reference/opcua/Opc.Ua.Types.bsd. + # Specification Part 4, section 7.2, Table 109, and the + # ApplicationDescription StructuredType in reference/opcua/Opc.Ua.Types.bsd. # # ApplicationUri and ProductUri are the fingerprint worth reporting: they name # the product and installation rather than the host that answered. @@ -94,8 +95,9 @@ class ApplicationDescription < BinData::Record opc_ua_string :application_uri opc_ua_string :product_uri opc_ua_localized_text :application_name - # ApplicationType: Server 0, Client 1, ClientAndServer 2, DiscoveryServer 3, - # from the enumeration of that name in reference/opcua/Opc.Ua.Types.bsd. + # ApplicationType: Server 0, Client 1, ClientAndServer 2, DiscoveryServer 3. + # See OPC-UA Specification Part 4, section 7.4, Table 111, and the + # enumeration of that name in reference/opcua/Opc.Ua.Types.bsd. uint32 :application_type opc_ua_string :gateway_server_uri opc_ua_string :discovery_profile_uri @@ -103,8 +105,8 @@ class ApplicationDescription < BinData::Record end # UserTokenPolicy, one way of proving identity that an endpoint will accept. - # See OPC-UA Specification Part 4, section 7.37, and the UserTokenPolicy - # StructuredType in reference/opcua/Opc.Ua.Types.bsd. + # See OPC-UA Specification Part 4, section 7.41, Table 192, and the + # UserTokenPolicy StructuredType in reference/opcua/Opc.Ua.Types.bsd. # # TokenType is a UserTokenType; see Rex::Proto::OpcUa::Enums::TOKEN_TYPES. A # policy of type Anonymous is the one that makes an endpoint reachable without @@ -123,7 +125,7 @@ class UserTokenPolicy < BinData::Record end # EndpointDescription, one way of connecting to a server. See OPC-UA - # Specification Part 4, section 7.10, and the EndpointDescription + # Specification Part 4, section 7.14, Table 135, and the EndpointDescription # StructuredType in reference/opcua/Opc.Ua.Types.bsd. # # SecurityMode is a MessageSecurityMode; see @@ -144,9 +146,9 @@ class EndpointDescription < BinData::Record uint8 :security_level end - # GetEndpointsRequest. See the GetEndpointsRequest StructuredType in - # reference/opcua/Opc.Ua.Types.bsd, and the GetEndpoints service in OPC-UA - # Specification Part 4. + # GetEndpointsRequest. See the GetEndpoints service in OPC-UA Specification + # Part 4, section 5.5.4, and the GetEndpointsRequest StructuredType in + # reference/opcua/Opc.Ua.Types.bsd. # # The specification requires this service to be available without # authentication, so that a client can discover how it is expected to connect @@ -165,7 +167,8 @@ class GetEndpointsRequest < BinData::Record opc_ua_array :profile_uris, type: :opc_ua_string, null_default: true end - # GetEndpointsResponse. See the GetEndpointsResponse StructuredType in + # GetEndpointsResponse. See OPC-UA Specification Part 4, section 5.5.4, and + # the GetEndpointsResponse StructuredType in # reference/opcua/Opc.Ua.Types.bsd. # # The endpoint array carries an explicit ceiling rather than the OpcUaArray diff --git a/lib/rex/proto/opc_ua/tcp.rb b/lib/rex/proto/opc_ua/tcp.rb index 28d5ee0774390..b5ca0a40d89c1 100644 --- a/lib/rex/proto/opc_ua/tcp.rb +++ b/lib/rex/proto/opc_ua/tcp.rb @@ -44,11 +44,19 @@ module Rex::Proto::OpcUa::Tcp MAX_MESSAGE_SIZE = 4 * 1024 * 1024 MAX_CHUNKS = 64 - # MessageType values, each three ASCII bytes, from OPC-UA Specification - # Part 6, section 7.1.2, which defines the messages named below in its - # subsections 7.1.2.2 to 7.1.2.5. These are the types this transport exchanges; every - # one of them appears in the captures under spec/file_fixtures/opc_ua or is - # sent to produce them. + # MessageType values, each three ASCII bytes. These come from two layers, and + # the specification keeps them in two places: + # + # HEL, ACK and ERR belong to the Connection Protocol and are listed in + # Table 73 of OPC-UA Specification Part 6, section 7.1.2.2, alongside the + # RHE this library does not use. + # + # MSG, OPN and CLO belong to the Secure Conversation layer and are listed in + # Table 57 of section 6.7.2.2. Table 73 accounts for them only as the + # additional values the Connection Protocol layer shall accept. + # + # Every one of the six appears in the captures under spec/file_fixtures/opc_ua + # or is sent to produce them. module MessageType HELLO = 'HEL' ACKNOWLEDGE = 'ACK' @@ -58,8 +66,13 @@ module MessageType MESSAGE = 'MSG' end - # ChunkType values, one ASCII byte, from OPC-UA Specification Part 6, - # section 6.7.2. A message that fits in one chunk is sent as a single F. + # ChunkType values, one ASCII byte. This is the IsFinal field of Table 57 in + # OPC-UA Specification Part 6, section 6.7.2.2, which notes that it is only + # meaningful for a MessageType of MSG and is always F for the others. Part 6 + # calls the same byte Reserved at the Connection Protocol layer, in Table 73 + # of section 7.1.2.2, where it is likewise always F. + # + # A message that fits in one chunk is sent as a single F. module ChunkType # More chunks follow this one. INTERMEDIATE = 'C' @@ -255,7 +268,7 @@ def recv_service_response unless msg.message_type == MessageType::MESSAGE raise Error::FramingError, "unexpected message type #{msg.message_type.inspect}" end - raise Error::AbortError, 'server aborted the response' if msg.abort? + raise abort_error(msg.body) if msg.abort? raise Error::FramingError, "unknown chunk type #{msg.chunk_type.inspect}" unless msg.final? || msg.intermediate? if msg.body.bytesize < SECURE_MSG_PREFIX_LEN @@ -274,18 +287,42 @@ def recv_service_response # Build the exception for an ERR message. # - # An ERR arrives only once the server has decided the connection is - # unusable, so a body that will not decode is still reported as the failure - # it is rather than being replaced by a complaint about the decode; the - # StatusCode is simply left unknown. - # # @param body [String] the ERR message body. # @return [Rex::Proto::OpcUa::Error::ServerError] def server_error(body) + Error::ServerError.new(**status_and_reason(body)) + end + + # Build the exception for an aborted response. + # + # An abort chunk is a MSG chunk, so its body opens with the secure + # conversation prefix and the Table 63 fields follow it. A chunk too short + # to hold even that prefix still aborts the response, so the slice is taken + # defensively rather than guarded by the length check that the ordinary + # chunk path applies further down. + # + # @param body [String] the abort chunk body, prefix included. + # @return [Rex::Proto::OpcUa::Error::AbortError] + def abort_error(body) + Error::AbortError.new(**status_and_reason(body.byteslice(SECURE_MSG_PREFIX_LEN..-1).to_s)) + end + + # Decode the StatusCode and Reason that an ERR body and an abort body both + # carry, in the same two fields in the same order. + # + # Either arrives only once the server has decided it cannot answer, so a + # body that will not decode is still reported as the failure it is rather + # than being replaced by a complaint about the decode; the StatusCode is + # simply left unknown. + # + # @param body [String] the bytes of the two fields. + # @return [Hash] keyword arguments for the exception, empty when the body + # could not be decoded. + def status_and_reason(body) err = ErrorMessage.read(body) - Error::ServerError.new(status_code: err.status_code.snapshot, reason: err.reason.snapshot) + { status_code: err.status_code.snapshot, reason: err.reason.snapshot } rescue ::IOError, ::BinData::Error - Error::ServerError.new + {} end end end diff --git a/spec/lib/rex/proto/opc_ua/tcp_spec.rb b/spec/lib/rex/proto/opc_ua/tcp_spec.rb index 288bcfd296604..af657cd982fd3 100644 --- a/spec/lib/rex/proto/opc_ua/tcp_spec.rb +++ b/spec/lib/rex/proto/opc_ua/tcp_spec.rb @@ -68,10 +68,22 @@ def msg_chunk(chunk_type, payload, sequence: 1) end # An ERR message: StatusCode then a Reason string, null when reason is nil. + # These two fields are Table 76 of Part 6 section 7.1.2.5. def err_frame(status_code, reason = nil) + frame('ERR', 'F', status_and_reason(status_code, reason)) + end + + # The same two fields, which Table 63 of section 6.7.3 gives as the body of an + # abort chunk. An abort is a MSG chunk, so they follow the secure conversation + # prefix. + def abort_chunk(status_code, reason = nil) + msg_chunk('A', status_and_reason(status_code, reason)) + end + + def status_and_reason(status_code, reason) body = [status_code].pack('V') body << (reason.nil? ? [-1].pack('l<') : [reason.bytesize].pack('l<') + reason.b) - frame('ERR', 'F', body) + body end describe 'ceilings' do @@ -315,17 +327,67 @@ def stream_over(data, segment: nil) end it 'raises when the server aborts part way through' do - data = msg_chunk('C', 'one-') + msg_chunk('A', 'discard me') + data = msg_chunk('C', 'one-') + abort_chunk(0x80840000) expect { stream_over(data).recv_service_response } .to raise_error(Rex::Proto::OpcUa::Error::AbortError, /aborted/) end + # An abort chunk says why it aborted, in the same two fields an ERR + # carries. Discarding them would throw away the only account of what went + # wrong that the server is going to give. + it 'carries the StatusCode and Reason from the abort chunk' do + data = msg_chunk('C', 'one-') + abort_chunk(0x80840000, 'client took too long') + + expect { stream_over(data).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::AbortError) { |e| + expect(e.status_code).to eq 0x80840000 + expect(e.reason).to eq 'client took too long' + expect(e.message).to include 'Bad_RequestInterrupted' + } + end + + it 'carries a null Reason from the abort chunk as nil' do + expect { stream_over(abort_chunk(0x80850000)).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::AbortError) { |e| + expect(e.reason).to be_nil + } + end + + # The abort still stands even when its own body is unusable: the response + # is not coming either way, and a complaint about the abort body would + # replace the more useful fact. + it 'still aborts when the abort chunk is too short to hold its own body' do + short = frame('MSG', 'A', 'x' * (Rex::Proto::OpcUa::Tcp::SECURE_MSG_PREFIX_LEN - 1)) + + expect { stream_over(short).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::AbortError) { |e| + expect(e.status_code).to be_nil + } + end + + it 'still aborts when the abort body will not decode' do + expect { stream_over(msg_chunk('A', "\x01\x02".b)).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::AbortError) { |e| + expect(e.status_code).to be_nil + } + end + it 'raises when the server answers with ERR part way through' do data = msg_chunk('C', 'one-') + err_frame(0x80820000, 'internal error') expect { stream_over(data).recv_service_response } - .to raise_error(Rex::Proto::OpcUa::Error::ServerError, /Bad_TcpInternalError - internal error/) + .to raise_error(Rex::Proto::OpcUa::Error::ServerError, + /server returned ERR: Bad_TcpInternalError - internal error/) + end + + # ERR and abort share a base class and a message format, so the two have + # to stay distinguishable by what they say as well as by their class. + it 'distinguishes an ERR from an abort in the message' do + expect { stream_over(err_frame(0x80820000)).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::ServerError, /\Aserver returned ERR:/) + expect { stream_over(abort_chunk(0x80820000)).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::AbortError, /\Aserver aborted the response:/) end it 'carries the StatusCode from an ERR on the exception' do From 61074b798602f1163b00279a5fb45a616d1f4ef9 Mon Sep 17 00:00:00 2001 From: ethan-thomason Date: Fri, 28 Aug 2026 13:27:55 -0700 Subject: [PATCH 7/8] Port opcua_endpoint_enum onto the OPC-UA library --- lib/rex/proto/opc_ua/secure_channel.rb | 27 + lib/rex/proto/opc_ua/tcp.rb | 67 +- .../scanner/scada/opcua_endpoint_enum.rb | 766 +++++++----------- .../rex/proto/opc_ua/secure_channel_spec.rb | 33 + spec/lib/rex/proto/opc_ua/tcp_spec.rb | 93 +++ spec/lib/rex/proto/opc_ua/types_spec.rb | 2 +- .../scanner/scada/opcua_endpoint_enum_spec.rb | 426 ++++++++++ 7 files changed, 920 insertions(+), 494 deletions(-) create mode 100644 spec/modules/auxiliary/scanner/scada/opcua_endpoint_enum_spec.rb diff --git a/lib/rex/proto/opc_ua/secure_channel.rb b/lib/rex/proto/opc_ua/secure_channel.rb index 6769b173d109b..0dd90a31277e6 100644 --- a/lib/rex/proto/opc_ua/secure_channel.rb +++ b/lib/rex/proto/opc_ua/secure_channel.rb @@ -140,6 +140,33 @@ class OpenSecureChannelResponse < BinData::Record opc_ua_byte_string :server_nonce end + # Decode the body of an OPN message, which is everything the transport hands + # back after the 8 byte message header. + # + # An OPN body is framed as a plaintext SecureChannelId, an + # AsymmetricSecurityHeader, a SequenceHeader and the TypeId naming the + # service, and only then the service structure. The lengths of the first three + # depend on their contents, so they have to be walked rather than skipped. + # + # The envelope is identical for a request and a response, and what follows the + # TypeId is decided by that TypeId, so this reads the response form the caller + # asked for rather than dispatching on it. + # + # @param body [String] the OPN message body. + # @return [OpenSecureChannelResponse] + # @raise [BinData::ValidityError] if a record along the way will not decode. + # @raise [IOError] if the body is shorter than the framing it declares. + def self.parse_open_response(body) + raw = body.to_s.b + offset = 4 # SecureChannelId + + [AsymmetricSecurityHeader, SequenceHeader, Rex::Proto::OpcUa::Types::OpcUaNodeId].each do |record| + offset += record.read(raw.byteslice(offset..-1).to_s).num_bytes + end + + OpenSecureChannelResponse.read(raw.byteslice(offset..-1).to_s) + end + # CloseSecureChannelRequest. The channel being closed is the one the message # is sent on, so the request carries nothing beyond its header. # diff --git a/lib/rex/proto/opc_ua/tcp.rb b/lib/rex/proto/opc_ua/tcp.rb index b5ca0a40d89c1..3c17d864e10c1 100644 --- a/lib/rex/proto/opc_ua/tcp.rb +++ b/lib/rex/proto/opc_ua/tcp.rb @@ -132,6 +132,28 @@ class ErrorMessage < BinData::Record uint32 :status_code opc_ua_string :reason + + # Decode a body that arrives only once the server has decided it cannot + # answer, so it is read as leniently as it can be: the two fields are taken + # one at a time, and a Reason that will not decode still leaves the + # StatusCode reportable. An ERR is the server's only account of why it gave + # up, and half of one is worth more than none. + # + # @param body [String] the two fields, without any framing. + # @return [Array(Integer, String), Array(Integer, nil), Array(nil, nil)] the + # StatusCode and Reason, either of which is nil when it could not be read. + def self.decode(body) + raw = body.to_s.b + return [nil, nil] if raw.bytesize < 4 + + reason = begin + Rex::Proto::OpcUa::Types::OpcUaString.read(raw.byteslice(4..-1).to_s).snapshot + rescue ::IOError, ::BinData::ValidityError + nil + end + + [raw.byteslice(0, 4).unpack1('V'), reason] + end end # One framed message as it came off the wire. The body excludes the header. @@ -160,10 +182,12 @@ def intermediate? # Frames and reassembles OPC-UA TCP messages over a socket. # - # The only thing required of the socket is get_once(length, timeout), which is - # what makes this testable without a network: Msf::Exploit::Remote::Tcp#sock - # satisfies it and so does a test double. Writing is deliberately not part of - # this class, since building a request is the business of the layer above. + # The only thing required of the socket is get_once(length, timeout) and put, + # which is what makes this testable without a network: + # Msf::Exploit::Remote::Tcp#sock satisfies it and so does a test double. + # + # Building the body of a request belongs to the layer above; what belongs here + # is the header that wraps it, so that a caller cannot get MessageSize wrong. class MessageStream # Seconds allowed per read when the caller gives no timeout of its own. DEFAULT_TIMEOUT = 5 @@ -181,6 +205,22 @@ def initialize(sock, timeout: DEFAULT_TIMEOUT) @timeout = timeout end + # Frame a message and write it. MessageSize counts the header, so it is + # computed here rather than trusted from the caller. + # + # Nothing this library sends needs more than one chunk: a Hello, an + # OpenSecureChannel and a GetEndpoints request are all small, and the + # SendBufferSize a server may impose is at least 8192 bytes. + # + # @param message_type [String] a MessageType value. + # @param body [String] everything that follows the 8 byte header. + # @param chunk_type [String] a ChunkType value. + # @return [Integer] the number of bytes written. + def send_message(message_type, body, chunk_type: ChunkType::FINAL) + raw = body.to_s.b + @sock.put((message_type + chunk_type).b + [HEADER_LEN + raw.bytesize].pack('V') + raw) + end + # Read exactly len bytes, accumulating across reads. A single read is not # guaranteed to return the full amount, and a GetEndpoints response carrying # server certificates routinely spans several segments. @@ -307,22 +347,15 @@ def abort_error(body) Error::AbortError.new(**status_and_reason(body.byteslice(SECURE_MSG_PREFIX_LEN..-1).to_s)) end - # Decode the StatusCode and Reason that an ERR body and an abort body both - # carry, in the same two fields in the same order. - # - # Either arrives only once the server has decided it cannot answer, so a - # body that will not decode is still reported as the failure it is rather - # than being replaced by a complaint about the decode; the StatusCode is - # simply left unknown. + # The StatusCode and Reason that an ERR body and an abort body both carry, in + # the same two fields in the same order. # # @param body [String] the bytes of the two fields. - # @return [Hash] keyword arguments for the exception, empty when the body - # could not be decoded. + # @return [Hash] keyword arguments for the exception. def status_and_reason(body) - err = ErrorMessage.read(body) - { status_code: err.status_code.snapshot, reason: err.reason.snapshot } - rescue ::IOError, ::BinData::Error - {} + status_code, reason = ErrorMessage.decode(body) + + { status_code: status_code, reason: reason } end end end diff --git a/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb b/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb index 4779ac586e0cd..ca75b32420dd2 100644 --- a/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb +++ b/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb @@ -10,198 +10,43 @@ class MetasploitModule < Msf::Auxiliary include Msf::Auxiliary::Scanner include Msf::Auxiliary::Report - # Every OPC-UA TCP message begins with an 8 byte header: - # MessageType (3 bytes ASCII) + ChunkType (1 byte ASCII) + MessageSize (UInt32 LE) - # MessageSize is the total length including the header itself. - HEADER_LEN = 8 - - # Each MSG chunk repeats SecureChannelId + TokenId + SequenceNumber + RequestId - # ahead of its slice of the service payload. - SECURE_MSG_PREFIX_LEN = 16 - - NONE_POLICY_URI = 'http://opcfoundation.org/UA/SecurityPolicy#None' - - # NodeIds for the services used here, in FourByte encoding: - # 0x01 (FourByte) + NamespaceIndex (Byte) + Identifier (UInt16 LE) - # Numeric identifiers are from OPC-UA Specification Part 6, Annex A. - OPN_REQUEST_NODEID = [0x01, 0x00, 446].pack('CCv').freeze - GET_ENDPOINTS_NODEID = [0x01, 0x00, 428].pack('CCv').freeze - CLOSE_CHANNEL_NODEID = [0x01, 0x00, 452].pack('CCv').freeze - - # MessageSecurityMode enumeration (Part 4, section 7.15). - SECURITY_MODES = { - 0 => 'Invalid', - 1 => 'None', - 2 => 'Sign', - 3 => 'SignAndEncrypt' - }.freeze - - # UserTokenType enumeration (Part 4, section 7.36). - TOKEN_TYPES = { - 0 => 'Anonymous', - 1 => 'UserName', - 2 => 'Certificate', - 3 => 'IssuedToken' - }.freeze - - # OPC-UA StatusCodes that may appear in an ERR response from the UA TCP - # transport. Values per the OPC Foundation StatusCodes definitions - # (Opc.Ua.StatusCodes) and OPC-UA Specification Part 6. - STATUS_CODES = { - # UA TCP transport-specific errors (Part 6, 7.1.2) - 0x807D0000 => 'Bad_TcpServerTooBusy', - 0x807E0000 => 'Bad_TcpMessageTypeInvalid', - 0x807F0000 => 'Bad_TcpSecureChannelUnknown', - 0x80800000 => 'Bad_TcpMessageTooLarge', - 0x80810000 => 'Bad_TcpNotEnoughResources', - 0x80820000 => 'Bad_TcpInternalError', - 0x80830000 => 'Bad_TcpEndpointUrlInvalid', - # Connection/security errors also seen at the transport layer - 0x80BE0000 => 'Bad_ProtocolVersionUnsupported', - 0x80130000 => 'Bad_SecurityChecksFailed', - 0x80120000 => 'Bad_CertificateInvalid', - 0x80840000 => 'Bad_RequestInterrupted', - 0x80850000 => 'Bad_RequestTimeout', - 0x80860000 => 'Bad_SecureChannelClosed', - 0x80870000 => 'Bad_SecureChannelTokenUnknown', - 0x80AC0000 => 'Bad_ConnectionRejected', - 0x80AE0000 => 'Bad_ConnectionClosed' - }.freeze - - # Defensive ceilings. A malformed or hostile response must fail quickly rather - # than allocate without bound or spin on an absurd array length. - MAX_MESSAGE_SIZE = 4 * 1024 * 1024 - MAX_CHUNKS = 64 - MAX_ENDPOINTS = 64 - MAX_ARRAY_LENGTH = 512 - - # Raised whenever a decode would read past the end of a response buffer or - # encounter an encoding this module does not handle. Always caught locally. - class UaParseError < StandardError; end - - # Position tracking reader over an OPC-UA binary message body. - # Encoding rules follow OPC-UA Specification Part 6, section 5.2. All - # multi-byte integers are little-endian. Every reader advances the cursor and - # bounds-checks first, so a truncated response raises instead of silently - # desynchronising the walk through the nested structures. - class Cursor - def initialize(data) - @data = data.to_s.dup.force_encoding('BINARY') - @pos = 0 - end - - def remaining - @data.bytesize - @pos - end - - def take(len) - raise UaParseError, "read of #{len} bytes past end of buffer" if len.negative? || len > remaining - - out = @data.byteslice(@pos, len) - @pos += len - out - end - - def u8 - take(1).unpack1('C') - end - - def u16 - take(2).unpack1('v') - end - - def u32 - take(4).unpack1('V') - end - - def i32 - take(4).unpack1('l<') - end - - def i64 - take(8).unpack1('q<') - end - - def skip(len) - take(len) - nil - end - - # String and ByteString share a wire format: an Int32 length prefix followed - # by that many bytes. A negative length denotes null; zero denotes empty. - def bytestring - len = i32 - return nil if len.negative? - - take(len) - end - - def string - raw = bytestring - return nil if raw.nil? - - raw.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') - end - - def skip_string - bytestring - nil - end - - # Array length prefix. A negative value denotes a null array. Anything above - # the ceiling is treated as a malformed response. - def array_length(max = MAX_ARRAY_LENGTH) - len = i32 - return 0 if len.negative? - raise UaParseError, "array length #{len} exceeds ceiling #{max}" if len > max - - len - end - - # LocalizedText: one encoding mask byte, then Locale and/or Text depending - # on mask bits 0x01 and 0x02. Returns the Text field only. - def localized_text - mask = u8 - skip_string if (mask & 0x01).positive? - (mask & 0x02).positive? ? string : nil - end - - # NodeId. The low nibble of the leading byte selects the identifier form; - # bits 0x40 and 0x80 add trailing NamespaceUri and ServerIndex fields. - def skip_node_id - encoding = u8 - case encoding & 0x0F - when 0x00 then skip(1) # TwoByte: Identifier only - when 0x01 then skip(3) # FourByte: ns (Byte) + id (UInt16) - when 0x02 then skip(6) # Numeric: ns (UInt16) + id (UInt32) - when 0x03 # String: ns (UInt16) + String - skip(2) - skip_string - when 0x04 then skip(2 + 16) # GUID: ns (UInt16) + 16 bytes - when 0x05 # ByteString: ns (UInt16) + ByteString - skip(2) - skip_string - else - raise UaParseError, format('unknown NodeId encoding 0x%02X', encoding) - end - skip_string if (encoding & 0x80).positive? # NamespaceUri (String), per Part 6 5.2.2.9 - skip(4) if (encoding & 0x40).positive? # ServerIndex (UInt32), per Part 6 5.2.2.9 - nil - end - - # ExtensionObject: TypeId NodeId, an encoding byte, then an optional body. - def skip_extension_object - skip_node_id - encoding = u8 - case encoding - when 0x00 then nil # no body - when 0x01, 0x02 then skip_string # ByteString or XmlElement body - else - raise UaParseError, format('unknown ExtensionObject encoding 0x%02X', encoding) - end - nil - end - end + # The OPC-UA transport, its records, its enumerations and its errors. Every + # byte level concern lives there: framing, chunk reassembly, the built-in type + # encodings and the service structures, all of them checked against + # reference/opcua and the captures under spec/file_fixtures/opc_ua. What is + # left here is the scan itself. + # + # The ceilings that bound a hostile response live there too, and are the only + # thing standing between this module and unbounded allocation: + # + # Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE one message, 4 MiB + # Rex::Proto::OpcUa::Tcp::MAX_CHUNKS chunks per response, 64 + # Rex::Proto::OpcUa::Services::MAX_ENDPOINTS endpoints per response, 64 + # Rex::Proto::OpcUa::Types::OpcUaArray::DEFAULT_MAX_LENGTH any other + # array, 512 + OpcUa = Rex::Proto::OpcUa + + # ProtocolVersion 0 is the only version this standard defines. The buffer + # sizes are what this client is willing to receive; zero for MaxMessageSize + # and MaxChunkCount says the client imposes no limit of its own, which is not + # the same as accepting anything, since the ceilings above apply regardless. + # See OPC-UA Specification Part 6, section 7.1.2.3. + HELLO_BUFFER_SIZE = 65_535 + + # TimeoutHint on every request, in milliseconds. + REQUEST_TIMEOUT_MS = 10_000 + + # RequestedLifetime for the secure channel, in milliseconds. The server + # answers with a revised lifetime it is prepared to honour. + CHANNEL_LIFETIME_MS = 3_600_000 + + # MessageSecurityMode None (Part 4, section 7.20, Table 139). The channel is + # opened unprotected because GetEndpoints is reachable that way by + # specification and the module reads nothing else. + SECURITY_MODE_NONE = 1 + + # UserTokenType Anonymous (Part 4, section 7.42, Table 193). + TOKEN_TYPE_ANONYMOUS = 0 def initialize(info = {}) super( @@ -257,284 +102,190 @@ def read_timeout end # --------------------------------------------------------------------------- - # Encoding helpers + # Requests # --------------------------------------------------------------------------- - # Encode a String or ByteString: Int32 length prefix then the raw bytes. - # A nil value is encoded as null (length -1). - def encode_string(str) - return [-1].pack('l<') if str.nil? - - raw = str.to_s.dup.force_encoding('BINARY') - [raw.bytesize].pack('l<') + raw + # Fields common to every request header. The AuthenticationToken and + # AuditEntryId are left to their defaults, which are the null NodeId of a + # sessionless request and a null String. + # + # @param handle [Integer] the RequestHandle, which pairs a response with the + # request that asked for it. + # @return [Hash] fields for a Rex::Proto::OpcUa::Services::RequestHeader. + def request_header_fields(handle) + { + timestamp: OpcUa::Types::OpcUaDateTime.now, + request_handle: handle, + return_diagnostics: 0, + timeout_hint: REQUEST_TIMEOUT_MS + } end - def frame(msg_type, body, chunk = 'F') - size = HEADER_LEN + body.bytesize - (msg_type + chunk).b + [size].pack('V') + body + # The TypeId that names the service a message body carries. Every service + # identifier this module uses is a FourByte NodeId in namespace 0. + # + # @param identifier [Integer] a Rex::Proto::OpcUa::Enums::NodeIds value. + # @return [String] the encoded NodeId. + def type_id(identifier) + OpcUa::Types::OpcUaNodeId.four_byte(identifier).to_binary_s end - # RequestHeader (Part 4, section 7.28). The authentication token is a null - # NodeId because GetEndpoints is called without a session. - def build_request_header(request_handle) - hdr = [0x00, 0x00].pack('CC') # AuthenticationToken: null NodeId - hdr << [ua_timestamp].pack('q<') # Timestamp - hdr << [request_handle].pack('V') # RequestHandle - hdr << [0].pack('V') # ReturnDiagnostics: none - hdr << encode_string(nil) # AuditEntryId: null - hdr << [10_000].pack('V') # TimeoutHint in milliseconds - hdr << [0x00, 0x00, 0x00].pack('CCC') # AdditionalHeader: null ExtensionObject - hdr - end + # @param stream [Rex::Proto::OpcUa::Tcp::MessageStream] + # @param endpoint_url [String] the URL this client believes it dialled. + # @return [Integer] bytes written. + def send_hello(stream, endpoint_url) + hello = OpcUa::Tcp::HelloMessage.new( + protocol_version: 0, + receive_buffer_size: HELLO_BUFFER_SIZE, + send_buffer_size: HELLO_BUFFER_SIZE, + max_message_size: 0, + max_chunk_count: 0, + endpoint_url: endpoint_url + ) - # OPC-UA DateTime: 100 nanosecond ticks since 1601-01-01 UTC. - def ua_timestamp - ((::Time.now.to_f + 11_644_473_600) * 10_000_000).to_i + stream.send_message(OpcUa::Tcp::MessageType::HELLO, hello.to_binary_s) end - def build_hello(endpoint_url) - body = [ - 0, # ProtocolVersion - 65_535, # ReceiveBufferSize - 65_535, # SendBufferSize - 0, # MaxMessageSize (0 = no limit) - 0 # MaxChunkCount (0 = no limit) - ].pack('V*') - body << encode_string(endpoint_url) - frame('HEL', body) + # An OPN message body: a plaintext SecureChannelId, the asymmetric security + # header, the sequence header, then the TypeId and the service request. Under + # SecurityPolicy None the certificate fields of the security header are null + # and no cryptography is applied to this or any later message on the channel. + # + # @param stream [Rex::Proto::OpcUa::Tcp::MessageStream] + # @return [Integer] bytes written. + def send_open_secure_channel(stream) + # SecureChannelId, zero because the channel does not exist yet. + body = [0].pack('V') + body << OpcUa::SecureChannel::AsymmetricSecurityHeader.new( + security_policy_uri: OpcUa::Enums::NONE_POLICY_URI + ).to_binary_s + body << OpcUa::SecureChannel::SequenceHeader.new(sequence_number: 1, request_id: 1).to_binary_s + body << type_id(OpcUa::Enums::NodeIds::OPEN_SECURE_CHANNEL_REQUEST) + body << OpcUa::SecureChannel::OpenSecureChannelRequest.new( + request_header: request_header_fields(1), + client_protocol_version: 0, + request_type: OpcUa::SecureChannel::OpenSecureChannelRequest::ISSUE, + security_mode: SECURITY_MODE_NONE, + requested_lifetime: CHANNEL_LIFETIME_MS + ).to_binary_s + + stream.send_message(OpcUa::Tcp::MessageType::OPEN_SECURE_CHANNEL, body) end - # OpenSecureChannel for SecurityPolicy=None. The asymmetric security header - # carries the None policy URI with null certificate fields, so no cryptography - # is applied to this or any subsequent message on the channel. - def build_open_secure_channel - req = OPN_REQUEST_NODEID.dup - req << build_request_header(1) - req << [0].pack('V') # ClientProtocolVersion - req << [0].pack('V') # SecurityTokenRequestType: Issue - req << [1].pack('V') # MessageSecurityMode: None - req << encode_string(nil) # ClientNonce: null under the None policy - req << [3_600_000].pack('V') # RequestedLifetime in milliseconds - - asym = encode_string(NONE_POLICY_URI) # SecurityPolicyUri - asym << encode_string(nil) # SenderCertificate - asym << encode_string(nil) # ReceiverCertificateThumbprint - - seq = [1, 1].pack('VV') # SequenceNumber, RequestId - - frame('OPN', [0].pack('V') + asym + seq + req) + # A MSG or CLO body on an open channel: the SecureChannelId and TokenId the + # server issued, the sequence header, then the TypeId and the service request. + # + # @param token [Rex::Proto::OpcUa::SecureChannel::ChannelSecurityToken] + # @param sequence [Integer] SequenceNumber and RequestId for this message. + # @param request [String] the TypeId and encoded service request. + # @return [String] the message body. + def channel_body(token, sequence, request) + body = [token.channel_id.snapshot].pack('V') + body << OpcUa::SecureChannel::SymmetricSecurityHeader.new(token_id: token.token_id.snapshot).to_binary_s + body << OpcUa::SecureChannel::SequenceHeader.new(sequence_number: sequence, request_id: sequence).to_binary_s + body << request + body end - def build_get_endpoints(channel_id, token_id, endpoint_url) - req = GET_ENDPOINTS_NODEID.dup - req << build_request_header(2) - req << encode_string(endpoint_url) # EndpointUrl - req << [-1].pack('l<') # LocaleIds: null array - req << [-1].pack('l<') # ProfileUris: null array - - frame('MSG', [channel_id, token_id, 2, 2].pack('VVVV') + req) + # @param stream [Rex::Proto::OpcUa::Tcp::MessageStream] + # @param token [Rex::Proto::OpcUa::SecureChannel::ChannelSecurityToken] + # @param endpoint_url [String] the URL this client believes it dialled. + # @return [Integer] bytes written. + def send_get_endpoints(stream, token, endpoint_url) + # LocaleIds and ProfileUris are filters and default to null, which asks for + # every endpoint the server has. + request = type_id(OpcUa::Enums::NodeIds::GET_ENDPOINTS_REQUEST) + request << OpcUa::Services::GetEndpointsRequest.new( + request_header: request_header_fields(2), + endpoint_url: endpoint_url + ).to_binary_s + + stream.send_message(OpcUa::Tcp::MessageType::MESSAGE, channel_body(token, 2, request)) end - def build_close_secure_channel(channel_id, token_id) - req = CLOSE_CHANNEL_NODEID.dup - req << build_request_header(3) + # @param stream [Rex::Proto::OpcUa::Tcp::MessageStream] + # @param token [Rex::Proto::OpcUa::SecureChannel::ChannelSecurityToken] + # @return [Integer] bytes written. + def send_close_secure_channel(stream, token) + request = type_id(OpcUa::Enums::NodeIds::CLOSE_SECURE_CHANNEL_REQUEST) + request << OpcUa::SecureChannel::CloseSecureChannelRequest.new( + request_header: request_header_fields(3) + ).to_binary_s - frame('CLO', [channel_id, token_id, 3, 3].pack('VVVV') + req) + stream.send_message(OpcUa::Tcp::MessageType::CLOSE_SECURE_CHANNEL, channel_body(token, 3, request)) end # --------------------------------------------------------------------------- - # Transport + # Responses # --------------------------------------------------------------------------- - # Read exactly len bytes, accumulating across reads. A single read is not - # guaranteed to return the full amount and a GetEndpoints response carrying - # server certificates routinely spans several segments. - def read_exact(len) - buf = ''.b - deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + read_timeout - while buf.bytesize < len - left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - return nil unless left.positive? - - chunk = sock.get_once(len - buf.bytesize, left) - return nil if chunk.nil? || chunk.empty? - - buf << chunk.b - end - buf - end - - # Read one framed message. Returns [message_type, chunk_type, body] or nil. - def recv_message - header = read_exact(HEADER_LEN) - return nil if header.nil? - - size = header.byteslice(4, 4).unpack1('V') - return nil if size < HEADER_LEN || size > MAX_MESSAGE_SIZE - - body_len = size - HEADER_LEN - body = body_len.positive? ? read_exact(body_len) : ''.b - return nil if body.nil? - - [header.byteslice(0, 3), header.byteslice(3, 1), body] + # The StatusCode of an ERR message, by name, with the Reason appended when the + # server supplied one. + # + # @param body [String] the ERR message body. + # @return [String] the detail to show the user. + def error_detail(body) + status_code, reason = OpcUa::Tcp::ErrorMessage.decode(body) + return 'unknown' if status_code.nil? + + name = OpcUa::Enums.status_code_name(status_code) + reason.to_s.empty? ? name : "#{name} - #{reason}" end - # Read a complete service response, reassembling chunks where the server has - # split it. Continuation chunks repeat the SecureChannelId, TokenId and - # SequenceHeader ahead of their payload slice, so those bytes are stripped - # before concatenation. The returned buffer therefore starts at the response - # TypeId, not at the SecureChannelId. - # Returns [payload, nil] on success or [nil, reason] on failure. - def recv_service_response - payload = ''.b - MAX_CHUNKS.times do - msg = recv_message - return [nil, 'no response'] if msg.nil? - - msg_type, chunk_type, body = msg - - if msg_type == 'ERR' - status, reason = decode_error(body) - detail = reason.to_s.empty? ? status : "#{status} - #{reason}" - return [nil, "server returned ERR: #{detail}"] - end - return [nil, "unexpected message type #{msg_type.inspect}"] unless msg_type == 'MSG' - return [nil, 'server aborted the response'] if chunk_type == 'A' - return [nil, 'chunk shorter than its own headers'] if body.bytesize < SECURE_MSG_PREFIX_LEN - - payload << body.byteslice(SECURE_MSG_PREFIX_LEN..-1).to_s - return [payload, nil] if chunk_type == 'F' - end - [nil, "response exceeded #{MAX_CHUNKS} chunks"] - end - - # Decode an ERR body: UInt32 StatusCode followed by a String reason. - def decode_error(body) - return ['unknown', ''] if body.bytesize < 4 - - code = body.byteslice(0, 4).unpack1('V') - status = STATUS_CODES[code] || format('0x%08X', code) - - reason = '' - if body.bytesize >= 8 - reason_len = body.byteslice(4, 4).unpack1('V') - if reason_len != 0xFFFFFFFF && reason_len.positive? && body.bytesize >= 8 + reason_len - reason = body.byteslice(8, reason_len).to_s - end - end - - [status, reason] - end - - # --------------------------------------------------------------------------- - # Response parsing - # --------------------------------------------------------------------------- + # The payload handed back by the transport begins at the response TypeId, + # which says which service answered. + # + # @param payload [String] the reassembled service payload. + # @return [Rex::Proto::OpcUa::Services::GetEndpointsResponse] + def parse_get_endpoints(payload) + type_id = OpcUa::Types::OpcUaNodeId.read(payload) - # ResponseHeader (Part 4, section 7.29). Returns the ServiceResult. - def parse_response_header(cur) - cur.skip(8) # Timestamp - cur.skip(4) # RequestHandle - service_result = cur.u32 - diagnostics_mask = cur.u8 # ServiceDiagnostics encoding mask - raise UaParseError, 'diagnostic info present but not requested' unless diagnostics_mask.zero? - - cur.array_length.times { cur.skip_string } # StringTable - cur.skip_extension_object # AdditionalHeader - service_result + OpcUa::Services::GetEndpointsResponse.read(payload.byteslice(type_id.num_bytes..-1).to_s) end - # The OPN response mirrors the request framing: a plaintext SecureChannelId, - # the asymmetric security header, the sequence header, then the service body. - def parse_open_response(body) - cur = Cursor.new(body) - cur.u32 # SecureChannelId - cur.skip_string # SecurityPolicyUri - cur.skip_string # SenderCertificate - cur.skip_string # ReceiverCertificateThumbprint - cur.u32 # SequenceNumber - cur.u32 # RequestId - cur.skip_node_id # TypeId - service_result = parse_response_header(cur) - cur.u32 # ServerProtocolVersion + # Flatten an EndpointDescription into the shape this module reports and files + # in the database. The keys and their order are what report_note serialises + # and what the module documentation describes, so they are part of the + # module's interface rather than an implementation detail. + # + # @param endpoint [Rex::Proto::OpcUa::Services::EndpointDescription] + # @return [Hash] + def present(endpoint) + server = endpoint.server + name = server.application_name + certificate = endpoint.server_certificate.snapshot + mode = endpoint.security_mode.snapshot + policy_uri = endpoint.security_policy_uri.snapshot { - service_result: service_result, - channel_id: cur.u32, # SecurityToken.ChannelId - token_id: cur.u32 # SecurityToken.TokenId + endpoint_url: endpoint.endpoint_url.snapshot, + application_uri: server.application_uri.snapshot, + product_uri: server.product_uri.snapshot, + application_name: name.text? ? name.text.snapshot : nil, + # The length rather than the certificate: the note goes to the database + # and there is nothing to be learned from storing the whole blob. + server_certificate_len: certificate.nil? ? 0 : certificate.bytesize, + security_mode: mode, + security_mode_name: OpcUa::Enums.security_mode_name(mode), + security_policy_uri: policy_uri, + security_policy_name: OpcUa::Enums.security_policy_name(policy_uri), + user_tokens: endpoint.user_identity_tokens.map { |token| present_token(token) }, + security_level: endpoint.security_level.snapshot } end - # GetEndpointsResponse: a ResponseHeader followed by EndpointDescription[]. - # The payload passed in already has the secure conversation prefix stripped. - def parse_get_endpoints(payload) - cur = Cursor.new(payload) - cur.skip_node_id # TypeId - service_result = parse_response_header(cur) - return [nil, format('GetEndpoints ServiceResult=0x%08X', service_result)] unless service_result.zero? - - count = cur.array_length(MAX_ENDPOINTS) - endpoints = Array.new(count) { parse_endpoint_description(cur) } - [endpoints, nil] - end - - # EndpointDescription (Part 4, section 7.10). Field order is fixed; every - # variable length field must be consumed in sequence to keep the cursor - # aligned for the next endpoint in the array. - def parse_endpoint_description(cur) - endpoint_url = cur.string - - # Server: ApplicationDescription (Part 4, section 7.2) - application_uri = cur.string - product_uri = cur.string - application_name = cur.localized_text - cur.u32 # ApplicationType - cur.skip_string # GatewayServerUri - cur.skip_string # DiscoveryProfileUri - cur.array_length.times { cur.skip_string } # DiscoveryUrls - - server_certificate = cur.bytestring - security_mode = cur.u32 - security_policy_uri = cur.string - - # UserIdentityTokens: UserTokenPolicy[] (Part 4, section 7.37) - token_count = cur.array_length - tokens = Array.new(token_count) do - policy_id = cur.string - token_type = cur.u32 - cur.skip_string # IssuedTokenType - cur.skip_string # IssuerEndpointUrl - cur.skip_string # SecurityPolicyUri, per token - { - policy_id: policy_id, - token_type: token_type, - token_type_name: TOKEN_TYPES[token_type] || "Unknown(#{token_type})" - } - end - - cur.skip_string # TransportProfileUri - security_level = cur.u8 + # @param token [Rex::Proto::OpcUa::Services::UserTokenPolicy] + # @return [Hash] + def present_token(token) + token_type = token.token_type.snapshot { - endpoint_url: endpoint_url, - application_uri: application_uri, - product_uri: product_uri, - application_name: application_name, - server_certificate_len: server_certificate.nil? ? 0 : server_certificate.bytesize, - security_mode: security_mode, - security_mode_name: SECURITY_MODES[security_mode] || "Unknown(#{security_mode})", - security_policy_uri: security_policy_uri, - security_policy_name: short_policy(security_policy_uri), - user_tokens: tokens, - security_level: security_level + policy_id: token.policy_id.snapshot, + token_type: token_type, + token_type_name: OpcUa::Enums.user_token_type_name(token_type) } end - def short_policy(uri) - return 'Unknown' if uri.nil? || uri.empty? - - uri.include?('#') ? uri.rpartition('#').last : uri - end - # --------------------------------------------------------------------------- # Reporting # --------------------------------------------------------------------------- @@ -544,7 +295,7 @@ def unencrypted?(endpoint) end def anonymous?(endpoint) - endpoint[:user_tokens].any? { |t| t[:token_type].zero? } + endpoint[:user_tokens].any? { |t| t[:token_type] == TOKEN_TYPE_ANONYMOUS } end def report_endpoints(ip, endpoints) @@ -607,58 +358,111 @@ def report_endpoints(ip, endpoints) # Scanner entry point # --------------------------------------------------------------------------- - def run_host(ip) - connect - - endpoint_url = "opc.tcp://#{Rex::Socket.to_authority(ip, rport)}" + # Say hello and read the acknowledgement. + # + # A framing error is reported the same way as silence, because from the far + # side of a scan they are the same thing: whatever answered is not speaking + # this protocol, and there is nothing further to try. + # + # @return [Boolean] whether the server acknowledged. + def hello_acknowledged?(stream, endpoint_url) + send_hello(stream, endpoint_url) - sock.put(build_hello(endpoint_url)) - msg = recv_message - if msg.nil? + begin + msg = stream.recv_message + rescue OpcUa::Error::OpcUaError vprint_status('No OPC-UA response to HEL') - return + return false end - unless msg[0] == 'ACK' - if msg[0] == 'ERR' - status, reason = decode_error(msg[2]) - detail = reason.to_s.empty? ? status : "#{status} - #{reason}" - print_status("OPC-UA server present but refused the Hello - #{detail}") - else - vprint_status("Non-OPC-UA response (type=#{msg[0].inspect})") - end - return + return true if msg.message_type == OpcUa::Tcp::MessageType::ACKNOWLEDGE + + if msg.error? + print_status("OPC-UA server present but refused the Hello - #{error_detail(msg.body)}") + else + vprint_status("Non-OPC-UA response (type=#{msg.message_type.inspect})") end + false + end - vprint_good('OPC-UA Hello acknowledged, opening secure channel') + # @return [Rex::Proto::OpcUa::SecureChannel::ChannelSecurityToken, nil] the + # token to quote on later messages, or nil if the channel did not open. + def open_secure_channel(stream) + send_open_secure_channel(stream) - sock.put(build_open_secure_channel) - msg = recv_message - if msg.nil? || msg[0] != 'OPN' - detail = msg.nil? ? 'no response' : "got #{msg[0].inspect}" - print_status("OpenSecureChannel with SecurityPolicy=None failed (#{detail}); endpoints cannot be enumerated") - return + msg = begin + stream.recv_message + rescue OpcUa::Error::OpcUaError + nil end - channel = parse_open_response(msg[2]) - unless channel[:service_result].zero? - print_status(format('OpenSecureChannel rejected, ServiceResult=0x%08X', channel[:service_result])) - return + if msg.nil? || msg.message_type != OpcUa::Tcp::MessageType::OPEN_SECURE_CHANNEL + detail = msg.nil? ? 'no response' : "got #{msg.message_type.inspect}" + print_status("OpenSecureChannel with SecurityPolicy=None failed (#{detail}); endpoints cannot be enumerated") + return nil end - sock.put(build_get_endpoints(channel[:channel_id], channel[:token_id], endpoint_url)) - payload, error = recv_service_response - if payload.nil? - print_error("GetEndpoints failed: #{error}") - return + response = OpcUa::SecureChannel.parse_open_response(msg.body) + service_result = response.response_header.service_result.snapshot + unless service_result.zero? + print_status(format('OpenSecureChannel rejected, ServiceResult=0x%08X', service_result)) + return nil end - endpoints, error = parse_get_endpoints(payload) - if endpoints.nil? - print_error(error) - return + response.security_token + end + + # Read the GetEndpoints response, reporting why it did not arrive. + # + # 'no response' is the detail for silence specifically; every other failure + # carries the account the error itself gives, which for an ERR or an abort is + # the StatusCode the server sent. + # + # @param stream [Rex::Proto::OpcUa::Tcp::MessageStream] + # @return [String, nil] the reassembled service payload, or nil on failure. + def read_service_response(stream) + stream.recv_service_response + rescue OpcUa::Error::TimeoutError + print_error('GetEndpoints failed: no response') + nil + rescue OpcUa::Error::OpcUaError => e + print_error("GetEndpoints failed: #{e.message}") + nil + end + + # @return [Array, nil] the endpoints in report shape, or nil on failure. + def enumerate_endpoints(stream, token, endpoint_url) + send_get_endpoints(stream, token, endpoint_url) + + payload = read_service_response(stream) + return nil if payload.nil? + + response = parse_get_endpoints(payload) + service_result = response.response_header.service_result.snapshot + unless service_result.zero? + print_error(format('GetEndpoints ServiceResult=0x%08X', service_result)) + return nil end + response.endpoints.map { |endpoint| present(endpoint) } + end + + def run_host(ip) + connect + + stream = OpcUa::Tcp::MessageStream.new(sock, timeout: read_timeout) + endpoint_url = "opc.tcp://#{Rex::Socket.to_authority(ip, rport)}" + + return unless hello_acknowledged?(stream, endpoint_url) + + vprint_good('OPC-UA Hello acknowledged, opening secure channel') + + token = open_secure_channel(stream) + return if token.nil? + + endpoints = enumerate_endpoints(stream, token, endpoint_url) + return if endpoints.nil? + if endpoints.empty? print_status('OPC-UA server returned no endpoints') return @@ -666,16 +470,26 @@ def run_host(ip) report_endpoints(ip, endpoints) - # Release the channel rather than leaving it open until its lifetime expires. + # Release the channel rather than leaving it open until its lifetime + # expires. Nothing is read back; the scan is finished either way. begin - sock.put(build_close_secure_channel(channel[:channel_id], channel[:token_id])) + send_close_secure_channel(stream, token) rescue ::Rex::ConnectionError, ::EOFError, ::Errno::ECONNRESET, ::Errno::EPIPE nil end - rescue UaParseError => e - print_error("Malformed OPC-UA response: #{e.message}") + # Every error the library raises descends from Rex::RuntimeError, and + # Msf::Auxiliary::Scanner re-raises a bare ::RuntimeError out of its per host + # loop rather than moving to the next host. One malformed server would + # otherwise end a whole sweep, so the family is caught here as well as at each + # step that expects it. + rescue OpcUa::Error::OpcUaError => e + vprint_error("OPC-UA transport error: #{e.message}") rescue ::Rex::ConnectionError, ::EOFError, ::Errno::ECONNRESET => e vprint_error("#{e.class}: #{e.message}") + # A record that will not decode raises from BinData rather than from the + # library. EOFError is an IOError, so it has to be caught above this. + rescue ::BinData::ValidityError, ::IOError => e + print_error("Malformed OPC-UA response: #{e.message}") ensure disconnect end diff --git a/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb b/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb index 96ca20b6965be..ace4d765186b8 100644 --- a/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb +++ b/spec/lib/rex/proto/opc_ua/secure_channel_spec.rb @@ -277,4 +277,37 @@ expect(described_class.read(request.to_binary_s).snapshot).to eq request.snapshot end end + + # Named parsed rather than response: response is already the whole captured + # message in this file, and message_body is derived from it. + describe '.parse_open_response' do + subject(:parsed) { Rex::Proto::OpcUa::SecureChannel.parse_open_response(message_body) } + + it 'walks past the framing to the service response' do + expect(parsed.security_token.channel_id.snapshot).to eq 6 + expect(parsed.security_token.revised_lifetime.snapshot).to eq 600_000 + end + + # response_body_offset is where the service response starts, established by + # the byte for byte walk this file already asserts. Landing on the same + # bytes from the framing alone is what says the walk inside the method + # agrees with it. + it 'lands on the same bytes as the offset the walk established' do + expect(parsed.snapshot) + .to eq Rex::Proto::OpcUa::SecureChannel::OpenSecureChannelResponse + .read(response[response_body_offset..]).snapshot + end + + it 'accounts for the rest of the message' do + expect(parsed.num_bytes).to eq response.bytesize - response_body_offset + end + + # The three records ahead of the response are all variable length, so a body + # that ends inside one of them cannot be walked and has to fail rather than + # return a response decoded from the wrong offset. + it 'raises rather than guess when the body ends inside the framing' do + expect { Rex::Proto::OpcUa::SecureChannel.parse_open_response(message_body.byteslice(0, 20)) } + .to raise_error(::IOError) + end + end end diff --git a/spec/lib/rex/proto/opc_ua/tcp_spec.rb b/spec/lib/rex/proto/opc_ua/tcp_spec.rb index af657cd982fd3..f0de96f729e32 100644 --- a/spec/lib/rex/proto/opc_ua/tcp_spec.rb +++ b/spec/lib/rex/proto/opc_ua/tcp_spec.rb @@ -451,3 +451,96 @@ def stream_over(data, segment: nil) end end end + +# The write side, kept in its own group both because it is a separate concern +# from framing what comes back and because the transport group above is already +# at the block length the project's RuboCop configuration allows. +RSpec.describe 'Rex::Proto::OpcUa::Tcp::MessageStream#send_message' do + let(:written) { [] } + + let(:socket) do + sink = written + Class.new do + define_method(:put) { |data| sink << data.dup.b } + define_method(:get_once) { |_length, _timeout| nil } + end.new + end + + subject(:stream) { Rex::Proto::OpcUa::Tcp::MessageStream.new(socket, timeout: 0.5) } + + it 'writes the message type, a final chunk type and the body' do + stream.send_message('HEL', 'body') + + expect(written.first.byteslice(0, 4)).to eq 'HELF' + expect(written.first.byteslice(8..)).to eq 'body' + end + + # MessageSize counts the header, so a caller that computed it would have to + # know that. Getting it wrong desynchronises the server for every request that + # follows, which is why it is not the caller's to get wrong. + it 'declares a MessageSize that includes the header' do + stream.send_message('HEL', 'body') + + expect(written.first.byteslice(4, 4).unpack1('V')).to eq written.first.bytesize + expect(written.first.bytesize).to eq Rex::Proto::OpcUa::Tcp::HEADER_LEN + 4 + end + + it 'frames an empty body' do + stream.send_message('CLO', '') + + expect(written.first).to eq "CLOF\x08\x00\x00\x00".b + end + + it 'takes a chunk type when one is given' do + stream.send_message('MSG', '', chunk_type: 'C') + + expect(written.first.byteslice(0, 4)).to eq 'MSGC' + end + + it 'writes binary regardless of the encoding it was handed' do + stream.send_message('HEL', "caf\xC3\xA9") + + expect(written.first.encoding).to eq ::Encoding::BINARY + end +end + +# The lenient decode both an ERR message and an abort chunk go through. Kept out +# of the transport group above so that group stays within the block length the +# project's RuboCop configuration allows. +RSpec.describe Rex::Proto::OpcUa::Tcp::ErrorMessage do + def body(status_code, reason = nil) + raw = [status_code].pack('V') + raw << (reason.nil? ? [-1].pack('l<') : [reason.bytesize].pack('l<') + reason.b) + raw + end + + describe '.decode' do + it 'returns the StatusCode and Reason' do + expect(described_class.decode(body(0x807D0000, 'busy'))).to eq [0x807D0000, 'busy'] + end + + it 'returns a null Reason as nil' do + expect(described_class.decode(body(0x807D0000))).to eq [0x807D0000, nil] + end + + it 'returns nothing at all when there is not even a StatusCode' do + expect(described_class.decode("\x01\x02".b)).to eq [nil, nil] + expect(described_class.decode('')).to eq [nil, nil] + end + + # Half an ERR is worth more than none: the StatusCode is the part that says + # why the server gave up, and it is readable even when the Reason is not. + it 'keeps the StatusCode when the Reason will not decode' do + truncated = [0x807D0000].pack('V') + [64].pack('l<') + 'short' + + expect(described_class.decode(truncated)).to eq [0x807D0000, nil] + end + + it 'reads the fields the record reads' do + raw = body(0x80820000, 'internal') + record = described_class.read(raw) + + expect(described_class.decode(raw)).to eq [record.status_code.snapshot, record.reason.snapshot] + end + end +end diff --git a/spec/lib/rex/proto/opc_ua/types_spec.rb b/spec/lib/rex/proto/opc_ua/types_spec.rb index 34028749fdbdd..f56de29dbfb1e 100644 --- a/spec/lib/rex/proto/opc_ua/types_spec.rb +++ b/spec/lib/rex/proto/opc_ua/types_spec.rb @@ -171,7 +171,7 @@ end end - # The synthetic cases above assert the intended behaviour; these assert it + # The synthetic cases above assert the intended behavior; these assert it # against bytes a real server put on the wire. # # The AsymmetricAlgorithmSecurityHeader of an OpenSecureChannelResponse opens diff --git a/spec/modules/auxiliary/scanner/scada/opcua_endpoint_enum_spec.rb b/spec/modules/auxiliary/scanner/scada/opcua_endpoint_enum_spec.rb new file mode 100644 index 0000000000000..05b0f31f28f14 --- /dev/null +++ b/spec/modules/auxiliary/scanner/scada/opcua_endpoint_enum_spec.rb @@ -0,0 +1,426 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +# A behavioral contract for the OPC-UA endpoint scanner, written against the +# implementation that parsed the protocol inline and kept unchanged across the +# port onto Rex::Proto::OpcUa. +# +# The point of it is that the port is invisible from outside the module: the +# same captured server produces the same console narration, in the same order, +# and the same rows in the database. Everything asserted here is either a string +# a user reads, a value the documentation describes, or a ceiling that is the +# only thing standing between a hostile response and unbounded allocation. +# +# The whole conversation is replayed from spec/file_fixtures/opc_ua, so this +# exercises the real HEL, OPN and GetEndpoints handling end to end rather than +# any one method of it. See spec/file_fixtures/opc_ua/README.md for provenance. +RSpec.describe 'scanner/scada/opcua_endpoint_enum' do + include_context 'Msf::Simple::Framework#modules loading' + + subject(:scanner) do + load_and_create_module( + module_type: 'auxiliary', + reference_name: 'scanner/scada/opcua_endpoint_enum' + ) + end + + def fixture(name) + File.binread(File.join(FILE_FIXTURES_PATH, 'opc_ua', name)) + end + + let(:ack) { fixture('ack_node_opcua.bin') } + let(:open_response) { fixture('open_secure_channel_response_node_opcua.bin') } + let(:get_endpoints_response) { fixture('get_endpoints_response_node_opcua.bin') } + + # Everything the server says, in the order it says it. The module reads + # exactly what it needs before sending the next request, so one buffer serves + # the whole exchange. + let(:conversation) { ack + open_response + get_endpoints_response } + + # Stands in for the module's socket. Only put and get_once are used, and + # get_once returns nil once the bytes run out, which is what a Rex socket does + # when nothing arrives before its timeout. + let(:sent) { [] } + + # Each element is the length a read asked for, so a ceiling can be shown to + # bite before the module allocates anything on the strength of the number the + # server sent. + let(:reads) { [] } + + def socket_over(bytes) + written = sent + asked = reads + Class.new do + define_method(:initialize) { @buffer = bytes.dup.b } + define_method(:put) { |data| written << data.dup.b } + define_method(:get_once) do |length, _timeout = nil| + asked << length + @buffer.empty? ? nil : @buffer.slice!(0, length) + end + end.new + end + + # Console narration, captured as [level, message] before + # Msf::Auxiliary::Scanner prepends the ip:port that the documentation shows. + let(:output) { [] } + + # Database writes, captured as [method, arguments]. + let(:reports) { [] } + + before do + scanner.datastore['RHOST'] = '192.0.2.1' + scanner.datastore['RPORT'] = 4840 + scanner.datastore['VERBOSE'] = true + + allow(scanner).to receive(:connect) + allow(scanner).to receive(:disconnect) + + %i[print_status print_good print_error print_warning vprint_status vprint_good vprint_error].each do |level| + allow(scanner).to receive(level) { |message| output << [level, message] } + end + + %i[report_service report_note report_vuln].each do |report| + allow(scanner).to receive(report) { |args| reports << [report, args] } + end + end + + def run_against(bytes) + allow(scanner).to receive(:sock).and_return(socket_over(bytes)) + scanner.run_host('192.0.2.1') + end + + def report_for(kind) + reports.find { |name, _| name == kind }&.last + end + + describe 'a successful enumeration of the captured server' do + before { run_against(conversation) } + + # Constraint 1. Every one of these appears in the Scenarios section of + # documentation/modules/auxiliary/scanner/scada/opcua_endpoint_enum.md. A + # change here means the documentation is wrong, silently. + it 'narrates the scan with the exact strings the documentation shows' do + expect(output).to eq [ + [:vprint_good, 'OPC-UA Hello acknowledged, opening secure channel'], + [:print_good, 'OPC-UA server enumerated - 7 endpoint(s), 1 unauthenticated and unencrypted'], + [:print_status, ' [0] opc.tcp://ua-node:4840/UA/BackdraftTest'], + [:print_status, ' security: None/None identity: UserName, Certificate, Anonymous'], + [:print_warning, ' endpoint accepts anonymous clients over an unencrypted channel'], + [:print_status, ' [1] opc.tcp://ua-node:4840/UA/BackdraftTest'], + [:print_status, ' security: Basic256Sha256/Sign identity: UserName, Certificate, Anonymous'], + [:print_status, ' [2] opc.tcp://ua-node:4840/UA/BackdraftTest'], + [:print_status, ' security: Aes128_Sha256_RsaOaep/Sign identity: UserName, Certificate, Anonymous'], + [:print_status, ' [3] opc.tcp://ua-node:4840/UA/BackdraftTest'], + [:print_status, ' security: Aes256_Sha256_RsaPss/Sign identity: UserName, Certificate, Anonymous'], + [:print_status, ' [4] opc.tcp://ua-node:4840/UA/BackdraftTest'], + [:print_status, ' security: Basic256Sha256/SignAndEncrypt identity: UserName, Certificate, Anonymous'], + [:print_status, ' [5] opc.tcp://ua-node:4840/UA/BackdraftTest'], + [ + :print_status, + ' security: Aes128_Sha256_RsaOaep/SignAndEncrypt identity: UserName, Certificate, Anonymous' + ], + [:print_status, ' [6] opc.tcp://ua-node:4840/UA/BackdraftTest'], + [ + :print_status, + ' security: Aes256_Sha256_RsaPss/SignAndEncrypt identity: UserName, Certificate, Anonymous' + ], + [:print_status, ' ApplicationUri: urn:ua-node:NodeOPCUA-Server'], + [:print_status, ' ProductUri: NodeOPCUA-Server'] + ] + end + + it 'sends a HEL, an OPN, a GetEndpoints MSG and a CLO, in that order' do + expect(sent.map { |frame| frame.byteslice(0, 4) }).to eq %w[HELF OPNF MSGF CLOF] + end + + # The declared size of every request has to match what was actually written, + # or the server reads the next request as the tail of this one. + it 'declares a MessageSize matching the bytes it wrote' do + expect(sent.map { |frame| frame.byteslice(4, 4).unpack1('V') }).to eq sent.map(&:bytesize) + end + + # The channel the server issued has to be quoted back on every message sent + # on it. Both are 6 and 1 in the capture. + it 'quotes the SecureChannelId and TokenId the server issued' do + expect(sent.drop(2).map { |frame| frame.byteslice(8, 8).unpack('V2') }).to eq [[6, 1], [6, 1]] + end + + it 'reports the service' do + expect(report_for(:report_service)).to eq( + host: '192.0.2.1', + port: 4840, + proto: 'tcp', + name: 'opc-ua', + info: 'OPC-UA server, 7 endpoint(s), ApplicationUri urn:ua-node:NodeOPCUA-Server' + ) + end + + it 'reports the vulnerability for the unauthenticated endpoint' do + vuln = report_for(:report_vuln) + + expect(vuln[:host]).to eq '192.0.2.1' + expect(vuln[:port]).to eq 4840 + expect(vuln[:proto]).to eq 'tcp' + expect(vuln[:name]).to eq 'OPC-UA endpoint accepting anonymous identity without encryption' + expect(vuln[:info]).to eq '1 of 7 advertised endpoint(s) accept the Anonymous user identity token ' \ + 'over a channel with MessageSecurityMode None' + end + + # Constraint 3. report_note serialises this into the database and the + # documentation describes its shape, so the keys, their order and the types + # of their values are all part of the contract. + describe 'the opcua.endpoints note' do + let(:note) { report_for(:report_note) } + let(:endpoints) { note[:data][:endpoints] } + + it 'is filed under the documented type with the documented update policy' do + expect(note[:host]).to eq '192.0.2.1' + expect(note[:port]).to eq 4840 + expect(note[:proto]).to eq 'tcp' + expect(note[:type]).to eq 'opcua.endpoints' + expect(note[:update]).to eq :unique_data + end + + it 'carries one entry per endpoint' do + expect(endpoints.length).to eq 7 + end + + it 'keys every endpoint identically, in the documented order' do + expect(endpoints.map(&:keys).uniq).to eq [ + %i[ + endpoint_url + application_uri + product_uri + application_name + server_certificate_len + security_mode + security_mode_name + security_policy_uri + security_policy_name + user_tokens + security_level + ] + ] + end + + it 'keys every user token identically, in the documented order' do + expect(endpoints.flat_map { |ep| ep[:user_tokens].map(&:keys) }.uniq) + .to eq [%i[policy_id token_type token_type_name]] + end + + it 'gives every value the documented type' do + endpoints.each do |ep| + expect(ep[:endpoint_url]).to be_a String + expect(ep[:application_uri]).to be_a String + expect(ep[:product_uri]).to be_a String + expect(ep[:application_name]).to be_a String + expect(ep[:server_certificate_len]).to be_an Integer + expect(ep[:security_mode]).to be_an Integer + expect(ep[:security_mode_name]).to be_a String + expect(ep[:security_policy_uri]).to be_a String + expect(ep[:security_policy_name]).to be_a String + expect(ep[:user_tokens]).to be_an Array + expect(ep[:security_level]).to be_an Integer + + ep[:user_tokens].each do |token| + expect(token[:policy_id]).to be_a String + expect(token[:token_type]).to be_an Integer + expect(token[:token_type_name]).to be_a String + end + end + end + + it 'carries the values the captured server sent' do + expect(endpoints.map { |ep| ep[:security_mode_name] }) + .to eq %w[None Sign Sign Sign SignAndEncrypt SignAndEncrypt SignAndEncrypt] + expect(endpoints.map { |ep| ep[:security_policy_name] }) + .to eq %w[ + None Basic256Sha256 Aes128_Sha256_RsaOaep Aes256_Sha256_RsaPss + Basic256Sha256 Aes128_Sha256_RsaOaep Aes256_Sha256_RsaPss + ] + expect(endpoints.map { |ep| ep[:security_level] }).to eq [1, 106, 105, 107, 206, 205, 207] + expect(endpoints.map { |ep| ep[:user_tokens].length }).to eq [5, 3, 3, 3, 3, 3, 3] + end + + # server_certificate_len is a length rather than the certificate itself, + # so the note stays small and carries no key material into the database. + it 'records the certificate length rather than the certificate' do + expect(endpoints.map(&:keys).flatten).not_to include :server_certificate + expect(endpoints.map { |ep| ep[:server_certificate_len] }).to all(eq(1078)) + end + + it 'names the token types the first endpoint accepts' do + expect(endpoints.first[:user_tokens].map { |token| token[:token_type_name] }) + .to eq %w[UserName UserName Certificate Certificate Anonymous] + end + + it 'records the policy ids verbatim' do + expect(endpoints.first[:user_tokens].map { |token| token[:policy_id] }).to eq %w[ + username_basic256Sha256 + username_aes128Sha256RsaOaep + certificate_basic256Sha256 + certificate_aes128Sha256RsaOaep + anonymous + ] + end + end + end + + # Constraint 1 again, for the paths that produce no endpoints. These strings + # are what a user sees against a host that is not an OPC-UA server, or is one + # that will not talk, and they are the ones the documentation quotes. + describe 'the paths that give up' do + it 'says so when the host never answers the Hello' do + run_against(''.b) + + expect(output).to eq [[:vprint_status, 'No OPC-UA response to HEL']] + end + + it 'reports an ERR in answer to the Hello with its StatusCode name' do + err = 'ERRF'.b + [16].pack('V') + [0x807D0000].pack('V') + [-1].pack('l<') + run_against(err) + + expect(output).to eq [[:print_status, 'OPC-UA server present but refused the Hello - Bad_TcpServerTooBusy']] + end + + it 'reports an ERR reason when the server supplies one' do + reason = 'too many clients' + err = 'ERRF'.b + [16 + reason.bytesize].pack('V') + + [0x807D0000].pack('V') + [reason.bytesize].pack('l<') + reason + run_against(err) + + expect(output).to eq [ + [:print_status, "OPC-UA server present but refused the Hello - Bad_TcpServerTooBusy - #{reason}"] + ] + end + + it 'says so when something that is not OPC-UA answers' do + run_against('HTTP'.b + [47].pack('V') + ('x' * 39)) + + expect(output).to eq [[:vprint_status, 'Non-OPC-UA response (type="HTT")']] + end + + # 'no response' is the detail wording for a channel that never opens. + it "uses 'no response' as the detail when the channel never opens" do + run_against(ack) + + expect(output).to eq [ + [:vprint_good, 'OPC-UA Hello acknowledged, opening secure channel'], + [ + :print_status, + 'OpenSecureChannel with SecurityPolicy=None failed (no response); endpoints cannot be enumerated' + ] + ] + end + + it "uses 'no response' as the detail when GetEndpoints goes unanswered" do + run_against(ack + open_response) + + expect(output.last).to eq [:print_error, 'GetEndpoints failed: no response'] + end + + it 'writes nothing to the database when it gives up' do + run_against(ack) + + expect(reports).to be_empty + end + end + + # Constraint 2. Msf::Auxiliary::Scanner rescues ::RuntimeError in its per host + # loop and re-raises it, which ends the sweep rather than moving to the next + # host. Every error the library raises is one, so run_host has to contain them + # or a single malformed server takes the whole scan down with it. + describe 'containing library errors' do + it 'raises errors that Msf::Auxiliary::Scanner would re-raise out of its host loop' do + expect(Rex::Proto::OpcUa::Error::OpcUaError.ancestors).to include ::RuntimeError + end + + it 'does not let a library error escape run_host' do + allow(scanner).to receive(:report_endpoints) + .and_raise(Rex::Proto::OpcUa::Error::FramingError, 'escaped') + + expect { run_against(conversation) }.not_to raise_error + end + + it 'does not let a malformed record escape run_host' do + allow(scanner).to receive(:report_endpoints).and_raise(BinData::ValidityError, 'escaped') + + expect { run_against(conversation) }.not_to raise_error + end + + it 'disconnects even when an error escapes' do + allow(scanner).to receive(:report_endpoints) + .and_raise(Rex::Proto::OpcUa::Error::FramingError, 'escaped') + + expect(scanner).to receive(:disconnect) + run_against(conversation) + end + end + + # Constraint 4. These are the only thing between the module and a response + # that claims to be larger than memory. + describe 'the ceilings on a hostile response' do + it 'keeps a message ceiling of 4 MiB' do + expect(Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE).to eq 4 * 1024 * 1024 + end + + it 'keeps a chunk ceiling of 64' do + expect(Rex::Proto::OpcUa::Tcp::MAX_CHUNKS).to eq 64 + end + + it 'keeps an endpoint ceiling of 64' do + expect(Rex::Proto::OpcUa::Services::MAX_ENDPOINTS).to eq 64 + end + + it 'keeps a ceiling of 512 on every other array' do + expect(Rex::Proto::OpcUa::Types::OpcUaArray::DEFAULT_MAX_LENGTH).to eq 512 + end + + # The arrays nested inside the endpoint array carry their own, tighter + # ceilings, because the endpoint ceiling does not bound them: without these + # the worst case is 64 endpoints times 512 elements. + it 'caps the arrays nested inside an endpoint below the general ceiling' do + expect(Rex::Proto::OpcUa::Services::MAX_USER_TOKENS) + .to be <= Rex::Proto::OpcUa::Types::OpcUaArray::DEFAULT_MAX_LENGTH + expect(Rex::Proto::OpcUa::Services::MAX_DISCOVERY_URLS) + .to be <= Rex::Proto::OpcUa::Types::OpcUaArray::DEFAULT_MAX_LENGTH + end + + # The read that matters is the one that never happens: the size is believed + # only far enough to reject it, so the header is read and nothing else. + it 'never reads the body of a message that declares more than the ceiling' do + run_against('ACKF'.b + [Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE + 1].pack('V')) + + expect(reads).to eq [Rex::Proto::OpcUa::Tcp::HEADER_LEN] + expect(output).to eq [[:vprint_status, 'No OPC-UA response to HEL']] + end + + it 'gives up on a response that never sends a final chunk' do + chunk = 'MSGC'.b + [8 + 16 + 1].pack('V') + [6, 1, 1, 1].pack('V4') + 'x' + run_against(ack + open_response + (chunk * Rex::Proto::OpcUa::Tcp::MAX_CHUNKS)) + + expect(output.last).to eq [:print_error, 'GetEndpoints failed: response exceeded the 64 chunk ceiling'] + end + + # The endpoint count sits 52 bytes into the captured message, after the + # framing, the TypeId and the ResponseHeader. Claiming one more than the + # ceiling has to fail before a single EndpointDescription is built. + it 'refuses an endpoint count above the ceiling' do + oversize = get_endpoints_response.dup + oversize[52, 4] = [Rex::Proto::OpcUa::Services::MAX_ENDPOINTS + 1].pack('l<') + run_against(ack + open_response + oversize) + + expect(output.last.first).to eq :print_error + expect(output.last.last).to start_with 'Malformed OPC-UA response: array length 65 exceeds' + end + + it 'writes nothing to the database when a response is refused' do + oversize = get_endpoints_response.dup + oversize[52, 4] = [Rex::Proto::OpcUa::Services::MAX_ENDPOINTS + 1].pack('l<') + run_against(ack + open_response + oversize) + + expect(reports).to be_empty + end + end +end From da0982165e9aa4a2b98fd22ad2a9927941b12c6b Mon Sep 17 00:00:00 2001 From: ethan-thomason Date: Mon, 31 Aug 2026 16:49:42 -0700 Subject: [PATCH 8/8] Bound the reassembled OPC-UA message and negotiate chunk size --- lib/rex/proto/opc_ua/tcp.rb | 79 ++++++++- .../scanner/scada/opcua_endpoint_enum.rb | 58 +++++-- spec/lib/rex/proto/opc_ua/tcp_spec.rb | 156 +++++++++++++++++- .../scanner/scada/opcua_endpoint_enum_spec.rb | 86 +++++++++- 4 files changed, 351 insertions(+), 28 deletions(-) diff --git a/lib/rex/proto/opc_ua/tcp.rb b/lib/rex/proto/opc_ua/tcp.rb index 3c17d864e10c1..f5a70279b8181 100644 --- a/lib/rex/proto/opc_ua/tcp.rb +++ b/lib/rex/proto/opc_ua/tcp.rb @@ -39,9 +39,23 @@ module Rex::Proto::OpcUa::Tcp SECURE_MSG_PREFIX_LEN = 16 # Defensive ceilings. A malformed or hostile response must fail quickly rather - # than allocate without bound. Both values are carried over unchanged from the - # module this library was factored out of. + # than allocate without bound, and the three below are deliberately layered: + # bounding a chunk does not bound a message, and bounding the number of chunks + # does not either, because the product of the two is what actually arrives. + # + # The largest single chunk accepted before a size has been negotiated, and the + # most this library will ever accept as a negotiated one. A server answering + # the Hello with a buffer size above this is refused rather than believed. + MAX_CHUNK_SIZE = 4 * 1024 * 1024 + + # The largest reassembled message, applied cumulatively as the chunks arrive. + # This is the MaxMessageSize a client advertises in its Hello, so a server + # that honours it aborts before reaching the ceiling and one that does not is + # cut off here. See OPC-UA Specification Part 6, section 7.1.2.3. MAX_MESSAGE_SIZE = 4 * 1024 * 1024 + + # The most chunks one message may be split into, the MaxChunkCount of the same + # Hello. MAX_CHUNKS = 64 # MessageType values, each three ASCII bytes. These come from two layers, and @@ -196,13 +210,55 @@ class MessageStream # header and the body of a message are each read under a fresh deadline. attr_reader :timeout - # @param sock [#get_once] the socket to read from. Only - # get_once(length, timeout) is called on it. + # @return [Integer] the largest single chunk currently accepted. This is + # MAX_CHUNK_SIZE until #negotiate replaces it with what the server agreed + # to in its Acknowledge. + attr_reader :max_chunk_size + + # @param sock [#get_once, #put] the socket to work over. # @param timeout [Integer, Float] seconds allowed per read. # @return [MessageStream] def initialize(sock, timeout: DEFAULT_TIMEOUT) @sock = sock @timeout = timeout + @max_chunk_size = MAX_CHUNK_SIZE + end + + # Apply the chunk size the server agreed to in its Acknowledge. + # + # OPC-UA Specification Part 6, section 7.1.2.2 requires the connection layer + # to check MessageSize against the negotiated ReceiveBufferSize before + # passing a message up. Until this is called there is no negotiated value + # and MAX_CHUNK_SIZE stands in for one. + # + # The field that bounds what arrives here is the server's SendBufferSize, + # not its ReceiveBufferSize: Table 75 defines the latter as the largest + # chunk the sender of the Acknowledge can receive, which bounds what this + # client sends rather than what it is sent. That table also requires the + # server's SendBufferSize not to exceed the ReceiveBufferSize the Hello + # asked for, so the smaller of the two is taken rather than trusting the + # server to have honoured it. + # + # A value below the specification's minimum is accepted as the server's own + # tighter limit, since it can only make this client allocate less. + # + # @param hello [HelloMessage] the Hello that was sent. + # @param acknowledge [AcknowledgeMessage] the Acknowledge that came back. + # @return [Integer] the bound now applied to each chunk. + # @raise [Error::FramingError] if the server named a chunk size that is + # unusable or larger than this library will accept from anyone. + def negotiate(hello, acknowledge) + offered = acknowledge.send_buffer_size.snapshot + requested = hello.receive_buffer_size.snapshot + + if offered.zero? || offered > MAX_CHUNK_SIZE + raise Error::FramingError, + "server advertised a SendBufferSize of #{offered}, outside 1..#{MAX_CHUNK_SIZE}" + end + + # A Hello that asked for nothing in particular leaves the server's figure + # to stand on its own. + @max_chunk_size = requested.positive? ? [offered, requested].min : offered end # Frame a message and write it. MessageSize counts the header, so it is @@ -272,8 +328,8 @@ def read_exact(len) def recv_message header = MessageHeader.read(read_exact(HEADER_LEN)) size = header.message_size.snapshot - if size < HEADER_LEN || size > MAX_MESSAGE_SIZE - raise Error::FramingError, "message size #{size} outside #{HEADER_LEN}..#{MAX_MESSAGE_SIZE}" + if size < HEADER_LEN || size > max_chunk_size + raise Error::FramingError, "message size #{size} outside #{HEADER_LEN}..#{max_chunk_size}" end Message.new( @@ -316,7 +372,16 @@ def recv_service_response "chunk of #{msg.body.bytesize} bytes is shorter than its #{SECURE_MSG_PREFIX_LEN} byte header" end - payload << msg.body.byteslice(SECURE_MSG_PREFIX_LEN..-1) + # The chunk ceiling and the chunk size ceiling do not bound this between + # them: the whole point of reassembly is that the result is larger than + # any one chunk, so the running total needs a ceiling of its own. + slice = msg.body.byteslice(SECURE_MSG_PREFIX_LEN..-1) + if payload.bytesize + slice.bytesize > MAX_MESSAGE_SIZE + raise Error::FramingError, + "reassembled response exceeded the #{MAX_MESSAGE_SIZE} byte ceiling" + end + + payload << slice return payload if msg.final? end diff --git a/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb b/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb index ca75b32420dd2..bb53ea4c95b14 100644 --- a/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb +++ b/modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb @@ -19,18 +19,23 @@ class MetasploitModule < Msf::Auxiliary # The ceilings that bound a hostile response live there too, and are the only # thing standing between this module and unbounded allocation: # - # Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE one message, 4 MiB + # Rex::Proto::OpcUa::Tcp::MAX_CHUNK_SIZE one chunk, 4 MiB, until the + # Acknowledge negotiates a + # smaller one + # Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE one reassembled message, 4 MiB # Rex::Proto::OpcUa::Tcp::MAX_CHUNKS chunks per response, 64 # Rex::Proto::OpcUa::Services::MAX_ENDPOINTS endpoints per response, 64 # Rex::Proto::OpcUa::Types::OpcUaArray::DEFAULT_MAX_LENGTH any other # array, 512 + # + # The first three are also what the Hello advertises, so a server that honours + # the handshake stops before any of them is reached and one that does not is + # cut off locally. OpcUa = Rex::Proto::OpcUa - # ProtocolVersion 0 is the only version this standard defines. The buffer - # sizes are what this client is willing to receive; zero for MaxMessageSize - # and MaxChunkCount says the client imposes no limit of its own, which is not - # the same as accepting anything, since the ceilings above apply regardless. - # See OPC-UA Specification Part 6, section 7.1.2.3. + # The largest chunk this client will receive or send, and the value the + # server's own SendBufferSize is negotiated down against. See OPC-UA + # Specification Part 6, section 7.1.2.3. HELLO_BUFFER_SIZE = 65_535 # TimeoutHint on every request, in milliseconds. @@ -130,20 +135,29 @@ def type_id(identifier) OpcUa::Types::OpcUaNodeId.four_byte(identifier).to_binary_s end + # MaxMessageSize and MaxChunkCount are the ceilings this module enforces + # locally, sent rather than left at zero. Part 6, section 7.1.2.3 reads zero + # as "the Client has no limit", which declines the server side protection + # while applying the limits anyway; sending the real figures means a + # cooperative server aborts with Bad_ResponseTooLarge before the local + # ceilings are reached, and an uncooperative one is cut off at them. + # # @param stream [Rex::Proto::OpcUa::Tcp::MessageStream] # @param endpoint_url [String] the URL this client believes it dialled. - # @return [Integer] bytes written. + # @return [Rex::Proto::OpcUa::Tcp::HelloMessage] the Hello that was sent, kept + # so the Acknowledge can be negotiated against what it asked for. def send_hello(stream, endpoint_url) hello = OpcUa::Tcp::HelloMessage.new( protocol_version: 0, receive_buffer_size: HELLO_BUFFER_SIZE, send_buffer_size: HELLO_BUFFER_SIZE, - max_message_size: 0, - max_chunk_count: 0, + max_message_size: OpcUa::Tcp::MAX_MESSAGE_SIZE, + max_chunk_count: OpcUa::Tcp::MAX_CHUNKS, endpoint_url: endpoint_url ) stream.send_message(OpcUa::Tcp::MessageType::HELLO, hello.to_binary_s) + hello end # An OPN message body: a plaintext SecureChannelId, the asymmetric security @@ -366,7 +380,7 @@ def report_endpoints(ip, endpoints) # # @return [Boolean] whether the server acknowledged. def hello_acknowledged?(stream, endpoint_url) - send_hello(stream, endpoint_url) + hello = send_hello(stream, endpoint_url) begin msg = stream.recv_message @@ -375,14 +389,26 @@ def hello_acknowledged?(stream, endpoint_url) return false end - return true if msg.message_type == OpcUa::Tcp::MessageType::ACKNOWLEDGE + unless msg.message_type == OpcUa::Tcp::MessageType::ACKNOWLEDGE + if msg.error? + print_status("OPC-UA server present but refused the Hello - #{error_detail(msg.body)}") + else + vprint_status("Non-OPC-UA response (type=#{msg.message_type.inspect})") + end + return false + end - if msg.error? - print_status("OPC-UA server present but refused the Hello - #{error_detail(msg.body)}") - else - vprint_status("Non-OPC-UA response (type=#{msg.message_type.inspect})") + # Part 6, section 7.1.2.2 requires the connection layer to check every + # MessageSize against the negotiated buffer size, so the size the server + # named has to replace the standing ceiling before anything else is read. + begin + stream.negotiate(hello, OpcUa::Tcp::AcknowledgeMessage.read(msg.body)) + rescue OpcUa::Error::FramingError => e + print_status("OPC-UA handshake rejected - #{e.message}") + return false end - false + + true end # @return [Rex::Proto::OpcUa::SecureChannel::ChannelSecurityToken, nil] the diff --git a/spec/lib/rex/proto/opc_ua/tcp_spec.rb b/spec/lib/rex/proto/opc_ua/tcp_spec.rb index f0de96f729e32..1219c9f8bc724 100644 --- a/spec/lib/rex/proto/opc_ua/tcp_spec.rb +++ b/spec/lib/rex/proto/opc_ua/tcp_spec.rb @@ -285,9 +285,10 @@ def stream_over(data, segment: nil) end # The ceiling matters most here: the size is believed only far enough to - # reject it, so nothing is allocated on the strength of it. + # reject it, so nothing is allocated on the strength of it. Before a size + # has been negotiated the bound is MAX_CHUNK_SIZE. it 'rejects a MessageSize above the ceiling' do - oversize = 'MSGF'.b + [Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE + 1].pack('V') + oversize = 'MSGF'.b + [Rex::Proto::OpcUa::Tcp::MAX_CHUNK_SIZE + 1].pack('V') expect { stream_over(oversize).recv_message } .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /message size 4194305 outside/) @@ -544,3 +545,154 @@ def body(status_code, reason = nil) end end end + +# The three ceilings are layered, and each of the specs above tests one of them +# on its own. These test them against each other, which is where the gap was: +# bounding one chunk and bounding the number of chunks says nothing about the +# size of what they add up to. +RSpec.describe 'Rex::Proto::OpcUa::Tcp::MessageStream bounds' do + def frame(message_type, chunk_type, body) + (message_type + chunk_type).b + [Rex::Proto::OpcUa::Tcp::HEADER_LEN + body.bytesize].pack('V') + body + end + + def msg_chunk(chunk_type, payload) + frame('MSG', chunk_type, [1, 2, 3, 4].pack('V4') + payload) + end + + def stream_over(data) + socket = Class.new do + define_method(:initialize) { @buffer = data.dup.b } + define_method(:get_once) { |length, _timeout| @buffer.empty? ? nil : @buffer.slice!(0, length) } + end.new + + Rex::Proto::OpcUa::Tcp::MessageStream.new(socket, timeout: 0.5) + end + + let(:hello) { Rex::Proto::OpcUa::Tcp::HelloMessage.new(receive_buffer_size: 65_535) } + + def acknowledge(send_buffer_size) + Rex::Proto::OpcUa::Tcp::AcknowledgeMessage.new( + receive_buffer_size: 65_535, + send_buffer_size: send_buffer_size + ) + end + + describe '#negotiate' do + subject(:stream) { stream_over('') } + + it 'accepts MAX_CHUNK_SIZE until a size has been negotiated' do + expect(stream.max_chunk_size).to eq Rex::Proto::OpcUa::Tcp::MAX_CHUNK_SIZE + end + + it 'takes the chunk size the server said it would send' do + expect(stream.negotiate(hello, acknowledge(16_384))).to eq 16_384 + expect(stream.max_chunk_size).to eq 16_384 + end + + # Part 6 Table 75 requires the server's SendBufferSize not to exceed the + # ReceiveBufferSize the Hello asked for. A server that ignores that is not + # believed: it cannot enlarge what this client agreed to receive. + it 'never exceeds what the Hello asked to receive' do + stream.negotiate(hello, acknowledge(1_000_000)) + + expect(stream.max_chunk_size).to eq 65_535 + end + + # This is the guard that stops a server answering the Hello with a figure + # that would put the per chunk bound back where it started. + it 'refuses a chunk size larger than this library will ever accept' do + oversize = acknowledge(Rex::Proto::OpcUa::Tcp::MAX_CHUNK_SIZE + 1) + + expect { stream.negotiate(hello, oversize) } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /SendBufferSize of 4194305, outside/) + end + + it 'refuses a chunk size of zero, which no message could satisfy' do + expect { stream.negotiate(hello, acknowledge(0)) } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /SendBufferSize of 0, outside/) + end + + it 'leaves the bound alone when it refuses' do + begin + stream.negotiate(hello, acknowledge(0)) + rescue Rex::Proto::OpcUa::Error::FramingError + nil + end + + expect(stream.max_chunk_size).to eq Rex::Proto::OpcUa::Tcp::MAX_CHUNK_SIZE + end + + # A server entitled to send smaller chunks than asked for is taken at its + # word, since that can only make this client allocate less. + it 'accepts a smaller chunk size than was asked for' do + stream.negotiate(hello, acknowledge(8192)) + + expect(stream.max_chunk_size).to eq 8192 + end + end + + describe 'the negotiated chunk size' do + it 'bounds a chunk that the unnegotiated ceiling would have allowed' do + stream = stream_over('MSGF'.b + [70_000].pack('V')) + stream.negotiate(hello, acknowledge(65_535)) + + expect { stream.recv_message } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /message size 70000 outside 8..65535/) + end + + it 'admits a chunk that fits inside it' do + body = [1, 2, 3, 4].pack('V4') + 'payload' + stream = stream_over(frame('MSG', 'F', body)) + stream.negotiate(hello, acknowledge(65_535)) + + expect(stream.recv_service_response).to eq 'payload' + end + end + + describe 'the cumulative bound on a reassembled response' do + # Each chunk is legal on its own and the chunk count is nowhere near spent, + # so this is the case neither of the other two ceilings catches: without a + # running total, 64 chunks of this size would reassemble to 140 MB. + let(:payload) { 'x' * 2_200_000 } + + it 'fails on the running total before the chunk count runs out' do + data = msg_chunk('C', payload) + msg_chunk('C', payload) + msg_chunk('F', 'tail') + + expect { stream_over(data).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /reassembled response exceeded the 4194304 byte ceiling/) + end + + it 'accepts each of those chunks on its own' do + expect(stream_over(msg_chunk('F', payload)).recv_service_response.bytesize).to eq payload.bytesize + end + + # The bound is on the total rather than on the chunk that crosses it, so the + # second chunk is refused for what it would add, not for its own size. + it 'counts what has already been reassembled' do + data = msg_chunk('C', payload) + msg_chunk('F', 'x' * 2_000_000) + + expect { stream_over(data).recv_service_response } + .to raise_error(Rex::Proto::OpcUa::Error::FramingError, /reassembled response exceeded/) + end + + it 'allows a response that reaches the ceiling exactly' do + exact = 'x' * (Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE / 2) + data = msg_chunk('C', exact) + msg_chunk('F', exact) + + expect(stream_over(data).recv_service_response.bytesize).to eq Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE + end + + # Once a size has been negotiated the two ceilings are consistent with each + # other: 64 chunks of 65535 bytes cannot reach 4 MB, so the chunk count is + # what a server runs into first and the running total is the backstop for a + # connection that never negotiated one. + it 'is not reachable within the chunk count once a size has been negotiated' do + stream = stream_over('') + stream.negotiate(hello, acknowledge(65_535)) + largest = stream.max_chunk_size - Rex::Proto::OpcUa::Tcp::HEADER_LEN - + Rex::Proto::OpcUa::Tcp::SECURE_MSG_PREFIX_LEN + + expect(largest * Rex::Proto::OpcUa::Tcp::MAX_CHUNKS).to be < Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE + end + end +end diff --git a/spec/modules/auxiliary/scanner/scada/opcua_endpoint_enum_spec.rb b/spec/modules/auxiliary/scanner/scada/opcua_endpoint_enum_spec.rb index 05b0f31f28f14..2c9e86f3a2185 100644 --- a/spec/modules/auxiliary/scanner/scada/opcua_endpoint_enum_spec.rb +++ b/spec/modules/auxiliary/scanner/scada/opcua_endpoint_enum_spec.rb @@ -15,7 +15,7 @@ # The whole conversation is replayed from spec/file_fixtures/opc_ua, so this # exercises the real HEL, OPN and GetEndpoints handling end to end rather than # any one method of it. See spec/file_fixtures/opc_ua/README.md for provenance. -RSpec.describe 'scanner/scada/opcua_endpoint_enum' do +RSpec.shared_context 'an opcua_endpoint_enum replay' do include_context 'Msf::Simple::Framework#modules loading' subject(:scanner) do @@ -93,6 +93,10 @@ def run_against(bytes) def report_for(kind) reports.find { |name, _| name == kind }&.last end +end + +RSpec.describe 'scanner/scada/opcua_endpoint_enum' do + include_context 'an opcua_endpoint_enum replay' describe 'a successful enumeration of the captured server' do before { run_against(conversation) } @@ -130,6 +134,18 @@ def report_for(kind) ] end + # Part 6 section 7.1.2.3 reads a zero MaxMessageSize or MaxChunkCount as + # "the Client has no limit", which would decline the server side protection + # while the module applied the same limits locally anyway. Sending the real + # figures lets a cooperative server stop first. + it 'advertises the ceilings it enforces rather than declining the protection' do + hello = Rex::Proto::OpcUa::Tcp::HelloMessage.read(sent.first.byteslice(8..)) + + expect(hello.max_message_size.snapshot).to eq Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE + expect(hello.max_chunk_count.snapshot).to eq Rex::Proto::OpcUa::Tcp::MAX_CHUNKS + expect(hello.receive_buffer_size.snapshot).to be_positive + end + it 'sends a HEL, an OPN, a GetEndpoints MSG and a CLO, in that order' do expect(sent.map { |frame| frame.byteslice(0, 4) }).to eq %w[HELF OPNF MSGF CLOF] end @@ -331,6 +347,13 @@ def report_for(kind) # loop and re-raises it, which ends the sweep rather than moving to the next # host. Every error the library raises is one, so run_host has to contain them # or a single malformed server takes the whole scan down with it. +end + +# The bounds that stop a hostile server, split into a group of their own so +# neither exceeds the block length the project's RuboCop configuration allows. +RSpec.describe 'scanner/scada/opcua_endpoint_enum bounds' do + include_context 'an opcua_endpoint_enum replay' + describe 'containing library errors' do it 'raises errors that Msf::Auxiliary::Scanner would re-raise out of its host loop' do expect(Rex::Proto::OpcUa::Error::OpcUaError.ancestors).to include ::RuntimeError @@ -361,7 +384,13 @@ def report_for(kind) # Constraint 4. These are the only thing between the module and a response # that claims to be larger than memory. describe 'the ceilings on a hostile response' do - it 'keeps a message ceiling of 4 MiB' do + it 'keeps a chunk ceiling of 4 MiB' do + expect(Rex::Proto::OpcUa::Tcp::MAX_CHUNK_SIZE).to eq 4 * 1024 * 1024 + end + + # The one bwatters found missing: bounding a chunk and bounding the number + # of chunks leaves the size of what they add up to unbounded. + it 'keeps a cumulative ceiling of 4 MiB on a reassembled message' do expect(Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE).to eq 4 * 1024 * 1024 end @@ -390,7 +419,7 @@ def report_for(kind) # The read that matters is the one that never happens: the size is believed # only far enough to reject it, so the header is read and nothing else. it 'never reads the body of a message that declares more than the ceiling' do - run_against('ACKF'.b + [Rex::Proto::OpcUa::Tcp::MAX_MESSAGE_SIZE + 1].pack('V')) + run_against('ACKF'.b + [Rex::Proto::OpcUa::Tcp::MAX_CHUNK_SIZE + 1].pack('V')) expect(reads).to eq [Rex::Proto::OpcUa::Tcp::HEADER_LEN] expect(output).to eq [[:vprint_status, 'No OPC-UA response to HEL']] @@ -423,4 +452,55 @@ def report_for(kind) expect(reports).to be_empty end end + + # The handshake decides how large a chunk this module will accept for the rest + # of the connection, so what the server says in its Acknowledge has to be + # checked rather than taken. + describe 'the negotiated chunk size' do + def ack_frame(send_buffer_size) + 'ACKF'.b + [28].pack('V') + [0, 65_535, send_buffer_size, 0, 0].pack('V5') + end + + it 'refuses a server that answers the Hello with an absurd buffer size' do + run_against(ack_frame(0xFFFFFFFF)) + + expect(output).to eq [ + [ + :print_status, 'OPC-UA handshake rejected - server advertised a SendBufferSize of 4294967295, ' \ + 'outside 1..4194304' + ] + ] + end + + it 'refuses a server that answers with a buffer size of zero' do + run_against(ack_frame(0)) + + expect(output.first.first).to eq :print_status + expect(output.first.last).to include 'SendBufferSize of 0' + end + + it 'stops before opening a channel when the handshake is refused' do + run_against(ack_frame(0xFFFFFFFF)) + + expect(sent.map { |frame| frame.byteslice(0, 4) }).to eq %w[HELF] + expect(reports).to be_empty + end + + # The captured server agrees to 65535, so from that point a chunk larger + # than the module said it could take is refused even though the standing + # 4 MiB ceiling would have allowed it. + it 'bounds later chunks by what the server agreed to' do + oversize = 'MSGF'.b + [70_000].pack('V') + run_against(ack + open_response + oversize) + + expect(output.last).to eq [:print_error, 'GetEndpoints failed: message size 70000 outside 8..65535'] + end + + it 'takes the size from the captured Acknowledge' do + run_against(conversation) + acknowledge = Rex::Proto::OpcUa::Tcp::AcknowledgeMessage.read(ack[8..]) + + expect(acknowledge.send_buffer_size.snapshot).to eq 65_535 + end + end end