Add OPC-UA protocol library and endpoint enumeration module - #21829
Add OPC-UA protocol library and endpoint enumeration module#21829ethan-thomason wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Metasploit auxiliary scanner module to enumerate OPC-UA server endpoints via OpenSecureChannel + GetEndpoints, report each endpoint’s advertised security settings and identity token types, and record weak (anonymous + unencrypted) endpoints as vulns; includes accompanying module documentation.
Changes:
- Introduces
auxiliary/scanner/scada/opcua_endpoint_enumimplementing OPC-UA TCP framing, chunk reassembly, and binary decoding forGetEndpoints. - Reports endpoint security posture (policy/mode, identity token types) to console and Metasploit DB (
report_service,report_note,report_vuln). - Adds module documentation with usage, port notes, test setup guidance, and scenarios.
Impact Analysis:
- Blast radius: low; new auxiliary module and new documentation only.
- Data and contract effects: low; adds new service/note/vuln records in DB for hosts scanned, no schema changes visible in diff.
- Rollback and test focus: rollback is deletion/revert of new files; focus validation on weak-endpoint classification logic and documentation accuracy.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| modules/auxiliary/scanner/scada/opcua_endpoint_enum.rb | New OPC-UA endpoint enumeration scanner with binary transport parsing and weak-endpoint reporting. |
| documentation/modules/auxiliary/scanner/scada/opcua_endpoint_enum.md | New user-facing documentation covering behavior, options, setup, and example runs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def unencrypted?(endpoint) | ||
| endpoint[:security_mode_name] == 'None' || endpoint[:security_policy_name] == 'None' | ||
| end |
| ### Setting Up a Test Server | ||
|
|
||
| Any OPC-UA server will exercise the module. Two convenient options: | ||
|
|
||
| **Inductive Automation Ignition (Docker)** |
| ## 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. |
There was a problem hiding this comment.
Also, run msftidy_docs.rb
bwatters-r7
left a comment
There was a problem hiding this comment.
I'd very much like to see the parsing stuff in it's own library, especially since I'd also like to see rspec testing to verify it.
| skip_string if (encoding & 0x40).positive? | ||
| skip(4) if (encoding & 0x80).positive? |
There was a problem hiding this comment.
Is there any chance these are reversed?
I read 0x80 means NamespaceUri is next and 0x40 means ServerIndex is next?
Citation: https://reference.opcfoundation.org/specs/OPC-10000-6/5.2.2.9
There was a problem hiding this comment.
Good catch, and you read it exactly right. Part 6 §5.2.2.9 has NamespaceUri = 0x80 and ServerIndex = 0x40, so a set 0x80 means a String follows and 0x40 means a UInt32. I had the two actions mapped to the wrong bits - skipping a string on 0x40 and four bytes on 0x80, the reverse of what it should be. Now fixed:
skip_string if (encoding & 0x80).positive? # NamespaceUri (String)
skip(4) if (encoding & 0x40).positive? # ServerIndex (UInt32)It slipped through because every server I tested returns endpoint NodeIds with neither flag set (namespace 0, no server index), so that branch never executed in the lab - exactly the kind of untested path a spec read catches and a live test doesn't. Appreciate you going to the reference.
I also checked the merged opcua_enum for the same pattern - it only does the HEL/ACK handshake and never parses NodeIds, so nothing there needs the correction. This was contained to this PR.
Done: re-ran msftidy_docs.rb (clean), rebased onto current master to pick up yesterday's CI fix, and force-pushed. Should be a clean single commit on a current base now.
|
One last note- I think we found the bug from yesterday that was causing the test issues and landed a fix for it just after you put up this PR. Could I ask you to please rebase this PR when you make those changes so we can avoid the CI issues we had yesterday? |
7b83d7a to
d23c70b
Compare
Done |
d23c70b to
9710f8d
Compare
Happy to do that. Would you rather it land in this PR, or as a follow-up that extracts the parser from both opcua_enum and opcua_endpoint_enum together? The library only makes sense covering both, and that means touching the already-merged one. |
|
Now that I look at it, have you considered using BinData for the parser class? |
I hadn't considered it but looking at queuejumper I think you're right. It would certainly clean things up and avoid bugs like you caught earlier where I had manual bit math backwards. 2 design considerations:
On sequencing — rewriting the parser inside this PR is a fair amount of churn for something that's about to move into a library anyway. My inclination is to land #21829 as-is and do BinData as part of the extraction, alongside the rspec coverage you asked about in #21830. Two things I'd want your call on there: whether you'd rather see BinData in this PR instead, and whether the extraction should port opcua_enum too, since it's already merged and a library serving one module seems like the wrong shape. I captured pcaps of the GetEndpoints exchange against 2 lab targets last night, including one over a WAN link where the response spans TCP segments. First spec will be that the BinData parser produces identical output to the current one against those captures, before I go near the hand-built edge cases. |
OK... I think I am following everything, but please correct me if I am wrong, and also I'm cognizant that this is unpaid labor, so let me know if I am asking too much. |
That makes sense and it's not too much to ask, maybe I'll get a msf t-shirt out of this. You're right It'll take me a few days, hope to have it ready by the end of the weekend. |
|
Pushed the library and the ported module. Full description to follow shortly. Short version: Rex::Proto::OpcUa with rspec coverage backed by captured fixtures, and opcua_endpoint_enum ported onto it. opcua_enum follows in a separate PR as we discussed. |
d7bf730 to
61074b7
Compare
What this doesAdds The library extraction is per @bwatters-r7's suggestion in #21830. The diff
The module drops 40% of its code. Nearly all of what's gone was protocol parsing that had no business in a module file. Library layout follows Testing365 examples. Fixtures in The strongest assertions are that the captured messages parse end to end with no bytes left over and re-encode byte-identically. Field-by-field checks can pass while a record is subtly wrong; those two can't. There's also a regression spec for the module ( Two paths had never executed before this: chunk reassembly and ERR status decoding. No lab server chunks a GetEndpoints response, and none of them misbehave. Both are now covered with hand-built frames per Part 6, which is only possible because Behavior changes
User-visible output is otherwise unchanged. The docs' Scenarios blocks still match a live run exactly. Two things worth flaggingBinData The fix is to extend a module after
Related: Spec citationsEvery record carries a Part 4 or Part 6 section reference, checked against the v1.05.07 documents. Worth noting for anyone touching OPC-UA code: nine of the Part 4 numbers in the original module were wrong. The numbering shifted between revisions - VerificationWith a database connected this records one service, one Both test targets are documented in the module docs as quoted Dockerfiles and commands, per your guidance: the Ignition gateway (including a pure-curl recipe for the OPC-UA bind address on 8.3.x, since it binds loopback by default) and the node-opcua server, which is the one that exercises the weak-endpoint path. |
| # 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 |
There was a problem hiding this comment.
The values above match https://www.ibm.com/docs/en/streamsets/7.x?topic=origins-opc-ua-client, but in recv_service_response, the implementation reads MAX_MESSAGE_SIZE up to MAX_CHUNKS instead of MAX_CHUNK_SIZE up to MAX_CHUNKS
That means the maximum message size in this implementation is more like 256 MB.
There was a problem hiding this comment.
Good catch. recv_message bounds a single chunk, recv_service_response bounds the chunk count, and nothing bounds the reassembled total - so 64 x 4MB is exactly right.
Worth noting how it got past the specs: there are examples for both ceilings and both pass. Each was tested in isolation and neither exercises them together. Two independently-correct guards composing into a weak one is precisely what an interaction test is for, and I didn't write one.
Chasing it turned up two more things. We send max_message_size: 0 in the Hello, not a value - zero is defined as no limit in 7.1.2.3, and max_chunk_count is zero too. So the module declines the protection the spec offers and then quietly applies limits of its own anyway.
7.1.2.2 also settles the per-chunk bound: "The OPC UA Connection Protocol layer shall verify the MessageType and make sure the MessageSize is less than the negotiated ReceiveBufferSize before passing any Message onto the SecureChannel layer." ReceiveBufferSize is the largest MessageChunk we can receive, and we negotiate 65535 - so the 4MB constant is 64x looser than the value we agreed to.
Fixing it as:
- Per-chunk bound becomes the negotiated ReceiveBufferSize from the ACK, per 7.1.2.2. The existing constant stays as
MAX_CHUNK_SIZE, capping what we'll accept as a negotiated value so a server can't answer the Hello with 4GB. - Advertise
MAX_MESSAGE_SIZEin the Hello instead of zero, and enforce it on the reassembled payload inside the loop - a hostile server won't honor what it was told. - Advertise
MAX_CHUNKSasMaxChunkCountinstead of zero, and keep enforcing it locally.
All three then trace to either a negotiated value or a limit we declared, rather than three numbers carried over from the module.
Plus a spec that pushes a stream of maximum-size chunks and asserts it fails on the cumulative bound before the chunk count runs out.
These values came across unchanged from the module, which had the same structure - but that's a reason it went unnoticed, not a defense. It matters more in a library, since every module built on this inherits the ceilings.
Will push shortly.
There was a problem hiding this comment.
Correction to my own fix list above: the ACK field that bounds incoming chunks is the server's SendBufferSize, not its ReceiveBufferSize. Both tables in 7.1.2 define their fields from the sender's perspective, and the sender of the Acknowledge is the server - so ACK.ReceiveBufferSize bounds what we send and ACK.SendBufferSize bounds what we receive. Implemented as min(our Hello ReceiveBufferSize, ACK SendBufferSize), since Table 75 says the server's SendBufferSize shall not exceed what we requested, and the min stops a non-conforming server from enlarging it.
|
Pushed. Two corrections to what I said above, one of them mine. The ACK field that bounds incoming chunks is the server's SendBufferSize, not its ReceiveBufferSize. Both tables in 7.1.2 define their fields from the sender's perspective, and the sender of the Acknowledge is the server - so ACK.ReceiveBufferSize bounds what we send and ACK.SendBufferSize bounds what we receive. Implemented as min(our Hello ReceiveBufferSize, ACK SendBufferSize): Table 75 says the server's SendBufferSize shall not exceed what we requested, and the min stops a non-conforming server from enlarging it. What landed:
One deliberate deviation: a server advertising below the 8192 minimum is accepted as its own tighter limit rather than rejected. A smaller figure can only make us allocate less, so rejecting it costs reach against sloppy servers for no safety gain. The constants now sit in a consistent relationship. At a negotiated 65535, 64 chunks x 65511 usable bytes = 4,192,704, just under the 4,194,304 cumulative ceiling - so a conforming server hits the chunk count first and the cumulative bound is the backstop for a connection that never negotiated one. There's an example pinning that inequality so it can't drift if either constant moves. 386 examples green. Verified against a live node-opcua server, output unchanged. |
Summary
Adds
auxiliary/scanner/scada/opcua_endpoint_enum, which enumerates the endpoints an OPC-UA server advertises and reports the security posture of each one.This follows up #21612 (
opcua_enum), which detects OPC-UA servers via the HEL/ACK handshake. This module goes a step further: it opens a secure channel withSecurityPolicy=Noneand callsGetEndpoints, then reports each endpoint's URL,MessageSecurityMode,SecurityPolicyUri, and acceptedUserIdentityTokentypes, plus the server'sApplicationUriandProductUrias a fingerprint.Endpoints accepting the Anonymous identity token over a channel with
MessageSecurityModeNone are flagged and recorded viareport_vuln- any host that can reach the port can connect with no credentials over an unencrypted channel, which is generally enough to read live process data and, depending on node permissions, write it.Why a separate module rather than an action on opcua_enum
opcua_enumsucceeds against servers where this one fails, so it isn't superseded. HEL/ACK is a transport handshake with no security negotiation, whileGetEndpointsrequires anOpenSecureChannel. The specification requires the discovery endpoint to acceptSecurityPolicy=Noneprecisely so clients can learn how to connect, but hardened deployments restrict it.opcua_enumremains the detector that works when the channel is closed.Verification
Tested against two independent OPC-UA implementations:
node-opcua (7 endpoints, 3 security policy families, includes an unsecured endpoint):
The parsed endpoint list matches the server's own startup log line for line, including ordering and the Aes128/Aes256 policy URIs.
Inductive Automation Ignition 8.3.4 (3 endpoints, all Basic256Sha256/SignAndEncrypt, no unsecured endpoint):
Worth noting that the endpoint list is returned even though no endpoint offers the None policy - the discovery channel is open by specification regardless of what the server's real endpoints require.
Output was also diffed field by field against an independent Python implementation using the
asyncuastack, and matches.Notes for reviewers
pack/unpack, following theiec104style.GetEndpointsresponses carry a certificate per endpoint and routinely span TCP segments; the largest observed was 10858 bytes.Fchunk. That path is written from Part 6 rather than validated on the wire.CloseSecureChannelis sent so channels are released rather than left to expire.