Skip to content

ilasm: PdbChecksum hashes only the #Pdb stream, and -DET leaves the PDB ID's stamp at 0 #135211

Description

@pcshrosbree

Description

ilasm writes a Portable PDB and a matching debug directory that disagree with PE-COFF.md in two ways.

  1. The PdbChecksum entry hashes only the #Pdb stream, not the PDB file. PE-COFF.md says: "If the symbol format is Portable PDB the checksum is calculated by hashing the entire content of the PDB file with the PDB ID set to 0 (20 zeroed bytes)." ilasm's checksum is SHA-256 of the #Pdb stream alone (64 bytes for the file below): as written without -DET, and with the ID zeroed with -DET. So the checksum never verifies against the PDB it was written with.
  2. With -DET, the PDB ID's stamp stays 0, while the CodeView entry's TimeDateStamp carries the deterministic stamp. PEReader.TryOpenAssociatedPortablePdb builds the expected ID from the CodeView GUID and that entry's stamp (PEReader.cs L785), so it refuses the PDB ilasm wrote alongside the DLL. StackTraceSymbols locates PDBs through that call (L198).

Both came in with #109091 and are unchanged on main (checked at 30f813606ae).

Reproduction

Min.il:

.assembly extern System.Runtime { .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A) .ver 10:0:0:0 }
.assembly Min { }
.module Min.dll

.class public auto ansi beforefieldinit C extends [System.Runtime]System.Object
{
  .method public hidebysig static int32 F() cil managed
  {
    ldc.i4.1
    ret
  }
}

Assemble it twice, into separate directories, where ilasm is the build under test:

mkdir -p nondet det
ilasm -DLL -DEBUG -QUIET -OUTPUT=nondet/Min.dll Min.il
ilasm -DLL -DEBUG -QUIET -DET -OUTPUT=det/Min.dll Min.il

Check each pair with this file-based app, which needs a .NET 10 or later SDK (dotnet run check.cs -- nondet/Min.dll det/Min.dll):

#:property UseAppHost=false
using System.Numerics;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;

foreach (var dll in args)
{
    using var pe = new PEReader(File.OpenRead(dll));
    bool opened = pe.TryOpenAssociatedPortablePdb(dll, p => File.Exists(p) ? File.OpenRead(p) : null, out var provider, out _);
    provider?.Dispose();
    var codeView = pe.ReadDebugDirectory().First(e => e.Type == DebugDirectoryEntryType.CodeView);

    byte[] pdb = File.ReadAllBytes(Path.ChangeExtension(dll, ".pdb"));
    using var pdbProvider = MetadataReaderProvider.FromPortablePdbImage(System.Collections.Immutable.ImmutableArray.Create(pdb));
    var header = pdbProvider.GetMetadataReader().DebugMetadataHeader!;
    int idAt = header.IdStartOffset;
    // #Pdb stream: PDB ID (20), EntryPoint (4), ReferencedTypeSystemTables (8), then one row count per table.
    ulong mask = BitConverter.ToUInt64(pdb, idAt + 24);
    int streamLength = 32 + 4 * BitOperations.PopCount(mask);

    byte[] Zeroed(byte[] b, int at) { var c = (byte[])b.Clone(); Array.Clear(c, at, 20); return c; }
    var stream = pdb.AsSpan(idAt, streamLength).ToArray();

    Console.WriteLine(dll);
    Console.WriteLine($"  PDB ID stamp {BitConverter.ToUInt32(pdb, idAt + 16)}, CodeView entry stamp {codeView.Stamp}");
    Console.WriteLine($"  TryOpenAssociatedPortablePdb: {opened}");
    foreach (var e in pe.ReadDebugDirectory().Where(e => e.Type == DebugDirectoryEntryType.PdbChecksum))
    {
        var sum = pe.ReadPdbChecksumDebugDirectoryData(e).Checksum;
        bool Is(byte[] preimage) => sum.SequenceEqual(SHA256.HashData(preimage));
        Console.WriteLine($"  checksum == SHA256(whole PDB, ID zeroed) [PE-COFF.md]: {Is(Zeroed(pdb, idAt))}");
        Console.WriteLine($"  checksum == SHA256(#Pdb stream only, as written):     {Is(stream)}");
        Console.WriteLine($"  checksum == SHA256(#Pdb stream only, ID zeroed):      {Is(Zeroed(stream, 0))}");
    }
}

