## Why The encoder/decoder fuzz targets only flagged crashes, unhandled exceptions and encoding-fidelity mismatches. They could not observe a decode that stays within the exception contract but consumes far more memory, CPU or stack than its input justifies, or that materialises values larger than the context's encoding limits. None of the resource-related findings of the codec audit would have been caught. ## What Adds four shared oracles to the fuzz harness (`fuzzing/Common/Fuzz/FuzzOracles*.cs`), applied by every host (libFuzzer, AFL, `--replay`, Tools playback and the NUnit replay): - **Allocation** — wraps the Binary/JSON/XML decode cores, the built-in-type targets, the PubSub UADP/JSON decoders and the UADP reassembler; fails when a decode allocates more than `64x` its input plus `8 MB`, whether it returns or throws. Uses the per-thread allocation counter (the AppDomain counter on .NET Framework). - **Time** — a per-input budget of `1 s + 100 ms/KB`, re-run twice and reported only if the fastest run is still over, so JIT/GC/CI noise does not trip it (`OPCUA_FUZZ_TIME_BUDGET_SCALE`, `0` disables). - **Stack** — every input runs on a worker thread with a `256 KB` stack, so recursion that comes close to the limit of a normal thread is caught in-process as a crash rather than near 200 levels. `StackTestcases` inputs replay through every target in a child process (`--fuzz-replay-all`), since such a crash cannot be caught. - **Decoded limits** — walks a decoded value for strings, identifiers, ByteStrings and arrays beyond the context's `MaxStringLength`/`MaxByteStringLength`/`MaxArrayLength`; matrices are additionally boxed the legacy way so a value no consumer can materialise is reported. The fuzz context uses `4096/8192/4096` so small inputs reach the limits. Supporting changes: - `ArrayOf<T>`/`MatrixOf<T>` are not `IEnumerable`, so the limit walker unpacks them via `ToArray`/`ToArrayOf` (otherwise it silently traversed nothing). - `Decimal` is registered in the fuzz context so the targets exercise `Decimal.Decode`. - **B1-2 bug fix**: `BinaryDecoder.ReadExtensionObject` dereferenced a null element for an XML body that is not an element; now rejected with `BadDecodingError`. Found by replaying the nightly crash corpus under the new exception contract; 5 corpus inputs are added as gating reproducers. - Reproducers for B1-1, T1-3, X1-1 and U1-3, built in process by documented builders (`ResourceReproducerTests`, `PubSubReproducerTests`) and checked in to the corpus, where every target replays them under the oracles. Each was verified by reverting the matching fix locally and confirming the guard fires (e.g. B1-1 then allocates ~21 MB from 481 input bytes). - `Fuzzing.md` / `CrashCorpus.md` document the oracles and the private-input log policy. ## Testing Green on net10.0 and net48: | Suite | net10.0 | net48 | |---|---|---| | `Opc.Ua.Encoders.Fuzz.Tests` | 8130 | 8130 | | `Opc.Ua.PubSub.Fuzz.Tests` | 149 | n/a (modern-only) | | `Opc.Ua.Fuzzing.Tests` | 154 | 154 | | `Opc.Ua.Types.Tests` (encoders) | 3698 | builds | Also verified: `Network`/`Certificates` fuzz test suites, all fuzz hosts and Tools projects build, the fuzz manifest validator passes, `dotnet format whitespace` is clean, and the full ~22k-input nightly crash corpus replays with no oracle false positives. Stacked on `romanett/encoder-decoder-spec-security-83c9f5`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
22 KiB
Fuzz testing for UA.NET Standard
This project provides integration of SharpFuzz with the
UA.NET Standard libraries, with support for both afl-fuzz and
libFuzzer. Each fuzz area lives directly under fuzzing/
as four sibling folders — host (*.Fuzz), corpus (*.Fuzz.Corpus), test fixture (*.Fuzz.Tests),
and tools (*.Fuzz.Tools) — and shares a generic NUnit replay harness plus the SharpFuzz host
under Common/.
Areas
| Area | Surface | Status |
|---|---|---|
Opc.Ua.Encoders.Fuzz |
Contiguous/segmented binary, JSON/XML round-trips, binary/XML-to-JSON conversions, built-in type readers and string parsers | shipped |
Opc.Ua.Certificates.Fuzz |
Certificate loading, default/ASN.1 chain parsing, signed CRL decoding and canonical TBS encoding, X509 extensions, PEM, PKCS#10 and ASN.1 helpers | shipped |
Opc.Ua.Network.Fuzz |
Offline TCP/UA-SC framing, reassembly and TcpMessageParsers; the secure-channel fixture uses SecurityPolicy None |
shipped |
Opc.Ua.PubSub.Fuzz |
Metadata-backed JSON, UADP decoding and chunk reassembly | modern .NET |
Encoders and Certificates build for the repo's standard TestsTargetFrameworks matrix
(net48, net8.0, net9.0, net10.0). Network and PubSub are modern .NET only;
Network uses net8.0;net9.0;net10.0 because
Opc.Ua.Core.Diagnostics does not target .NET Framework.
Directory layout
fuzzing/
Fuzzing.md # this file
Common/ # shared source (no csproj)
Fuzz/
Program.cs, FuzzMethods.cs # SharpFuzz host
Fuzz.Tests/
FuzzTargetTestsBase.cs, FuzzTargetFunction.cs
TestcaseAsset.cs, TestAssetUtils.cs # generic NUnit harness
Fuzz.Tools/
Program.cs, Playback.cs, Logging.cs, Testcases.cs # Tools host
Dictionaries/ # libFuzzer / afl dictionaries
asn1.dict binary.dict json.dict nodeid.dict
tcp.dict uasc.dict xml.dict
Scripts/ # area-agnostic runners
fuzz-afl.ps1 fuzz-libfuzzer.ps1 fuzz-menu.ps1
install.sh readme.txt
Opc.Ua.Encoders.Fuzz/ # csproj + FuzzableCode partials
Opc.Ua.Encoders.Fuzz.Corpus/ # seed corpus (Testcases.*/)
Opc.Ua.Encoders.Fuzz.Tests/ # deterministic NUnit replay
Opc.Ua.Encoders.Fuzz.Tools/ # corpus generator + playback
Opc.Ua.Certificates.Fuzz/ Opc.Ua.Certificates.Fuzz.Corpus/
Opc.Ua.Certificates.Fuzz.Tests/ Opc.Ua.Certificates.Fuzz.Tools/
Opc.Ua.Network.Fuzz/ Opc.Ua.Network.Fuzz.Corpus/
Opc.Ua.Network.Fuzz.Tests/ Opc.Ua.Network.Fuzz.Tools/
Opc.Ua.PubSub.Fuzz/ Opc.Ua.PubSub.Fuzz.Corpus/
Opc.Ua.PubSub.Fuzz.Tests/ Opc.Ua.PubSub.Fuzz.Tools/
Opc.Ua.Fuzzing.Tests/ # shared harness negative controls
OneFuzz/ # publication and callback validation
fuzz-targets.json # behavior/corpus/oracle inventory
fuzz-parity.runsettings # fuzz-only production coverage
The flat layout matches the rest of the repository (tests/, src/, src/ also list
projects directly under the root with no domain grouping). Every csproj folder name equals the
csproj filename (without the .csproj extension), and every assembly is prefixed with Opc.Ua.
in line with the repo's AssemblyPrefix convention.
Each area's *.Fuzz project hosts FuzzableCode.*.cs partials whose public static methods
with a single parameter of type Stream (afl), string (afl), or ReadOnlySpan<byte>
(libFuzzer), returning void and without generic parameters, are auto-discovered by:
- the SharpFuzz host (
Common/Fuzz/Program.cs) at fuzz-run time, and - the generic NUnit harness (
Common/Fuzz.Tests/FuzzTargetTestsBase.cs) at test time — every target is replayed against the area's*.Fuzz.Corpus/Testcases.*/corpus plus crash/timeout assets, with no per-area test code required.
Each area's *.Fuzz.Tests is therefore a single thin subclass:
[TestFixture]
[Category("Fuzzing")]
public class EncoderTests : FuzzTargetTestsBase
{
[DatapointSource]
public static readonly FuzzTargetFunction[] FuzzableFunctions =
CreateFuzzTargetFunctions(typeof(FuzzableCode));
protected override Type FuzzableCodeType => typeof(FuzzableCode);
}
How to add a new fuzz area
- Pick a surface. Anything that takes untrusted input (bytes, string, or stream) is a candidate. Prefer surfaces with deterministic output and bounded resource usage.
- Copy the four
Opc.Ua.Encoders.Fuzz*folders toOpc.Ua.<NewArea>.Fuzz*. Rename the three csproj files and update<AssemblyName>(keep theOpc.Ua.prefix) and<RootNamespace>. - Replace
FuzzableCode.*.cswith your area's targets. Naming convention:Aflfuzz<Target>(Stream|string)for afl-fuzz andLibfuzz<Target>(ReadOnlySpan<byte>)for libFuzzer. Each target must define a narrow, parser-specific malformed-input policy. Do not use a generic exception allowlist: anArgumentExceptioncan also be a programming error. Inspect wrapped exceptions before accepting a status code, and never suppress a failed oracle or an exception while decoding output the target itself just generated. - Subclass the harness.
Opc.Ua.<NewArea>.Fuzz.Tests/<NewArea>Tests.csinherits fromFuzzTargetTestsBase, setsFuzzableCodeType => typeof(FuzzableCode), and exposes the[DatapointSource]FuzzableFunctionsfield withCreateFuzzTargetFunctions(typeof(FuzzableCode)). - Generate seeds. Add
<NewArea>.Testcases.cstoOpc.Ua.<NewArea>.Fuzz.Tools/that builds valid sample inputs through the real producer code and writes them underOpc.Ua.<NewArea>.Fuzz.Corpus/Testcases.<Bucket>/. EachTestcases.*/subdirectory becomes a logical seed bucket and is auto-discovered byTestAssetUtils.DiscoverTestcaseEncoderSuffixes. - Add a dictionary. Put
<NewArea>.dictunderfuzzing/Dictionaries/with common tokens / magic bytes / length sentinels — this dramatically improves libFuzzer coverage progression. Every line must be a loadable libFuzzer entry (name="token"or"token") with a nonempty token and escaped inner quotes. libFuzzer aborts the entire campaign on a single malformed line, so an unloadable dictionary silently disables every target that declares it;validate-fuzz-manifest.ps1enforces this. Dictionaries are optional per target. - Wire into
UA.slnx. Add the three new projects to the/fuzzing/folder, the*.dictfile under/fuzzing/Dictionaries/, and the seed loose files you want visible in the IDE. - Register the target. Update
fuzz-targets.jsonwith its adapters, positive corpora, dictionaries, oracle and fork behavior mapping. Add success-path assertions in addition to cross-target malformed-input replay. Validate both the local host and published callback. - Update this
Fuzzing.md. Add a row to the area table.
The CI test matrix discovers fuzz test projects like any other test project. GitHub Actions
also has an explicit fuzz replay matrix, including changes only to seeds, dictionaries and
scripts. Scripts/test-fuzzing.ps1 runs the applicable projects and rejects empty or skipped
test runs. fuzz-parity.runsettings includes generated protocol methods in fuzz-only production
coverage; it does not change the repository-wide coverage policy.
Fuzz scripts pass -p:FuzzCoverage=true so generated protocol data types omit their
ExcludeFromCodeCoverage attributes in that build only. A runsettings include filter
cannot override those compiled attributes. Ordinary builds retain the exclusions.
Resource oracles
Crash, exception and fidelity checks never flag an input that makes a decoder allocate
gigabytes, recurse close to the stack limit or run for seconds, as long as it finally returns
or throws the documented error. FuzzOracles turns those into
findings for every host (libFuzzer, AFL, --replay, Tools playback and the NUnit replay):
| Oracle | Where | Finding |
|---|---|---|
| Stack | FuzzMethods, NUnit replay |
Every input runs on a worker thread with a 256 KB stack. Recursion that is unguarded, or guarded only by MaxEncodingNestingLevels, overflows at a depth mutation can reach instead of only past about 200 levels on a 1 MB thread. The overflow ends the process like any crash. |
| Time | FuzzMethods, NUnit replay |
An input over 1 s + 100 ms per KB is re-run twice; it is a ResourceBudgetException only if the fastest run is still over budget, so JIT, GC and CI noise are not reported. |
| Allocation | decode cores (Binary/JSON/XML, built-in types, PubSub UADP/JSON, UADP reassembler) | The decode allocated more than 64x its input + 8 MB, whether it returned or threw. It uses the per-thread allocation counter, or the AppDomain counter on .NET Framework. |
| Limit | Encoders decode cores | A decoded string, NodeId/QualifiedName/LocalizedText text, ByteString or array is longer than the limits of the context. Matrices are also boxed the legacy way, so a value no consumer can materialise fails. |
The Encoders fuzz MessageContext uses MaxStringLength 4096, MaxByteStringLength 8192 and
MaxArrayLength 4096, so the limit oracle is reachable with inputs of a few KB. Raw
ExtensionObject bodies and XmlElement values are bounded by the message size, not checked.
Overrides for local investigation: OPCUA_FUZZ_TIME_BUDGET_SCALE scales the time budget (0
disables it) and OPCUA_FUZZ_STACK_KB sets the worker stack (0 runs on the calling thread).
Do not relax them in CI.
Deeply nested inputs belong in Opc.Ua.Encoders.Fuzz.Corpus/StackTestcases/ (copied to the StackTestcases output folder).
FuzzStackTestcasesAsync replays each one through every target with --fuzz-replay-all in a
child process, because a stack overflow cannot be caught and would end the test host.
A resource finding on a crash asset outside Assets/Repo logs only the asset name and size, never
a REPRODUCER line: it may be an unfixed resource-abuse regression.
Areas in detail
Encoder profiles and oracles
Binary, JSON and XML canonical targets retain exact re-encoded output checks and decoded-value equality. Conversion targets use a known message type and explicit namespace context. RawData conversion restores only omitted type artifacts from known metadata before typed decoding; it does not repair payload values or claim arbitrary metadata-free JSON is reversible.
| Profile | Behavior |
|---|---|
| Verbose / Compact | Current supported options with semantic and canonical round-trip assertions |
| RawData | Current artifact-suppressed JSON, with metadata-backed decoding |
| LegacyReversible | Current Compact-based option profile exercising defaults and namespace indices |
| LegacyNonReversible | Alias of current RawData behavior; not additional independent production coverage |
The Legacy* names identify comparison profiles, not legacy wire-format support.
Retired object-form identifiers, old Type/Body envelopes and other unrepresented distinctions
remain partial mappings in the manifest. Do not declare them equivalent from their names or
count the RawData alias twice. Full parity requires resolving or proving those behavior mappings,
alongside matched-input production coverage and internal worker evidence.
Rich deterministic ReadRequest, ReadResponse, PublishResponse and WriteRequest fixtures exercise
multiple NodeId forms, namespaces, diagnostics, matrices, structured ExtensionObjects and arrays.
Existing seeds and original crash/timeout/slow reproducers are retained.
The 12 original fork message files are hash-pinned under Assets/ForkMessages and are
included in matching published-target corpora, without normalization or regeneration.
All four binary originals and the original XML requests have positive typed-decoding checks.
The pinned fork itself rejects the two response XML files' nested ListOf matrix encoding;
those files remain explicit rejection cases, not claimed successful-path coverage.
Original JSON TypeId/Body envelopes remain unsupported by the stack; replaying their expected
rejection does not close that compatibility gap.
Canonical binary (contiguous and segmented), JSON and XML targets also declare the three
historical regression inputs explicitly for published replay.
Network / Transport area — Opc.Ua.Core.Diagnostics + Core UA-SC seam
The Network area is unusual because it's split across two complementary entry points:
-
Fuzzes the public surfaces of
src/Opc.Ua.Core.Diagnostics:OpcUaFrameParser.Process(TCP → UA-SC chunk splitter),TcpStreamReassembler.Process(raw TCP),OfflineSecureChannel.ReadChunk(UA-SC parsing with the existing SecurityPolicy None fixture), andServiceCallReassembler.Push(chunk → service-call assembly with mixed sequence numbers, request ids, oversize bodies). Mock client/server construction is lifecycle coverage, not evidence of stateful replay, decryption, certificate validation or live network execution. -
Uses the public
TcpMessageParsersinsrc/Opc.Ua.Core/Stack/Tcp/TcpMessageParsers.cscovering the pre-crypto, pre-auth chunk surface the pcap binding does not expose:TryParseChunkHeader,ReadHelloMessage,ReadAcknowledgeMessage,ReadErrorMessage,ReadReverseHelloMessage,ReadAsymmetricMessageHeader.
Seed-corpus and key-material discipline
Network.Testcases.cs and Transport.Testcases.cs generate seeds from an
in-process handshake (Hello → OPN → MSG (Read/Browse) → CLO) using test certificates and
the pcap binding's LoopbackFrameBuilder / CapturingMessageSocketFactory. Outputs:
- raw TCP segments →
Opc.Ua.Network.Fuzz.Corpus/Testcases.Tcp/ - UA-SC chunks →
Opc.Ua.Network.Fuzz.Corpus/Testcases.Chunks/,Testcases.Tcp.Hello/,Testcases.Tcp.Ack/,Testcases.Tcp.Err/,Testcases.Tcp.Rhe/,Testcases.Tcp.AsymHdr/ - paired
ChannelKeyMaterialJSON →Opc.Ua.Network.Fuzz.Corpus/Testcases.Keys/
Never commit real keylog material. Seed corpora use only key material generated from the existing fixture test certificates via the binding's own multi-TFM replay helpers.
Dependency hygiene
The Network fuzz host references Opc.Ua.Core.Diagnostics (which transitively pulls
PacketDotNet + SharpPcap) but does not instantiate NicCaptureSource so the
AFL/libFuzzer process never opens raw sockets.
Installation
Linux (afl-fuzz + libFuzzer)
Both fuzzers are supported on Linux. afl-fuzz can be compiled on any Linux system; for libFuzzer prebuilt binaries are available for Debian / Ubuntu / Windows from the libfuzzer-dotnet releases.
cd <repo>/fuzzing
sudo apt-get update
sudo apt-get install -y build-essential cmake git dotnet-sdk-10.0
# Powershell on Linux (required by the helper scripts):
# https://learn.microsoft.com/powershell/scripting/install/install-ubuntu
./Scripts/install.sh # builds afl-fuzz + installs SharpFuzz.CommandLine
install.sh downloads afl-2.52b, runs make install, then
dotnet tool install --global SharpFuzz.CommandLine. Validate with:
afl-fuzz --help
sharpfuzz
Windows (libFuzzer via WSL or native)
Install the latest .NET 10 SDK / runtime, then:
dotnet tool install --global SharpFuzz.CommandLine
For afl-fuzz, use WSL with the Linux instructions above.
Running a fuzzer
The dynamic menu script lists every FuzzableCode static target in a built area assembly
without hardcoding target names:
powershell -File fuzzing/Scripts/fuzz-menu.ps1 `
-AssemblyPath fuzzing/Opc.Ua.Network.Fuzz/bin/Debug/net10.0/Opc.Ua.Network.Fuzz.dll
# -Filter <regex> narrows the list; -Index <n> selects a target without prompting.
Scripts/fuzz-libfuzzer.ps1 and Scripts/fuzz-afl.ps1 accept a -fuzztarget parameter
matching one of the listed names:
cd fuzzing
powershell -File Scripts/fuzz-libfuzzer.ps1 `
-libFuzzer ./libfuzzer-dotnet-windows.exe `
-project ./Opc.Ua.Encoders.Fuzz/Opc.Ua.Encoders.Fuzz.csproj `
-fuzztarget LibfuzzBinaryDecoder `
-corpus ./Opc.Ua.Encoders.Fuzz.Corpus/Testcases.Binary/
The libFuzzer helper defaults to a bounded -runs 10000 campaign and a per-input
-timeout 120 seconds; supply an explicit budget for reproducible comparisons. Each invocation
uses a fresh temporary work directory and prints its location. Findings and mutated corpora are
retained there, not written over the checked-in seeds. Existing output directories are never
recursively cleared. Restore the selected host before running the helper.
The driver accepts exactly one target argument. The helper publishes an apphost and passes the
callback name to it; passing "assembly.dll callback" to dotnet is not portable to the Linux
driver. Local instrumentation covers actual Opc.Ua.*.dll modules, not NuGet package-name globs.
Replay of crashes and timeouts
Run the area's *.Fuzz.Tools project with -p / -s for playback with stack traces:
dotnet run --project fuzzing/Opc.Ua.Network.Fuzz.Tools -- --playback --stacktrace --target LibfuzzTcpChunkHeader --input <input-directory>
--input is required and accepts a file, directory or glob. Missing/empty input sets, unknown
targets and failed callbacks return a failure. Without --target, playback attempts every
libFuzzer callback and aggregates failures rather than logging them as success.
The local host also supports --list and --replay <target> <file-or-directory> without starting
SharpFuzz. The NUnit slow/timeout regressions run in child processes with a 30-second watchdog;
CancelAfter alone cannot stop a synchronous callback. Tools playback itself is in-process,
so use the isolated replay gate for suspected hangs.
The full crash corpus (about 22k inputs) lives on the orphan branch fuzz-corpus and is replayed
nightly; see CrashCorpus.md for how to replay it locally and how to add inputs.
Recreate or improve seeds
Run the area's *.Fuzz.Tools project with -t to (re)generate the area's Testcases.*/
seed corpus:
dotnet run --project fuzzing/Opc.Ua.Network.Fuzz.Tools -- --testcases --output <artifact-directory>/Testcases
Generators use the real producers. Freeze semantic inputs such as times, namespace tables and
identifiers when comparing runs, but do not claim newly generated cryptographic material is
byte-identical. Record the actual SHA-256 inventory. --output is a path prefix; generators
append format suffixes. Without it, output is under the executable directory, independent of CWD.
Never commit new certificates, private keys, operational captures or cloud corpora.
OneFuzz publication
Create a public validation drop without internal credentials:
.\fuzzing\Scripts\validate-fuzz-manifest.ps1 -SelfTest
.\fuzzing\Scripts\build-onefuzz.ps1 -OutputDirectory <new-drop-directory>
The publisher uses isolated SDK outputs, materializes declared generated corpora in controlled
artifacts, and replays the published callbacks. It never uploads or submits jobs. Without
-OwnershipProfile <approved-profile.json>, it does not emit a service-submittable
OneFuzzConfig.json. See fuzzing/OneFuzz and .azurepipelines/onefuzz.yml for the ownership
profile and internal pipeline contract. Existing output/work directories are rejected, not deleted.
The service mode is OneFuzz=true: managed net10.0 class libraries from the same source,
without the local runner or direct SharpFuzz dependency. Internal worker configuration uses
azurelinux3 and libfuzzerDotNet. The build host OS is not evidence of the resolved worker
image. Keep service drops uninstrumented; instrument separate copies only for local campaigns.
The public GitHub drop job exercises publication without credentials. An internal canary must also prove that every configured worker actually ran the intended callbacks and corpora, loaded the production dependencies and produced meaningful coverage. Missing owner configuration, authorized cloud-corpus snapshots or worker evidence blocks a full parity claim.
Automation — fuzz-tester custom agent
The fuzz-tester agent uses the manifest to select targets, required corpora and dictionaries. It preserves each reproducer and its target/oracle association, fixes the root cause, then repeats regression and bounded campaign gates. Test counts alone do not prove parity: retain positive-path assertions and compare corresponding production methods/branches with matched input, runtime and mutation budgets.
Campaign processes remain attached to the session unless the user explicitly asks otherwise. No agent runbook authorizes automatic commits, pushes, cloud uploads or modifications to a reference fork. Internal OneFuzz execution requires separate authorized configuration and worker execution evidence; a successful local publish or upload is not a successful canary.
Bounded campaign results
Every declared continuous target has been driven with a bounded, reproducible budget
(1,000 inputs, fixed seed 1, 120-second per-input timeout, serial execution), after replaying
each target's full required corpus. Reproducers are retained as Assets/crash-* regression
inputs, so FuzzCrashAssets replays them through every target on each test run.
These campaigns found genuine defects that are now fixed, including unvalidated UADP length
arithmetic, encoder paths leaking third-party parser exceptions, JSON ExtensionObject
representations that silently lost a non-null type identifier, and a dictionary that
libFuzzer refused to load — which had silently prevented four parser targets from executing
at all. A finite campaign proves neither the absence of future bugs nor coverage parity.