Skip to content

cann3v/uafuzz

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

52 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

UAFuzz: Fuzzer for PLC Applications via OPC UA

Overview

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.

Features

  • 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 (.json scenarios) to verify or demonstrate previously observed crashes.
    • Minimize: Automatically reduces a crashing scenario to its minimal triggering form, preserving the same error signature.

Installation

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.

Configuration

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)

Usage

Preparatory Actions

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.

Running the Fuzzer

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-ui

When 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_error field containing the matched server-side exception string (if available), used for later reproduction.

Replaying a Scenario

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.

Minimizing a Scenario

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.json

The 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_error are currently supported for minimization and replay. Other anomaly types (e.g., timeouts, disconnects) may not yield reliable minimization.

Example Scenario

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.toml

You 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.

Logging and Verbosity

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.

Running Tests

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.

About

Fuzzer for PLC applications via OPC UA

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages