No Visible Unmanaged Pointer
ID |
csharp.no_visible_unmanaged_pointer |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
low |
Remediation Effort |
medium |
Resource |
Code Smell |
Language |
CSharp |
Tags |
api-design, code_smell, memory |
Description
Reports a field that holds the address of unmanaged memory — a pointer type such as int* or
void*, or one of the IntPtr / UIntPtr / nint / nuint handle types — when the field is
externally visible (public, protected or protected internal) and is not readonly.
Visibility is the effective one: the field’s own access level and that of every enclosing type.
A public field inside a type that callers in another assembly cannot name is not reachable from
there, so it is not reported. That covers the common interop helper — a top-level class with no
access modifier is internal — and any member of a nested private or internal type.
readonly fields are not reported: the address is fixed at construction and only the declaring
type decides it. const members are not reported either, and private / internal fields are
out of scope because every writer lives in code the declaring assembly owns.
private protected counts as non-visible, since only derived types inside that assembly reach it.
An array of pointers or handles is a reference to a managed array rather than a pointer field, so
it is not reported here.
Rationale
An address is not a value the type can validate after the fact. Once the field is writable from outside, any caller in any referencing assembly can point it somewhere else, and from that moment every read, write or free performed through the field acts on the new target. The invariants the type held about that address — what it refers to, how large the block is, who is responsible for releasing it — are all gone, and nothing in the type’s own code can notice.
The failure mode is the unpleasant kind: no exception at the point of misuse, just corruption of unrelated memory, or an address handed to native code that releases it a second time. It also happens outside the managed runtime’s reach, so bounds checking and the garbage collector offer no protection and no diagnostic.
public unsafe class Buffer
{
public int* Data; // FLAW - callers can point it anywhere
protected IntPtr Handle; // FLAW - derived types elsewhere can overwrite it
public readonly IntPtr FixedHandle; // OK - set once, by the type itself
private IntPtr scratch; // OK - not visible outside the type
internal UIntPtr shared; // OK - writers confined to this assembly
public int Length; // OK - not an address
}
class InteropHelper // no modifier, so internal
{
public IntPtr Handle; // OK - unreachable from another assembly
}
Remediation
Make the field readonly when the address is established once during construction — that is the
usual intent and costs nothing. When the address genuinely changes over the object’s lifetime,
make the field private and expose the operations callers actually need (read a value at an
offset, resize, release), so the type keeps ownership of the address and of the bounds that go
with it. For handles that cross into native code, prefer a SafeHandle subclass: it ties the
lifetime of the handle to the object and makes release deterministic.