.Net application development specialists
asp.net, c#, vb.net, html, javascript, jquery, html, xhtml, css, oop, design patterns, sql server, mvc and much more
contact: admin@paxium.co.uk

Paxium is the company owned by myself, Dave Amour and used for providing IT contract development services including


  • Application development - Desktop, Web, Services - with Classic ASP, Asp.net WebForms, Asp.net MVC, Asp.net Core, .NET 8/9/10
  • Azure - Azure Functions, App Services, Azure SQL, Service Bus, Blob Storage, Key Vault, API Management (APIM), Logic Apps and Application Insights
  • Html, Css, JavaScript, jQuery, React, C#, SQL Server, Ado.net, Entity Framework, NHibernate, TDD, WebApi, GIT, IIS
  • Database schema design, implementation & ETL activities
  • Website design and hosting including email hosting
  • Training - typically one to one sessions
  • Reverse Engineering and documentation of undocumented systems
  • Code Reviews
  • Performance Tuning
  • Located in Cannock, Staffordshire
Rugeley Chess Club Buying Butler Cuckooland Katmaid Pet Sitting Services Roland Garros 60 60 Golf cement Technical Conformity Goofy MaggieBears Vacc Track Find Your Smart Phone eBate Taylors Poultry Services Lafarge Rebates System Codemasters Grid Game eBate DOFF

Reverse Engineering Legacy .NET DLLs to Prove Recovered Source Matches Production

Taking ownership of a legacy application when the original developer has left can present an interesting problem.

Having some source code is one thing. Knowing that the source code actually corresponds to the binaries currently running in production is something else entirely.

I recently encountered this while taking over a collection of legacy ASP.NET applications built on .NET Framework 4.5.

The applications were running successfully on an IIS server and I had recovered source code, but there was no reliable build and deployment process that could prove the recovered source was actually the code from which the deployed DLLs had been built.

The challenge therefore became:

Can I rebuild the recovered source and establish, with reasonable confidence,
that its application logic matches the DLLs currently deployed on the server?

Step 1: Comparing the DLL Files

The obvious starting point was to rebuild the applications and compare the resulting DLL files with those deployed on the server.

Unfortunately, binary comparison is not particularly useful for this.

Two .NET assemblies built from identical source code are not necessarily binary-identical. Assemblies contain metadata that can change between builds, including timestamps and the MVID.

MVID stands for:

Module Version ID

It is a GUID stored in the metadata of a .NET module and normally changes when the assembly is rebuilt.

That means two DLLs can contain identical application logic while still having different binary content.

Even file size is not conclusive. A 28 KB DLL and a 29 KB DLL do not necessarily contain different application code.


Step 2: Decompiling the DLLs with dotPeek

The next step was to use JetBrains dotPeek to decompile both my newly built DLL and the DLL copied from the server.

This immediately gave a much more useful comparison because I could inspect the reconstructed C# rather than the raw binary.

For example, both assemblies might decompile to exactly the same class:

public class AD_Application_User
{
    public int AD_Application_User_ID { get; set; }

    public string Username { get; set; }

    public string First_Name { get; set; }

    public string Last_Name { get; set; }

    public string Email { get; set; }

    public bool Enabled { get; set; }
}

However, dotPeek also includes generated header information such as:

// MVID: 2D6A4EC2-...
// Assembly location: F:\...\Source\Account.dll

and the deployed version might show:

// MVID: A3C927F2-...
// Assembly location: F:\...\Server\Account.dll

These differences are completely irrelevant when trying to establish whether the application logic is the same.


Step 3: Decompiled C# Can Also Be Misleading

The next problem was more subtle.

Sometimes the decompiled C# itself looked significantly different even when the underlying source was probably the same.

One version might decompile into a clean LINQ expression while another might contain lower-level expression-tree code involving:

ParameterExpression
Expression
MethodInfo
FieldInfo

dotPeek might even insert comments such as:

// ISSUE: method reference

The important point is that decompiled C# is not necessarily the original source code.

A decompiler is reconstructing C# from compiled Intermediate Language, or IL. Different compilers can generate different IL for the same source code, and different IL can then decompile into noticeably different C#.

This meant a simple text comparison of the decompiled source was not reliable enough.


Step 4: Building an Automated IL Comparison Tool

Rather than manually inspect thousands of classes, I created a small .NET 8 console application using Mono.Cecil.

The basic process was:

Recovered Source
      |
      v
Build
      |
      v
Source DLL -----------+
                     |
                IL Comparer
                     |
Server DLL -----------+

The tool examined matching assemblies and compared:

  • Types
  • Fields
  • Methods
  • Method signatures
  • Normalised IL instructions

Metadata tokens could not simply be compared numerically because those values can change between builds, so references were normalised to their actual type, field and method names.

The tool also generated CSV summary and review reports.


Step 5: The First Results Looked Terrible

The initial automated results were alarming.

Across 14 assemblies the first version reported:

Exact methods:       5,104
Different methods:  16,420

That did not correspond at all with what I was seeing manually.

The comparison itself was technically correct, but it was answering the wrong question.

It was effectively asking:

Did these two compiler runs produce identical IL?

What I actually needed to know was:

Does the recovered source represent the same application logic?

Those are not the same thing.


Step 6: Removing Compiler-Generated Noise

The next version filtered out compiler-generated constructs such as:

<>c
<>c__DisplayClass...
<MethodName>b__...
<MethodName>d__...

It also normalised several differences involving local variables, branch instructions and other compiler implementation details.

Instead of simply classifying methods as matching or different, the tool used:

EXACT MATCH
LIKELY MATCH
REVIEW
MISSING

That reduced the number of methods needing investigation from:

16,420

to:

2,610

A considerable improvement, but still far too many to review manually.


Step 7: Matching the Original Compiler Made the Biggest Difference

The real breakthrough came from considering the age of the deployed application.

The application was a .NET Framework 4.5 system dating from the Visual Studio 2012 era, but I had originally rebuilt the recovered source using Visual Studio 2022.

Both builds targeted .NET Framework, but they were using very different generations of the C# compiler.

The compiler-generated names gave a strong clue.

The modern build contained names such as:

<>c__DisplayClass13_0
<>c__DisplayClass15_0

while the deployed DLL contained older-style generated names such as:

<>c__DisplayClass0
<>c__DisplayClassa
CS$<>8__locals11

I therefore rebuilt the recovered application using:

Visual Studio 2012 Update 5

The difference was dramatic.

The results changed from:

Exact methods:       5,104
Methods to review:   2,610

to:

Exact methods:       8,955
Methods to review:      59

across the entire set of assemblies.

Several DLLs became complete matches.

One DLL that had previously contained 53 review methods was reduced to:

209 exact
0 likely
1 review
0 missing from source
0 missing from server
0 field differences

This was strong evidence that the vast majority of the earlier differences were caused by the compiler rather than by different application source.


Step 8: Debug Versus Release Was Another Important Clue

I naturally assumed that the deployed DLLs would have been compiled in Release configuration.

I therefore rebuilt everything in Visual Studio 2012 using Release.

Unexpectedly, the comparison became considerably worse:

Exact methods:       5,538
Methods to review:   2,129

The previous Visual Studio 2012 build had required only 59 methods to be reviewed.

This strongly suggested that the production assemblies had been built with compiler optimisation disabled, possibly using a Debug configuration or an equivalent custom build configuration.

This was a useful reminder that Debug and Release do not simply control whether PDB files are produced.

Compiler optimisation can significantly alter the emitted IL while leaving application behaviour unchanged.


Step 9: Reviewing the Remaining Differences with Beyond Compare

Once the automated comparison had reduced thousands of differences down to a manageable number, the remaining methods could be inspected manually.

I exported the source-built DLL and deployed DLL from dotPeek into separate folders and compared those folders using Beyond Compare 5.

The decompiled files still contained generated metadata such as:

// MVID: ...
// Assembly location: ...

Beyond Compare allows regular expressions to define unimportant text.

I added the following rule to ignore MVID lines:

^\s*//\s*MVID:.*$

and another to ignore assembly location:

^\s*//\s*Assembly location:.*$

I then configured the Folder Compare session to:

  • Compare file contents
  • Use rules-based comparison
  • Override quick-test results
  • Hide minor or unimportant differences

This meant files whose only differences were MVID or file location no longer cluttered the comparison.

The remaining highlighted files were much more likely to contain genuine application differences worth investigating.


Step 10: The Final Workflow

The process I eventually settled on was:

Recover legacy source
        |
        v
Identify original framework and build toolchain
        |
        v
Build using the closest possible compiler and settings
        |
        v
Compare DLL structure and normalised IL automatically
        |
        v
Remove compiler-generated and build-related noise
        |
        v
Generate a list of REVIEW methods
        |
        v
Decompile both assemblies
        |
        v
Compare the exported source trees
        |
        v
Ignore MVID and assembly-location metadata
        |
        v
Manually inspect only the remaining meaningful differences

The important point is that each stage reduces the search space.

Instead of asking a developer to manually compare thousands of classes, the automated comparison identifies a relatively small number of methods that deserve human judgement.


What I Learned

One of the biggest lessons was that saying:

The DLLs are different

is almost meaningless on its own.

There are several different levels of equality:

Binary identical
        |
        v
IL identical
        |
        v
Structurally identical
        |
        v
Semantically equivalent
        |
        v
Functionally equivalent

For source-code recovery, the last two are usually the ones that matter.

The original build environment can also be extremely important.

Simply changing from a modern Roslyn compiler back to Visual Studio 2012 reduced the apparent method differences in this application from:

2,610

to:

59

That was not just a small improvement to the process. It completely changed the scale of the problem.

The other useful lesson was that reverse engineering does not always mean reconstructing missing source code line-by-line.

Sometimes the real task is proving that the source you have recovered is already the correct source.

To do that reliably, it helps to understand the entire chain:

Source
  |
  v
Compiler
  |
  v
IL
  |
  v
Assembly
  |
  v
Decompiler

Once you understand where harmless differences can be introduced along that chain, what initially looks like an impossible comparison becomes a much more systematic engineering exercise.