Pangram verdict · v3.3
We believe that this document is fully human-written
AI likelihood · overall
HumanArticle text · 1,708 words · 5 segments analyzed
Some people may think I am a shill for .NET.
With my previous post, they may be right.
However, as much as I like .NET, there are some things that just really do not make any sense to me, and they frustrate me to no end.
Given that I maintain a PE parsing library and thus am deeply familiar with the anatomy of .NET binaries, I feel I am qualified to complain about some of the design choices Microsoft made for this file format :).
In this post, I will rant about Custom Attributes and why their underlying storage mechanism is among the poorest design choices Microsoft has ever made in .NET. It has caused me so much grief over the past few years, it has become a meme in the AsmResolver core maintainers group. I am truly convinced that custom attributes are the source of all evil.
I literally have nightmares about custom attributes.
What are Custom Attributes?
For the unfamiliar, custom attributes are extra pieces of metadata you can attach to classes, methods, fields, parameters, etc. They are typically used to instruct the C# compiler to do something extra.
A classic example is the ObsoleteAttribute, which lets the compiler produce a warning if an object marked with this attribute is used in some user code:
1 2 3 4 [Obsolete] // Custom attribute that marks MyClass obsolete. public class MyClass { /* ... */ }
var x = new MyClass(); // <-- Compiler warns: "warning CS0612: 'MyClass' is obsolete"
You can define your own custom attributes, and they can also define parameters:
1 2 3 4 public class MyAttribute(int x, string y) : Attribute { /* ... */ }
[MyAttribute(0x1337, "Hello, world!")] public class MyClass { /* ... */ }
Custom attributes are a great way to extend the normal metadata that exists around a function, variable, or type. It is used mainly by analyzers, source generators, or dynamic initialization/inspection and is great for meta-programming and use cases like automatic serialization and deserialization of objects.
Anatomy of a Custom Attribute
In the .NET file format, everything is stored in a database of metadata tables. Types, fields, methods, parameters, etc.
will all reside in their own table. This allows for each object to be referenced and looked up efficiently by a metadata token, i.e., a table + row index.
You can view the raw contents of these metadata tables in a .NET binary with a tool like CFF Explorer:
Metadata tables in a .NET binary.
Custom attributes are no exception to this. Each row in the CustomAttribute table represents one instantiation of an attribute. It contains a reference to the member the attribute is attached to, a reference to the attribute’s constructor, and an index into the blob stream referencing an array of arguments to call this constructor with.
A single custom attribute row
The blob signature is the interesting part for this post.
The Custom Attribute Signature
All arguments in a custom attribute signature are serialized to their binary representation and concatenated in sequence. It is important to note that this binary representation is fully implied by the parameter types of the attribute’s constructor. For example, if the first argument’s type is int, the first four bytes encode an integer. If the second argument is a string, you follow it up by reading a length-prefixed array of characters. You keep going until you read all the arguments, and if you did everything right, you would be at the end of the blob signature.
1 2 00000000 37 13 00 00 13 48 65 6c 6c 6f 2c 20 77 6f 72 6c |.....Hello, worl| 00000010 64 21 |d!|
This is intuitive; there are no problems here. Things go downhill very fast after this, though.
Custom Attributes with Enum Values
The vast majority of attributes do not actually take in primitive arguments like int or string, but often include parameters defined as an enum type. The specification stipulates that enum values are serialized in the same way as their underlying type.
ECMA specification on enum values.
Consider the following example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public enum MyIntEnum // `int` is the default in C# { Value1 = 1, Value2 = 2, Value3 = 3, // ... }
public enum MyShortEnum : short // explicit underlying type `short` { Value1 = 1, Value2 = 2, Value3 = 3, // ... }
public class FooAttribute(MyIntEnum a, MyShortEnum b) : Attribute { /* ... */ }
[Foo(MyIntEnum.Value2, MyShortEnum.Value3)] public class SomeClass;
Because MyIntEnum implicitly subclasses int and MyShortEnum subclasses short, the first argument of the instantiated Foo attribute would occupy 4 bytes (02 00 00 00), and the second argument only 2 bytes (03 00). This results in the full argument sequence:
1 02 00 00 00 03 00
To correctly read back enum arguments, you have to know the enum’s underlying type and read the appropriate number of bytes from the signature. If you don’t, you risk misinterpreting the bytes for subsequent arguments.
Here is an important fact about this:
Determining the enum underlying type is an incredibly expensive operation.
Here is why:
Resolving the Enum type
Since there is no indication of an enum’s underlying type in the attribute nor in the constructor’s signature, determining this underlying type is a huge pain because it requires resolving the enum type itself so that we can inspect its metadata structure.
Type resolution involves the following steps:
Assembly resolution: We first need to figure out which assembly the type is stored in. This involves probing DLL files on the disk in various directories (using complicated probing algorithms that differ across .NET versions, sometimes requiring parsing one or two JSON or XML files shipped with the binary), parsing all the relevant headers, traversing its metadata streams, and verifying whether it is really the assembly we’re looking for (i.e., checking its name, version, public key token, etc.).
This is not a trivial operation.
Finding the right DLLs is not trivial. Type tree traversal: Once we have found a candidate assembly, we need to actually search for a type that matches the enum reference. This means going through its TypeDef table (which can contain hundreds if not thousands of rows for larger DLLs like mscorlib.dll), resolving the names of each entry, and checking whether there is any match.
TypeDef table of System.Private.CoreLib.dll with 2759 rows.
Things get more complicated if the enum type is a nested type. The rows in the TypeDef table only specify the Name and Namespace (typically null for nested types), and store no information about their enclosing parent types. For this, we need to consult a second table, NestedClass, which associates nested types with their direct enclosing type.
NestedClass table
This also means we need to do this recursively. A class C that is enclosed by two classes B and A will require traversing two rows in the NestedClass table (one placing C inside B, and one placing B inside A). Type forwarders: To make matters worse, a type may not even be defined in the assembly we just found! Instead, it may be forwarded to another assembly. This mechanism is heavily used by the standard libraries of .NET themselves. For example, using the enum System.DebuggableAttribute.DebuggingModes in your C# code adds a reference to System.Runtime.dll as the declaring assembly.
1 [assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default)] // References System.Runtime.dll
However, once you have found and parsed System.Runtime.dll correctly, you will see that System.DebuggableAttribute.DebuggingModes is not there! In fact, no types are defined here at all:
Where are all the types?
Instead, it is defined as an exported type (stored in yet another table), which forwards you to System.Private.CoreLib.dll on modern .NET versions.
Type forwarder to System.Private.CoreLib.dll
In theory, you can have as many type forwarders as you want. They will all trigger new assembly and type resolutions over and over again until you finally get to the type you are trying to resolve.
There it is!
Bottom line: Type resolution is a non-trivial operation. Even if you implement heavy caching (e.g., assembly and type resolutions), it still is orders of magnitude more complex and error-prone than just reading a byte.
Traversing the Enum type
Once we have finally resolved the enum type, we must figure out its underlying enum type.
Given the syntax C# uses, you may think this is the Extends column in the TypeDef table (as would be the case for other types), but for enum types this is always set to System.Enum.
Base type of enum typedefs
Instead, you need to find a special hidden non-static field (typically called value__) defined in the enum type, which requires iterating a subset of yet another table (Field). Once you have found this field, you need to parse its field signature to figure out its field type.
Field type
Congratulations, you have finally found the underlying enum type!
Now you can finally use it to decide whether you need to consume 1, 2, 4, or 8 bytes from the signature for a single enum-based argument. If your custom attribute defines another enum argument, you have to go through this process all over again :).
My thoughts on enum values
This system is so convoluted, especially since 99.97% of all enums ever used in custom attributes are using a normal 32-bit integer value as their underlying storage mechanism [citation needed]. But some don’t, and thus you need to have this system in place. You also better hope all assemblies that the signature depends on are nearby, or else assembly resolution will fail and you will never be able to determine the size of the enum argument.
The system is also extremely unnecessary. If you consider there are only a very small set of “valid” enum underlying types (i.e., sbyte, short, int, …), I feel this could have easily been solved by simply prefixing the raw value with a CorElementType indicator byte (i.e., ELEMENT_TYPE_I1, ELEMENT_TYPE_I2, ELEMENT_TYPE_I4 …). These indicators are used throughout the rest of the file format as well, and the compiler should have this information at build time anyway.
I don’t know why they made it like this.
Custom Attributes with Type Values
But wait, it gets worse!