UAFuzz is a fuzz testing tool designed to test the robustness of PLC (Programmable Logic Controller) applications that communicate via the OPC UA protocol. It generates sequences of OPC UA write operations (scenarios) with random or mutated input values to trigger unexpected behaviors or crashes in a target OPC UA server. UAFuzz was originally developed to fuzz a specific PLC application that processes incoming OPC UA data, but it can be adapted to any OPC UA-enabled system. The fuzzer supports both Windows and Linux, and it integrates with QEMU virtual machines to snapshot and restore the PLC application state between test cases.
- Directed OPC UA Fuzzing: Discovers all OPC UA nodes that are writable on the target and sends random or mutated values to those nodes, including support for different data types (e.g. Boolean, Int16, Float, etc.).
- Scenario Generation: Creates random scenarios consisting of multiple write operations (and optional delays) to simulate sequences of inputs. The length and complexity of scenarios are configurable (e.g. number of steps, number of unique nodes per scenario).
- Anomaly Detection (Oracles): Built-in oracles detect anomalies such as server disconnects, timeouts, or crash signatures. If an oracle flags a problem (e.g. the OPC UA server became unresponsive or returned an error), the fuzzer marks the scenario as a crash and can save the details for analysis.
- Crash Signature Matching: Supports persistent error detection by recording server-side exception messages or patterns (crash signatures) and re-matching them on replay or minimization to confirm reproducibility.
- QEMU Snapshot Integration: Optionally uses a QEMU QMP interface to manage VM snapshots. Before each scenario, UAFuzz can restore the target PLC VM to a clean base snapshot, and if a crash is detected, it can save a snapshot of the VM for post-mortem analysis. This ensures each test starts from a known good state.
- Interactive Dashboard (TUI): Provides a terminal UI dashboard (using the Textual library) to monitor fuzzing in real time. The dashboard shows metrics like number of executions, last scenario duration, average duration, and crash count, along with recent log messages. A headless (no-UI) mode is also available for running in CI or automated environments.
- Scenario Reproduction and Minimization:
- Replay: Re-executes saved testcases (
.jsonscenarios) to verify or demonstrate previously observed crashes. - Minimize: Automatically reduces a crashing scenario to its minimal triggering form, preserving the same error signature.
- Replay: Re-executes saved testcases (
Requirements: Python 3.11 or higher. The fuzzer uses the FreeOPC UA Python library (opcua>=0.98.13) for OPC UA client functionality, and the QEMU QMP library (qemu.qmp>=0.0.5) for VM control. Other dependencies include Pydantic v2 for configuration, and Rich/Textual for the UI. These will be installed automatically. Ensure you have QEMU installed (if using the snapshot feature) and an OPC UA server (the system under test).
You can install UAFuzz from the source repository. For example, using pip in the project directory:
pip install .This will install the uafuzz package and its dependencies. After installation, a command-line entry point uafuzz
will be available to start the fuzzer.
UAFuzz uses a TOML configuration file to set up the target and fuzzing parameters. By default, it looks for
config/base.toml (you can specify a different file with the -c/--config option). The configuration has sections for
OPC UA, scenario settings, and snapshot manager. For example:
[opc_ua]
url = "opc.tcp://127.0.0.1:48010" # OPC UA server endpoint
path_to_opc_ua_spec = "spec/spec.json" # Path to JSON file with writable nodes
[scenario]
max_steps = 200 # Max steps per scenario
max_unique_nodes_in_scenario = 100 # Max distinct nodes touched per scenario
[scenario.oracle_configs.crash_signature]
enabled = true # Enable crash signature matching
path = "config/crash_signatures.json" # Path to crash signatures list
[snapshot_manager]
qemu_qmp_socket = "127.0.0.1:4444" # QEMU QMP interface (host:port)In the opc_ua section, url is the OPC UA server address of the target application, and path_to_opc_ua_spec is a JSON file containing a list of all OPC UA nodes that can be fuzzed. This spec file is essentially the "attack surface" – UAFuzz will randomly choose nodes from this list to write values to. You can generate this file by scanning the OPC UA server (see Usage below).
The scenario section controls fuzzing scenario generation. For example, max_steps defines how many write operations (max) to include in each test scenario, and max_unique_nodes_in_scenario limits how many different nodes will be targeted in one scenario (to avoid scenarios that touch too many distinct points). You can also configure optional parameters like delay probabilities or oracle settings in this section (see documentation in code for advanced options). You can also configure the crash signature oracle by specifying the path to the list of crash signatures that the oracle will check after each executed scenario (if any of the listed crash signatures are detected on the server, an anomaly is recorded). The list of crash signatures is creating manually and including the most critical parameters for the application, for example:
[
{
"nodeId": "ns=2;s=Application.TESTS.controller_fault.Status.logic_state",
"crashValue": false
},
{
"nodeId": "ns=2;s=Application.TESTS.Outputs.Surge_Emergency",
"crashValue": true
}
]After a scenario execution, the server contains the value false in the node
ns=2;s=Application.TESTS.controller_fault.Status.logic_state, which triggers the first crash signature, and the
scenario is marked as anomalous.
The snapshot_manager section configures the QEMU QMP connection. qemu_qmp_socket should point to the QMP TCP socket
where a QEMU instance (running the PLC or target system) is listening. This is used to load and save VM snapshots. For
example, you might start QEMU with an argument like -qmp tcp:127.0.0.1:4444,server=on,wait=off and have a snapshot
named "base" inside that VM. If you are not using QEMU integration, you still need to provide a dummy value here to
satisfy the configuration, but note that the fuzzer will attempt to connect on start. (In a future update, we may
support running without a snapshot manager. Currently, you can use a stub instead of a QEMU QMP socket – see
tests/fakes.py)
Before running the fuzzer, you need to prepare the OPC UA specification JSON that lists all writable nodes of the target OPC UA server. UAFuzz provides a helper to do this. First, ensure the target OPC UA server (e.g. the PLC application) is running and accessible at the opc_ua.url address you set. Then you can use the uafuzz to scan it:
uafuzz save_spec -c config/base.toml -o spec/spec.json -f "GVL"This will connect to the OPC UA server, recursively browse the address space, and save all nodes that are writable to
the file specified in -o parameter. Also, you can use -f to skip some nodes by entering a value in the NodeId.
Once the spec JSON is ready, you can start fuzzing. Run the uafuzz command with the desired config:
# Run fuzzing with the default config (config/base.toml) and UI dashboard
uafuzz
# Or specify a config file and disable the interactive UI if running headless
uafuzz -c config/base.toml --no-uiWhen the fuzzer starts, it will load the list of nodes from the spec file, initialize the OPC UA client and connect to the target server, and (if configured) connect to the QEMU QMP socket to load the base snapshot. It then enters a loop to generate and execute scenarios continuously until you stop it. Each scenario consists of a series of steps, where each step is either a write to a node or a short delay. For example, a scenario might be: Write value X to NodeA, Write value Y to NodeB, Delay 50ms, Write value Z to NodeA. These operations are chosen randomly based on the fuzzing logic.
Interactive Dashboard: By default, UAFuzz launches a Textual UI dashboard in your terminal. This dashboard displays
real-time metrics such as the number of scenarios executed, the latest scenario name and duration, average execution
time, and how many errors/crashes have been detected. It also shows a rolling log of recent events (info, warnings,
errors). You can press "q" to quit the dashboard at any time, which will stop the fuzzer. If you run with --no-ui, the
fuzzer will run in the foreground logging to the console; use Ctrl+C to stop it.
During execution, if the fuzzer's oracles detect an anomaly (for instance, the OPC UA server dropped the connection or
returned an unexpected error), the framework will mark the scenario as a failure. In such cases, UAFuzz will do a few
things automatically: it will log the error, set context.anomaly = True, and use the snapshot manager to save a new VM
snapshot named after the scenario (e.g. with the scenario's unique name/tag). It will also save the scenario details
(the sequence of steps and node values) to a JSON file in the crashes/ directory for later analysis. This allows you to
review the exact sequence of writes that triggered a potential crash or bug in the target. If no anomalies are
detected, the scenario is considered "ok" and the fuzzer moves on to generate the next one after a brief pause.
Reproducing and Analyzing Crashes: To investigate a recorded crash scenario, you can load the corresponding VM snapshot (if using QEMU) to inspect the target's state at the time of crash, and replay the saved JSON of OPC UA writes to see if the issue can be reproduced. The saved scenario JSON contains:
- The sequence of steps (writes, delays)
- Scenario metadata and timestamp
- Oracle reports indication why the scenario was marked as anomalous
- Optionally, a
crash_signature_errorfield containing the matched server-side exception string (if available), used for later reproduction.
To verify that a saved scenario still triggers a failure, especially one with a known signature:
uafuzz replay -s crashes/random_write-d6b96026.json``This restores the VM to the base snapshot and replays the steps exactly as recorded. If the same
crash_signature_error is observed in response, the tool confirms that the scenario reliably reproduces the issue.
To reduce a crashing scenario to its minimal form (i.e., the shortest sequence that still causes the same crash signature):
uafuzz minimize -s crashes/random_write-d6b96026.jsonThe minimized version is saved with a _min suffix: random_write-d6b96026_min.json
You can also minimize all scenarios in a folder:
uafuzz minimize -s crashes/The minimizer iteratively removes or reorders steps, checking whether the crash_signature_error still matches after
each change.
Only scenarios with
crash_signature_errorare currently supported for minimization and replay. Other anomaly types (e.g., timeouts, disconnects) may not yield reliable minimization.
For instance, suppose you have a PLC program running in a QEMU VM with an OPC UA server at opc.tcp://127.0.0.1:48010.
You have generated a spec file listing all writable nodes on this server (say spec/spec.json, see
here). You start QEMU with a snapshot named "base" at a
clean state of the PLC. Now run the fuzzer:
uafuzz -c config/base.tomlYou will see a dashboard appear in the terminal. The fuzzer begins executing scenarios. The metrics cards update as
scenarios run – for example, "Executions" count increases, "Last scenario" shows the time taken for the last scenario,
and "Errors caught" increments if a crash is detected. The log panel will show messages like connecting to the OPC UA
server, writing values, and any warnings or errors encountered. If the PLC application crashes or disconnects, you
might see an error in the log (e.g. "Found disconnect error" from the DisconnectOracle) and the fuzzer will flag that
scenario as a crash. The UI will continue running, with the crash count increased. You can then quit the fuzzer (press
"q") and inspect the crashes/ directory for a JSON file corresponding to the crash scenario. This JSON will contain the
nodeId and value that were being written when the crash happened, among other steps, which is invaluable for debugging
the PLC application.
By default, UAFuzz configures logging via config/logging.yaml. The overall log level is set to DEBUG. You can adjust
the logging configuration (format, level, etc.) by editing that YAML file. If running with --no-ui, all logs will be
printed to the console. In a UI run, logs are still printed to the terminal (and captured in the log panel). Notably,
when a scenario with an anomaly is detected, the system logs an exception or error and saves the scenario JSON for
analysis, as mentioned above.
The project includes a suite of Pytest tests (see the tests/ directory) to verify the fuzzer's components. To run the
tests, install the development dependencies (e.g. pip install -e ".[dev]" which will bring in Pytest) and then execute
pytest.
Important: Some tests are integration-style and assume that an OPC UA server and a QEMU instance are available
(as configured in config/base.toml). For example, tests for the ScenarioExecutor and SnapshotManager will try to
connect to the OPC UA endpoint at 127.0.0.1:48010 and the QMP at 127.0.0.1:4444. If you don't have a server and VM
running locally at those addresses, those tests will fail or hang. In a development environment, you can run tests with
pytest -m "not real" for using a dummy OPC UA and QEMU QMP client classes.