Files
romanett 52f8520baa Detect resource and limit abuse in the encoder/decoder fuzz harness (#4666)
## 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>
2026-10-04 19:16:23 +00:00

238 lines
8.4 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
namespace Opc.Ua.Fuzzing
{
/// <summary>
/// Supplies observable callbacks and deliberately invalid signatures for the linked harness.
/// </summary>
public sealed class FuzzableCode
{
internal static List<(string Target, byte[] Input)> Invocations { get; } = [];
internal static int FuzzInfoCalls { get; private set; }
internal static Exception InjectedFailure { get; set; } =
new InvalidOperationException("Injected fuzz target failure.");
internal static ReadOnlySpan<byte> HangingInput => "opcua-fuzz-watchdog-regression"u8;
internal static ReadOnlySpan<byte> CompletedInput => "opcua-fuzz-watchdog-control"u8;
internal static ReadOnlySpan<byte> LargeOutputInput => "opcua-fuzz-output-control"u8;
internal static ReadOnlySpan<byte> DeepRecursionInput => "opcua-fuzz-deep-recursion"u8;
internal static string[] InvalidTargetNames =>
[
nameof(FuzzInfo),
nameof(ReturningStreamTarget),
nameof(ReturningStringTarget),
nameof(ReturningSpanTarget),
nameof(GenericStreamTarget),
nameof(InstanceStreamTarget),
nameof(MultipleParametersTarget),
nameof(ByteArrayTarget),
nameof(WritableSpanTarget),
nameof(DerivedStreamTarget),
nameof(ByRefStringTarget),
nameof(HiddenStreamTarget),
nameof(InternalStreamTarget)
];
public static void FuzzInfo()
{
FuzzInfoCalls++;
}
public static void StreamTarget(Stream input)
{
RecordStream(nameof(StreamTarget), input);
}
public static void StringTarget(string input)
{
Invocations.Add((nameof(StringTarget), Encoding.UTF8.GetBytes(input)));
}
public static void SpanTarget(ReadOnlySpan<byte> input)
{
Invocations.Add((nameof(SpanTarget), input.ToArray()));
}
public static void ThrowingStreamTarget(Stream input)
{
RecordStream(nameof(ThrowingStreamTarget), input);
throw InjectedFailure;
}
public static void ThrowingStringTarget(string input)
{
Invocations.Add((nameof(ThrowingStringTarget), Encoding.UTF8.GetBytes(input)));
throw InjectedFailure;
}
public static void ThrowingSpanTarget(ReadOnlySpan<byte> input)
{
Invocations.Add((nameof(ThrowingSpanTarget), input.ToArray()));
throw InjectedFailure;
}
public static void HangingSpanTarget(ReadOnlySpan<byte> input)
{
Invocations.Add((nameof(HangingSpanTarget), input.ToArray()));
if (input.SequenceEqual(HangingInput))
{
Console.WriteLine(HangingTargetEntered);
Console.Out.Flush();
Thread.Sleep(Timeout.Infinite);
}
else if (input.SequenceEqual(CompletedInput))
{
Console.WriteLine(TargetCompleted);
}
else if (input.SequenceEqual(LargeOutputInput))
{
Console.Out.Write(new string('o', OutputLength));
Console.Error.Write(new string('e', OutputLength));
}
}
/// <summary>
/// Recurses about 400 KB deep for <see cref="DeepRecursionInput"/>: within the stack of a
/// 1 MB thread, beyond the stack of the fuzz worker thread.
/// </summary>
public static void DeepRecursionSpanTarget(ReadOnlySpan<byte> input)
{
Invocations.Add((nameof(DeepRecursionSpanTarget), input.ToArray()));
if (input.SequenceEqual(DeepRecursionInput))
{
Console.WriteLine(TargetCompleted + " " + Recurse(DeepRecursionLevels));
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Recurse(int levels)
{
Span<byte> frame = stackalloc byte[RecursionFrameBytes];
frame[levels % RecursionFrameBytes] = (byte)levels;
return levels == 0 ? frame[0] : Recurse(levels - 1) + frame[levels % RecursionFrameBytes];
}
public static int ReturningStreamTarget(Stream input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public static string ReturningStringTarget(string input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public static int ReturningSpanTarget(ReadOnlySpan<byte> input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public static void GenericStreamTarget<T>(Stream input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public void InstanceStreamTarget(Stream input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public static void MultipleParametersTarget(Stream input, string text)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public static void ByteArrayTarget(byte[] input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public static void WritableSpanTarget(Span<byte> input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public static void DerivedStreamTarget(MemoryStream input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
public static void ByRefStringTarget(ref string input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
internal static void Reset()
{
Invocations.Clear();
FuzzInfoCalls = 0;
InjectedFailure = new InvalidOperationException("Injected fuzz target failure.");
}
internal static void InternalStreamTarget(Stream input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
private static void HiddenStreamTarget(Stream input)
{
throw new InvalidOperationException(kInvalidTargetMessage);
}
private static void RecordStream(string target, Stream input)
{
using var copy = new MemoryStream();
input.CopyTo(copy);
Invocations.Add((target, copy.ToArray()));
}
internal const string HangingTargetEntered = "Injected hanging target entered.";
internal const string TargetCompleted = "Injected target completed.";
internal const int OutputLength = 128 * 1024;
internal const int DeepRecursionLevels = 400;
internal const int RecursionFrameBytes = 1024;
private const string kInvalidTargetMessage = "An invalid fuzz target must not be invoked.";
}
}