Skip to content

Saving a credential leaves its plaintext JSON in ArrayPool<byte>.Shared: zeroing the byte[] from SerializeToUtf8Bytes misses System.Text.Json's pooled copy #185

Description

@matt-edmondson

What's wrong

CredentialSerialization.Serialize (CredentialCache/Storage/CredentialSerialization.cs:34) is JsonSerializer.SerializeToUtf8Bytes(credential, Options). All three native stores call it on Save:

  • Windows: WindowsCredentialStore.cs:79
  • macOS: MacOsCredentialStore.cs:77
  • Linux, via NativeSecretBuffer.OfCredential: NativeSecretBuffer.cs:152

Each store then zeroes the byte[] it gets back (the work of #144, #160 and #165).

That buffer isn't the only copy, though. Internally, SerializeToUtf8Bytes writes into a buffer rented from ArrayPool<byte>.Shared, which is 16 KB for a credential-sized payload. It copies the result out with ToArray() and returns the rented buffer without clearing it. So a second, identical plaintext copy of every saved token or password stays in the process-wide shared pool:

  • Any unrelated code that rents a 16 KB array gets that array back, with the secret still in it.
  • The secret shows up in crash dumps for as long as the process lives.

This contradicts the README's guarantee (README.md:156): "every copy the library owns is a byte array or an unmanaged buffer that is overwritten with zeros before it is released".

Reproduction

This was verified on .NET 10.0.12 with System.Text.Json 10.0.12, the version the repo pins:

var cred = new CredentialWithToken { Token = "SUPERSECRET-" + Guid.NewGuid() };
byte[] bytes = CredentialSerialization.Serialize(cred);
Array.Clear(bytes);                                   // what the stores do
byte[] rented = ArrayPool<byte>.Shared.Rent(16384);
// rented contains the UTF-8 "SUPERSECRET-..." JSON

Of the power-of-two sizes, only Rent(16384) returned the secret; every other size was clean. The deserialize path (Deserialize(byte[])) leaves nothing behind in the pool.

Suggested fix

  • Serialize with JsonSerializer.Serialize(Utf8JsonWriter, credential, Options) into a private IBufferWriter<byte> that zeroes its old array each time it grows, and zeroes its final array on dispose.
  • Hand the stores the written span, or copy it out and zero the original.
  • A prototype of this wrapper (a small scrubbing buffer writer around new Utf8JsonWriter(writer)) produced byte-identical output, and no pool size contained the secret afterwards.

Acceptance criteria

  • After a Save on any store, renting ArrayPool<byte>.Shared arrays of every power-of-two size up to 64 KB finds no trace of the secret.
  • A test in the style of SecretScrubbingTests covers this. It needs no native keyring, so it runs on every OS.

Activity

  1. matt-edmondson commented on Oct 6, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions