Discover and audit OPC UA (IEC 62541) servers with Nmap — endpoints, security policies, application certificates and access levels, in one scan.
Nmap ships no OPC UA script and no OPC UA service probe. The two third party
scripts that exist stop right after the HEL/ACK handshake. These scripts go the
whole way: secure channel, chunked message assembly, endpoint enumeration,
certificate analysis, sessions, address space browsing and credential guessing —
in plain Lua, with no Python runtime, in a single nmap invocation.
$ nmap -p 4885 --script opcua-discover 192.0.2.10
PORT STATE SERVICE
4885/tcp open opcua-tcp
| opcua-discover:
| Protocol: OPC UA Binary (opc.tcp)
| Transport: protocol version 0, receive buffer 65535, send buffer 65535, max message 104857600, max chunks 1601
| Server:
| Application Name: OPC UA Test Server (legacy crypto)
| Application URI: urn:opcua-nse:test:legacy
| Application Type: ClientAndServer
| Endpoints (5):
| 1:
| URL: opc.tcp://192.0.2.10:4885/nse/
| Security: None / None (level 0)
| User tokens: Anonymous [None], UserName [Basic128Rsa15]
| 2:
| URL: opc.tcp://192.0.2.10:4885/nse/
| Security: Sign / Basic128Rsa15 (level 1)
| User tokens: Anonymous [None], UserName [Basic128Rsa15]
| Server Certificate:
| Subject: UaServer@opcua-nse-legacy, OPC UA NSE Test Lab, DE
| Issuer: UaServer@opcua-nse-legacy, OPC UA NSE Test Lab, DE (self-signed)
| Valid: 2026-08-24 13:11:38 UTC - 2027-08-24 13:11:38 UTC
| Key: rsa 1024 bits, sha1WithRSAEncryption
| ApplicationUri (SAN): urn:wrong:application:uri
| SHA-256: E07B682FDF076A2BCBF139163E1F399EBB4F7211ED39446DF378E65230237A0D
| Issues:
| HIGH: weak 1024 bit RSA key
| HIGH: weak certificate signature algorithm: sha1WithRSAEncryption
| MEDIUM: certificate URI does not match ApplicationUri (urn:wrong:application:uri vs urn:opcua-nse:test:legacy)
| LOW: certificate is self-signed
| Security Findings:
| HIGH: Anonymous access allowed (endpoints 1, 2, 3, 4, 5)
| HIGH: No securely configured endpoint
| HIGH: SecurityMode None (endpoint 1)
| MEDIUM: Deprecated SecurityPolicy Basic128Rsa15 (endpoints 2, 3)
|_ MEDIUM: Deprecated SecurityPolicy Basic256 (endpoints 4, 5)- The scripts
- Installation
- opcua-discover
- opcua-info
- opcua-browse
- opcua-brute
- Service probe for -sV
- Ports
- Test environment
- Running the tests
- The library
- How this compares
- Scope
| Script | Category | What it does |
|---|---|---|
opcua-discover |
discovery, safe, default |
Endpoints, security modes and policies, user tokens, certificate analysis, security findings, lateral discovery |
opcua-info |
discovery, intrusive |
Opens a session and reads BuildInfo, ServerStatus, namespaces, service level and RBAC state |
opcua-browse |
discovery, intrusive |
Walks the address space and reports what the session may write or execute |
opcua-brute |
brute, intrusive |
Password guessing against the UserName identity token |
broadcast-opcua-discover |
broadcast, discovery, safe |
Finds servers over multicast DNS, without scanning a single port |
They share one library, opcua.lua, which holds the entire protocol
implementation. The split follows Nmap's categories rather than convenience:
only opcua-discover is safe, because it never opens a session, so a routine
-sC scan of a plant network cannot create sessions or attempt logins.
Everything is read-only. The scripts never write a value and never call a method — a writable node is proven from its AccessLevel attribute, not by writing to it.
sudo cp opcua.lua /usr/share/nmap/nselib/
sudo cp opcua-*.nse /usr/share/nmap/scripts/
sudo nmap --script-updatedb
nmap -p 4840 --script opcua-discover <target>Running from the checkout without installing
The scripts can be given by path, but the library has to be findable. Point
NMAPDIR at a directory that contains it:
mkdir -p /tmp/nsedev/nselib
ln -s "$PWD/opcua.lua" /tmp/nsedev/nselib/opcua.lua
NMAPDIR=/tmp/nsedev nmap -p 4840 --script ./opcua-discover.nse <target>Common invocations:
nmap -p 4840 --script opcua-discover <target> # discovery only
nmap -p 4840 --script "opcua-* and not brute" <target> # everything but password guessing
nmap -p 4840 -sC <target> # opcua-discover runs by default
nmap -p 4840 --script opcua-discover --script-args newtargets <target>Performs the connection handshake, opens a secure channel with SecurityPolicy None and calls the discovery services that OPC 10000-4 explicitly allows without a session.
Endpoint configuration
| Finding | Severity |
|---|---|
UserName token over SecurityPolicy None on an unencrypted endpoint — passwords in cleartext |
CRITICAL |
SecurityMode None |
HIGH |
SecurityPolicy None |
HIGH |
| Anonymous identity token accepted | HIGH |
| No endpoint at all combining SignAndEncrypt with a current policy | HIGH |
Deprecated Basic128Rsa15 or Basic256 (SHA-1, deprecated in OPC UA 1.04) |
MEDIUM |
| Weak certificate on an endpoint that advertises signing or encryption | MEDIUM |
SecurityMode Sign only — authenticated but readable in transit |
LOW |
SecurityLevel 0 — the server's own admission that an endpoint offers nothing |
LOW |
Application certificate, parsed from the DER blob in the EndpointDescription:
- self-signed, expired, or not yet valid
- RSA keys below 2048 bits, MD5 or SHA-1 signatures
- the subjectAltName URI not matching the announced ApplicationUri, which OPC 10000-4 requires
- SHA-256 fingerprints correlated across every host in the scan, so a vendor shipping identical key material on many devices shows up in a post-scan summary:
Post-scan script results:
| opcua-discover:
| Shared server certificates:
|_ 967B2C24009A8F74... used by 2 hosts: 192.0.2.10:62541, 192.0.2.11:48010 (UaServer@opcua-nse-shared, OPC UA NSE Test Lab, DE)
Lateral discovery
FindServersreveals further applications and their discovery URLs, on other hosts and ports as well. With--script-args newtargetsthose hosts are added to the scan queue.FindServersOnNetworkreturns the registration cache of a Local Discovery Server with multicast extension.
Rejected handshakes still count. A server that answers ERR has understood
the message, so it is identified anyway:
| opcua-discover:
| Protocol: OPC UA Binary (opc.tcp)
| Status: confirmed by protocol error response
|_ Server response: server rejected HEL: Bad_TcpEndpointUrlInvalid (0x80830000) - The endpoint URL is not supported by this server.
| Argument | Default | Meaning |
|---|---|---|
opcua-discover.timeout |
from the timing template | Socket timeout in milliseconds |
opcua-discover.endpoint-url |
derived from the target | Endpoint URL to request |
opcua-discover.discovery-paths |
true |
Also try /discovery and /UADiscovery |
opcua-discover.find-servers |
true |
Call FindServers and FindServersOnNetwork |
opcua-discover.certs |
true |
Show the parsed certificate |
opcua-discover.recv-buffer |
65535 |
ReceiveBufferSize to advertise; lower values force chunked replies |
opcua-discover.all-ports |
off | Try every open TCP port, not just the known OPC UA ports |
opcua-discover.vulns |
off | Also report findings through Nmap's vulns library |
newtargets |
off | Add discovered hosts to the scan queue |
Creates a session and reads the Server object. This is the only way to learn the stack version over the network, and it feeds Nmap's version detection with product, version and a derived CPE.
$ nmap -p 4840 --script opcua-info 192.0.2.10
| opcua-info:
| Session: anonymous on opc.tcp://192.0.2.10:4840/nse/ (SecurityMode None)
| Build Information:
| Manufacturer: FreeOpcUa
| Product: FreeOpcUa Python Server
| Product URI: urn:freeopcua.github.io:python:server
| Software Version: 1.0pre
| Build Number: 0
| Server Status:
| State: Running
| Started: 2026-08-24 13:11:41Z
| Server time: 2026-08-24 13:12:12Z (clock skew -1s)
| Namespaces:
| 0: http://opcfoundation.org/UA/
| 1: urn:opcua-nse:test:insecure
| 2: http://opcua-nse.test/plant
| Service Level: 255/255
| Diagnostics:
| Sessions: 1 open, 21 since start
| Rejected sessions: 2 rejected, 0 on security grounds
|_ Role based access control: 14 roles: Anonymous, AuthenticatedUser, Observer, Operator, Engineer, Supervisor, ...The session counters come from the server's own ServerDiagnosticsSummary: how many clients are connected right now, how many have been since it started, and how many it turned away. On an assessment that answers the question of whether anyone else has been here.
The namespace URIs identify vendor and device model, the clock skew is worth
knowing before reading timestamps, and a missing RoleSet node means the server
predates the role model introduced in OPC UA 1.04.
A server that enforces access control is reported as such rather than producing an empty result — the difference between "found nothing" and "the server is doing it right":
| Session: anonymous on opc.tcp://192.0.2.10:4897/nse/ (SecurityMode None)
|_ Reads denied: service fault: Bad_SecurityChecksFailed (0x801F0000) -- the session is not authorised to read the Server object; access control is enforced
Arguments: username, password, timeout, endpoint-url.
Walks the address space breadth first and reads AccessLevel, UserAccessLevel, Executable and UserExecutable. UserAccessLevel is what the current session may do, so a variable listed here is writable by whoever can reach the port.
$ nmap -p 4840 --script opcua-browse --script-args opcua-browse.depth=2 192.0.2.10
| opcua-browse:
| Session: anonymous (SecurityMode None)
| Address space: 40 nodes visited, depth 2
| Writable by this session (3):
| ns=2;i=2 Plant/Temperature read+write
| ns=2;i=3 Plant/Pressure read+write
| ns=2;i=4 Plant/Setpoint read+write
| Methods callable by this session (6):
| ns=2;i=7 Multiply
| i=11492 Server/GetMonitoredItems
|_ Finding: HIGH: an anonymous session can write 3 variable(s); anyone able to reach this port can change process data.On a plant this is the difference between an information leak and a control problem. The script establishes it from the access level bits alone — it never writes and never calls.
Arguments: depth (default 3), max-nodes (default 200), root (default 85,
the Objects folder), tree, username, password, timeout.
Guesses UserName credentials through ActivateSession, built on Nmap's brute
library, so the usual userdb, passdb and brute.* arguments apply.
$ nmap -p 4840 --script opcua-brute --script-args userdb=users.txt,passdb=pass.txt 192.0.2.10
| opcua-brute:
| Accounts:
| operator:operator - Valid credentials
| engineer:Password1 - Valid credentials
| Statistics: Performed 8 guesses in 1 seconds, average tps: 8.0
|_ Note: this endpoint accepts UserName tokens over SecurityPolicy None, so passwords are transmitted in cleartextThe script needs an endpoint whose UserName token policy is None, because any
other policy requires the password to be encrypted with the server's public key.
That restriction is itself the finding: if the script can run at all, the server
accepts passwords in cleartext.
OPC 10000-12 has every host with OPC UA applications run a Local Discovery
Server with the multicast extension, announcing its servers as
_opcua-tcp._tcp.local. One multicast query then maps a segment that a port
scan would take minutes to cover — including servers on ports nobody would
think to scan.
$ nmap --script broadcast-opcua-discover --script-args newtargets -sn
Pre-scan script results:
| broadcast-opcua-discover:
| 192.0.2.10:
| Discovery URL: opc.tcp://192.0.2.10:4840/nse/
| Capabilities: LDS,DA
|_ Added to scan queue: 192.0.2.10It runs as a prerule, so with newtargets the hosts it finds are scanned in the
same invocation.
Nmap has no OPC UA probe, so -sV cannot identify these servers — which matters
because they mostly listen on vendor ports (49320 KEPServerEX, 53530 Prosys,
62541 OPC Foundation stack). nmap-service-probes.opcua adds one:
cat /usr/share/nmap/nmap-service-probes nmap-service-probes.opcua > /tmp/probes
nmap -sV --versiondb /tmp/probes -p 4840,49320,53530,62541 <target>PORT STATE SERVICE VERSION
48010/tcp open opcua-tcp OPC UA Binary Protocol
The probe sends a well formed HEL message. Both an ACKF and an ERRF reply
identify the protocol, because a server that rejects the endpoint URL has still
understood the message — and the rejection reason is reported too:
4845/tcp open opcua-tcp OPC UA Binary Protocol (endpoint URL rejected)
An upstream pull request for OPC UA detection already exists (nmap/nmap#2791, open since March 2024). Until it lands, this file is how you get the detection locally.
The scripts run on 4840, 4843, 4845, 4855, 4885, 4897, 26543, 48010–48050,
49320, 49380, 51210, 53530 and 62541, and on any port whose service name is
opcua-tcp, opc-ua-tcp, opcua or opcua-tls. Add
--script-args opcua-discover.all-ports to try every open port instead.
test-server/ holds a docker-compose matrix that reproduces every case the
scripts claim to detect, across three independent OPC UA implementations —
asyncua (Python), open62541 (C) and node-opcua (JavaScript). It is what the CI
integration job runs against.
| Port | Mode | What it exercises |
|---|---|---|
| 4840 | insecure |
SecurityPolicy None, anonymous full access, writable nodes, valid credentials for the brute script |
| 4843 | lds |
An open62541 Local Discovery Server with the multicast extension — the only kind of server that answers FindServersOnNetwork |
| 4845 | fault |
Answers every handshake with ERR, so the error path stays covered |
| 26543, 48040, 48050, 49320, 49380, 51210, 53530 | hostile |
Completes the handshake and then lies: four-billion-element arrays, negative lengths, endless chunks, truncated messages, unknown chunk types, a 2 GB string, a byte every two seconds |
| 4855 | secure |
Seven endpoints across all modern policies, valid 2048 bit certificate — also the chunking case |
| 4885 | legacy |
Basic128Rsa15 and Basic256 with a 1024 bit SHA-1 certificate whose URI does not match |
| 4897 | expired |
Expired certificate, and anonymous sessions denied read access |
| 48010, 62541 | shared-a, shared-b |
The same certificate on two hosts, for fingerprint correlation |
| 48020 | open62541 |
A second implementation, in C: string NodeIds, GUID authentication tokens, its own encoding choices |
| 48030 | node-opcua |
A third implementation, in JavaScript |
cd test-server
pip install cryptography
python3 gen_certs.py # certificates, including deliberately broken ones
docker compose up -d --buildSee test-server/README.md for the details.
./test/run-tests.sh --unit # library unit tests only, no docker, no network
./test/run-tests.sh # unit tests plus integration against the matrix== opcua-discover against the secure server (port 4855) ==
PASS all seven endpoints decoded
PASS modern policy recognised
PASS certificate parsed
PASS SAN URI matches ApplicationUri
...
== Summary ==
57 passed, 0 failed
The 50 unit tests live inside opcua.lua and run through Nmap's own framework,
against byte fixtures rather than a live server:
nmap --script unittest --script-args "unittest.run,unittest.tests={opcua}"CI runs three jobs on every push — unit tests, shellcheck, and the full
integration suite against the container matrix. See
.github/workflows/ci.yml.
opcua.lua is a self-contained OPC UA client for NSE, usable on its own:
local opcua = require "opcua"
local conn = opcua.Connection:new(host, port)
conn:connect() -- TCP plus HEL/ACK with buffer negotiation
conn:open_secure_channel() -- SecurityPolicy None
local endpoints = conn:get_endpoints()
local findings = opcua.assess_endpoints(endpoints)
conn:close()What it covers:
- Transport — HEL/ACK/ERR with negotiated limits, decoded status codes,
chunk assembly for
C,FandAchunks, sending chunked requests - Binary encoding — a cursor object for every built-in type, including NodeId in all five encodings, ExpandedNodeId, Variant with arrays, DataValue, ExtensionObject, DiagnosticInfo and DateTime as Windows FILETIME
- Services — OpenSecureChannel, GetEndpoints, FindServers, FindServersOnNetwork, CreateSession, ActivateSession, Read, Browse, CloseSecureChannel
- Assessment — policy ratings, endpoint findings, and certificate analysis
through
sslcert.parse_ssl_certificate, which digests the raw DER directly
| ot-blue-team | msf-opcua | opcua-scan | OpalOPC | this | |
|---|---|---|---|---|---|
| HEL/ACK detection | yes | yes | yes | yes | yes |
| ERR status decoding | no | no | no | no | yes |
| Chunk reassembly | no | yes | yes | yes | yes |
| GetEndpoints | no | yes | yes | yes | yes |
| FindServers, new targets | no | no | yes | yes | yes |
| Certificate analysis | no | no | no | yes | yes |
| Cross-host certificate correlation | no | no | no | no | yes |
| Session, BuildInfo, CPE | no | yes | yes | yes | yes |
| Access level audit | no | yes | yes | yes | yes |
| Credential guessing | no | yes | partly | yes | yes |
| Writes to the target | no | no | yes | no | no, by design |
| Runs natively in Nmap | yes | no | no | no | yes |
The comparison targets are ot-nmap-blue-team, COMSYS/msf-opcua, wavestone-cdt/opcua-scan and OpalOPC.
These scripts are for authorised assessments and asset inventory. Only scan systems you have permission to scan.
They are read-only by design: Write and Call are deliberately not
implemented, because in a plant a written setpoint is an incident, not a test
result. Writable nodes and callable methods are reported from their attributes.
A scanner points itself at unknown ports, so the peer may answer with anything. The library caps what it will accept — 512 chunks and 8 MB per reassembled message, strict array and string lengths — and the test suite attacks it with seven hostile answers to prove each one is refused within a time budget rather than believed.
Why it matters: an internet-wide measurement of 1 114 reachable OPC UA deployments found 92 % configured deficiently — 26 % with security disabled, 25 % on deprecated cryptography, 44 % allowing anonymous access (Dahlmanns et al., ACM IMC 2020). Almost all of it is visible without a single login.
Same as Nmap — see https://nmap.org/book/man-legal.html.