Output with ilasm 10.0.0 (runtime.linux-x64.Microsoft.NETCore.ILAsm), Linux x64. The non-deterministic stamp varies between runs; the -DET values depend only on the size of this input's metadata (#135214):

nondet/Min.dll
  PDB ID stamp 1791172104, CodeView entry stamp 1791172104
  TryOpenAssociatedPortablePdb: True
  checksum == SHA256(whole PDB, ID zeroed) [PE-COFF.md]: False
  checksum == SHA256(#Pdb stream only, as written):     True
  checksum == SHA256(#Pdb stream only, ID zeroed):      False
det/Min.dll
  PDB ID stamp 0, CodeView entry stamp 2978741164
  TryOpenAssociatedPortablePdb: False
  checksum == SHA256(whole PDB, ID zeroed) [PE-COFF.md]: False
  checksum == SHA256(#Pdb stream only, as written):     False
  checksum == SHA256(#Pdb stream only, ID zeroed):      True

As a control, the same program on a Roslyn-built DLL prints True for TryOpenAssociatedPortablePdb and for the PE-COFF.md rule, and False for both stream-only lines.

Cause

  • PdbHeap::ComputeSha256Checksum hashes m_data/m_size, which is the #Pdb stream that PdbHeap::SetData built, not the serialized PDB. Assembler::CreatePEFile calls it right after BuildPdbStream, before the PDB's other streams are serialized.
  • Under -DET, that call happens while the ID is all zero (L1483). Then ChangePdbStreamGuid patches the GUID into the heap through PdbHeap::SetDataGuid, but SetTimestamp updates only PortablePdbWriter::m_pdbStream, although the comment above the two calls says "update the GUID and timestamp". CreateDebugDirectory reads the stamp from that struct for the CodeView entry (writer.cpp L360), and the heap, which is what is saved, keeps 0.
  • IlasmPortablePdbTester.TestPortablePdbDebugDirectory compares only the 16 GUID bytes of the ID with the CodeView entry, does not run -DET, and does not check the checksum. So neither defect fails a test.

Suggested fix

  • Compute the checksum over the serialized PDB with the 20-byte ID zeroed, as Roslyn does: serialize the PDB with a zero ID, hash it, then write the ID and the debug directory entries.
  • Under -DET, write the stamp into the heap as well as the GUID, for example with a ChangePdbStreamId(guid, stamp) that patches all 20 bytes.
  • Extend TestPortablePdbDebugDirectory to compare all 20 bytes of the ID (GUID and stamp) with the CodeView entry, to verify the PdbChecksum entry by the PE-COFF.md rule, and to run with and without -DET.

Fix: #135212

Note

This report and its analysis were prepared with AI assistance (Anthropic Claude and OpenAI Codex) under my direction. AI agents ran the reproduction on my machine. I reviewed the text before posting.

Activity

  1. dotnet-policy-service commented on Oct 5, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-system-io
    See info in area-owners.md if you want to be subscribed.

  2. pcshrosbree commented on Oct 6, 2026

    @pcshrosbree
    ContributorAuthor

    This issue is about the native ilasm (src/coreclr/ilasm), the same tool as #135214, which carries area-ILTools-coreclr. The bot labelled this one area-System.IO, so the ILTools owners were not tagged. Could someone move it to area-ILTools-coreclr?

    Context from the discussion on #135212: @am11 noted that the managed ilasm rewrite is the direction, so this report may end up tracked against that implementation rather than the native one. Either way, the ILTools area is the right home.

    Note

    This comment was prepared with AI assistance (Anthropic Claude) at my request.

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

    area-System.IOuntriagedNew issue has not been triaged by the area owner

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions