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.

















