Overriding Sealed Methods in C#: Key Takeaways
Overriding Sealed Methods in C#: Key Takeaways
Key Takeaways
A method is a block of code that contains a series of instructions. It can be declared within a class,
structure, or interface by specifying the method signature, which consists of the method’s name,
parameters, the return value, and modifiers like its access level, abstract, or sealed.
A method signature must uniquely determine the method used in a given execution context. Depending
on the context, the return value may be part of the signature (e.g., when determining the compatibility
between a delegate and the method to which it points), or it may be ignored (e.g., when the method is
overloaded). When we call a method, we need to provide both the method name and the method
parameters. We don’t specify the return type in C#, but we need to do it in Intermediate Language (IL).
This way, we can specify which method to call, and the .NET platform takes care of the rest of the process.
A method can also be virtual, which adds support for polymorphic execution via late-binding
mechanism (the method can be redefined in derived classes). This is one of the cornerstones of Object-
Oriented Programming, and it is widely used in C#. However, not all methods can be virtual and support
overriding - static methods, constructors, or operators do not support this mechanism. We can also mark
a method as sealed to stop it from being overridden in subclasses even if it is marked as virtual in the
base class.
There are, however, ways to modify how a sealed method is executing, overriding it to some extent.
Before we get into that, we need first to understand how methods are implemented in the .NET platform.
Method internals
Live Webinarcode
A method’s and Q&A - Introducing
is typically MARA:
compiled A Reference
twice. The C#Architecture for Kubernetes
compiler does Microservices
the first compilation. (Live
This Webinar
process
Sept 29th, 2022)
takes theYour
Save C# source
Seat code as an input and generates intermediate language (IL) code as an output. Later,
the IL code is compiled again, typically at runtime by the Just-In-Time (JIT) compiler. It may also be
compiled before an application gets executed in an Ahead-Of-Time (AOT) mode with ngen or
ReadyToRun (R2R) mechanisms. The second compilation takes the IL code as an input and generates
machine code matching the current hardware (CPU) architecture as an output. The machine code can be
later executed directly by the CPU, with no help from the .NET platform.
To call a method on a machine code level, we need to be aware of multiple things that we can safely
ignore when writing code in C#. Not only do we need to provide a name and parameters to a method, but
we also need to know how to pass values to the method (via registers or stack). Not only that but also who
cleans up the stack after the method finishes (callee or caller), how a value is returned, what’s the order of
parameters (left-to-right or right-to-left), and many more details. When writing in C#, we ignore these
details because the .NET platform takes care of them. However, on a machine code level, we need to
adhere to the binary protocol carefully. Otherwise, we’ll most likely get a segmentation fault or an access
violation.
JIT-compilation is a multi-step process, relying both on the internals of the .NET platform and on the
specifics of the CPU architecture. It must consider multiple aspects of the runtime:
1. How to pass parameters to a method? Depending on the architecture, a different set of registers is
used. In 32-bit architectures, the first two parameters are passed via the ecx and edx registers, and
all the others are passed via stack. In 64-bit architectures, the first four parameters are passed via
the rcx, rdx, r8, and r9 registers, and the others are passed via the stack. This, however, can be
changed at any time and is not guaranteed to remain the same between compiler versions.
2. How is the return value returned? Integer values are returned in the eax register, while floating-
point ones are returned through FP or XMM registers.
3. What is the order of parameters? Whether parameters are passed left-to-right or right-to-left
depends on the architecture and is controlled by the platform.
4. How is the memory for the machine code allocated? Since the machine code is not available when
the application starts, it must be written by the application and stored somewhere. Typically, a new
memory page is allocated and is then marked as executable with either the VirtualProtectEx or
mprotect functions from the operating system.
5. Who removes parameters from the stack? If it’s the caller, we risk significant code duplication
because every time we call a method, we need to remove its parameters from the stack when it
returns. However, if it’s the callee who cleans up, we cannot reliably implement methods with
variadic parameters, like printf (which can accept any number of parameters).
6. Is it worth calling the method? Should it be inlined? Maybe it cannot be inlined because it is either
too big or it uses try-catch, which may change the stack trace.
Live7.Webinar
Can weand Q&A - Introducing
optimize the method?MARA: A Reference
Precalculate Architecture
constants, for Kubernetes
remove dead code,Microservices (Live Webinar
reorder instructions?
Sept 29th, 2022)
Save Your Seat
8. What’s the endianness? How do we encode instructions and addresses?
We typically don’t need to think about these aspects when writing a C# code. They only become
important once we start calling methods from other platforms with the P/Invoke mechanism.
Starting with .NET Core 2.1, a method may be JIT-compiled multiple times due to the multi-tiered
compilation mechanism. The first compilation is rough and dirty. It generates non-optimized machine
code. The second compilation may happen after some time (e.g., when the .NET platform observes that
the method is on a hot path and is executed often). The compiler then spends more time in the
compilation and produces optimized code. This may result in using fewer registers, removing dead code,
or precalculating values and using constants. Multi-tiered compilation is enabled by default starting from
.NET Core 3. Effectively, a method always has one instance of an IL code, but it may have multiple
instances of a machine code.
Reflection also allows the programmer to query details of a method. It can be used to get the method
name, parameters, return types, and all other specifiers. Reflection uses method descriptors (metadata)
under the hood. Each descriptor is a structure providing a unique method handle (used for calling the
method), holding expensive metadata (like method modifiers), and capturing the runtime state of the
method. By querying the descriptor, we can determine if a method was already JIT-compiled and where
the generated machine code is. We can access the method descriptor via reflection with
Type.GetMethod(), and then access its method handle with the MethodHandle property.
By examining the method handle, we can access the internal structures of a method. For instance, we can
find the actual pointer specifying where the machine code of the method is. This gives us a way to point a
method to some different code or modify the logic in place, which we can use to override a sealed method.
Let’s say we have a non-virtual, non-static method X, and we want to modify it to call method Y instead.
If X were virtual, we could inherit the method from the base class, override it with Y, and then use the
polymorphic invocation to achieve our goal. However, since X is non-virtual, we need to modify it on a
lower level.
using System;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
Live Webinar and Q&A - Introducing MARA: A Reference Architecture for Kubernetes Microservices (Live Webinar
using
Sept 29th,System.Threading;
2022)
Save Your Seat
namespace OverridingSealedMethodNetCore
{
class Program
Console.WriteLine();
Console.WriteLine();
{
MultiTieredClass.Test(v, i);
Thread.Sleep(100);
// Prepare methods to get machine code (not needed in this example, thou
RuntimeHelpers.PrepareMethod(source.MethodHandle);
RuntimeHelpers.PrepareMethod(target.MethodHandle);
Webinar and Q&A - Introducing MARA: A Reference Architecture for Kubernetes Microservices (Live Webinar
Live
Sept 29th, 2022)
var sourceMethodDescriptorAddress = source.MethodHandle.Value;
[MethodImpl(MethodImplOptions.NoInlining)]
[MethodImpl(MethodImplOptions.NoInlining)]
class TestClass
[MethodImpl(MethodImplOptions.NoInlining)]
[MethodImpl(MethodImplOptions.NoInlining)]
class MultiTieredClass
[MethodImpl(MethodImplOptions.NoInlining)]
Q&A Introducing MARA: A Reference Architecture for Kubernetes Microservices (Live Webinar
Sept 29th, 2022)
Console.WriteLine($"Vector iteration {i:0000}:\t{v}\t{TestClass.StaticSt
Save Your Seat
}
In the example above, we have a class named TestClass with two methods: StaticString (line 71) and
InstanceString (line 77). Both of them are non-virtual and return a hard-coded string. Our goal is to
hijack these methods so when calling StaticString the .NET platform executes the
StaticStringHijacked method (line 56). Similarly, when calling InstanceString, we want to get
InstanceStringHijacked (line 62) called.
The Main method proceeds in the following manner: it first calls the StaticString method and prints its
output, then it hijacks it with StaticStringHijacked, and then it calls StaticString again to see if it
was successfully overridden. After that, it does the same for the InstanceString method. The whole
magic happens in the method HijackMethod (line 38).
HijackMethod accepts four parameters. The first two define the source method to be overridden (method
X, or StaticString in our example). The last two parameters define the target method (method Y, or
StaticStringHijacked in our case). To specify a method we need the Type instance holding the method
and the method name. Since this is an example only, we don’t handle situations when there are multiple
methods of the same name with different sets of arguments, but the code above can be easily extended to
that end.
We start by taking method descriptors of the methods (lines 41-42) by calling the regular GetMethod
function from the reflection mechanism:
Since the methods may not be JIT-compiled yet, we trigger the compilation manually by calling
RuntimeHelpers.PrepareMethod (lines 45-46):
// Prepare methods to get machine code (not needed in this example, though)
RuntimeHelpers.PrepareMethod(source.MethodHandle);
RuntimeHelpers.PrepareMethod(target.MethodHandle);
Live
WithWebinar
that, weand
getQ&A - Introducing
actual MARA:
pointers that weAcan
Reference
modify.Architecture for Kubernetes
The first one Microservices
is the address (Live Webinar
of the internal method
Sept 29th, 2022)
descriptor of the
Save Your Seatsource method. It’s a structure that holds the address of the machine code backing the
method. The address is stored in two pointers from the beginning of the structure (8 bytes from the
beginning in a 32-bit application or 16 bytes from the beginning in a 64-bit application). This depends on
the .NET version as the internal representation may be changed at any time, but it is consistent from
.NET Framework 1 until .NET 5. We get the address of the structure in line 48
Next, we get the address of the machine code of the target method. The .NET platform provides a method
named GetFunctionPointer that does exactly that, but we could as well extract this value manually by
getting the internal descriptor address and then reading the pointer, which is 8 or 16 bytes from the
beginning depending on the CPU architecture in use (line 49):
To override the method, we take the pointer and modify it directly in the internal descriptor structure
(line 52):
After this modification, we effectively changed the pointer of the StaticString method, so now it points
to the machine code of StatingStringHijacked. When we call the method, it will effectively execute the
latter’s machine code, as we can see in the application output:
Start calling
StaticString
method
Get the address of the machine code, which points to the actual code of the StaticString method
Execute the
StaticString
code
Live
AfterWebinar and Q&A
the method - Introducing MARA: A Reference Architecture for Kubernetes Microservices (Live Webinar
hijacking:
Sept 29th, 2022)
Save Your Seat
Start calling
StaticString
method
Get the address of the machine code, which points to the code of the
StaticStringHijacked
method
Execute the
StaticStringHijacked
code
The same structure applies to the InstanceString method which we hijack afterwards. This hijacking
method works in .NET 5.0.102 in Windows 10 x64 and in .NET 5.0.401 in WSL2 Ubuntu 20.04. It works
for both Debug and Release configurations, for both x86 and x64.
However, it is not 100% bulletproof and may not work reliably. This is because .NET introduced a code
cache infrastructure for multi-tiered compilation, which is prone to the time effect. In other words, it may
be that the hijacked method will be picked up only “shortly after” we actually change pointers and the
first couple calls., which still point to the old method. To observe this effect, we will examine the second
part of the code.
We have a method named Test in line 86. It takes a two-dimensional vector as an argument, normalizes
it, and prints its value. The details of this mathematical operation are not important here. What is
important is the fact that the Vector2.Normalize() method may be highly optimized to use SSE
instructions, which we will observe due to multi-tiered compilation. In line 89, we print the following:
So we print the iteration number, normalized vector, and we call the StaticString method. We call the
method Test from the Main method multiple times, as seen in line 27:
MultiTieredClass.Test(v, i);
Thread.Sleep(100);
Live Webinar
Vector and Q&A -0003:
iteration Introducing MARA: A Reference
<0.9750545, Architecture for Kubernetes
-0.22196561> StaticMicroservices
string
(Live Webinar
Sept 29th, 2022)
Vector iteration
Save Your Seat 0004: <0.9750545, -0.22196561> Static string
We can see that even though we already hijacked the StaticString method to point to
StaticStringHijacked, the first iterations still call the regular code (not the hijacked one). However,
after half a second, we see that the output has changed. This is the code caching effect in practice.
However, things get even more interesting as we continue. Around the 35th iteration, a multi-tiered
compilation kicks in and recompiles the method. The output we get is:
Two important things happened here. First, the output of the Vector2.Normalize() method changed.
Previously it was printing 0.9750545, and now it returned 0.97505456 (note the additional 6 at the end of
the value). This shows that the code under the hood was recompiled and actually changed the value. The
reason is that the optimized version uses SSE instructions which provide higher precision. You can read
more about this behavior here.
The second important observation is that in iteration 35 a regular StaticString method was called. This
is the effect of method inlining. If we debug the code and observe internal structures, we can see the
following:
MethodTable: 00007ffa38464d48
mdToken: 0000000006000009
Module: 00007ffa3843f888
IsJitted: yes
Version History:
ILCodeVersion: 0000000000000000
ReJIT ID: 0
IL Addr: 0000000000000000
NativeCodeVersion: 0000018E3EF1D140
NativeCodeVersion: 0000000000000000
Live
So weWebinar
can seeand Q&Aare
there - Introducing MARA:
two instances of Athe
Reference
machine Architecture
code. The for Kubernetes
first instance Microservices (Live Webinar
(labeled QuickJitted) calls
Sept 29th, 2022)
the method,
Save Yourbut
Seatthe second instance (labeled OptimizedTier1) inlines the string literal. Effectively neither
StaticString nor StaticStringHijacked is called.
This technique will not work for all scenarios. It may not work for methods compiled in the AOT manner.
It may not support all methods from the standard library as method descriptors differ for them. As we
can see, it may break due to multi-tiered compilation or code inlining.
Pros:
Cons:
To generate the machine code, first we need to understand how the jump instruction works. In x86
architectures, it uses one value as a parameter (4 or 8 bytes long), which is a numerical offset of how far
to move in the memory address (literally jumping to another place). Since it uses an offset instead of an
absolute memory address, it’s slightly harder to use as we need to calculate the distance (offset) to jump.
However, we can use a trick to move to an absolute address. In 32-bit mode, we can push the address into
the stack and then execute the return instruction, which takes the address from the stack and moves to it.
In 64-bit architectures, we can’t push the address directly (as there is no instruction to push 8 bytes into
the stack), so we first move the address to the register and then push the register on the stack.
We want to generate this code and put it at the beginning of the StaticString method to execute it.
Effectively, we’ll always execute the source method and only then jump to the target.
using System;
using System.Linq;
using System.Numerics;
Live Webinar and Q&A - Introducing MARA: A Reference Architecture for Kubernetes Microservices (Live Webinar
using
Sept 29th,System.Reflection;
2022)
Save Your
using Seat
System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
namespace MethodHijackerNetCore
Console.WriteLine();
Console.WriteLine();
{
MultiTieredClass.Test(v, i);
Thread.Sleep(100);
[MethodImpl(MethodImplOptions.NoInlining)]
Live Webinar[MethodImpl(MethodImplOptions.NoInlining)]
and Q&A - Introducing MARA: A Reference Architecture for Kubernetes Microservices (Live Webinar
Sept 29th, 2022)
public
Save Your Seat string InstanceStringHijacked()
HijackMethod(source, target);
RuntimeHelpers.PrepareMethod(source.MethodHandle);
RuntimeHelpers.PrepareMethod(target.MethodHandle);
byte[] instruction;
if (is32Bit)
{
instruction = new byte[] {
.Concat(BitConverter.GetBytes((int)targetAddress))
.Concat(new byte[] {
0xC3 //ret
}).ToArray();
}
else
{
instruction = new byte[] {
.Concat(new byte[] {
0xC3 // ret
}).ToArray();
class TestClass
[MethodImpl(MethodImplOptions.NoInlining)]
[MethodImpl(MethodImplOptions.NoInlining)]
class MultiTieredClass
[MethodImpl(MethodImplOptions.NoInlining)]
v = Vector2.Normalize(v);
The important part happens in HijackMethod starting in line 59. We first compile both the source and
the target methods to generate the machine code.
Live Webinar
We get and Q&Acode
the machine - Introducing
addressMARA:
of bothA source
Reference
andArchitecture for Kubernetes
target methods (line 65).Microservices (Live
To do that, we Webinar
read the
Sept 29th, 2022)
address
Save from the method descriptor:
Your Seat
If we run this on a 32-bit platform, the code pushes the address to the stack and returns. This is what
happens in lines 74-80. You can see that 0x68 is the number of the instruction for pushing the value onto
the stack. After that, we cast the address to integer (which is 4 bytes long, but we know that we are on a
32-bit platform) and convert it to bytes. The last instruction is 0xC3, which takes the address from the
stack, removes it and jumps to it.
.Concat(BitConverter.GetBytes((int)targetAddress))
.Concat(new byte[] {
0xC3 //ret
}).ToArray();
On a 64-bit platform, we can’t push the value directly. We first need to move it to the rax register, then
push the register into the stack and return. This is done in lines 84-92. Notice that this time we cast the
address to a long instead of an integer (since on 64-bit platforms the addresses are 8-bytes long):
.Concat(BitConverter.GetBytes((int)targetAddress))
.Concat(new byte[] {
0xC3 //ret
}).ToArray();
Finally, we copy the code to the beginning of the source method’s machine code.
Start calling
StaticString
method
Get the address of the machine code which points to the same code address as before hijacking
Execute the
StaticString
code
The first part of the
StaticString
is a jump to the StaticStringHijacked, so we jump to the other method
Execute the
StaticStringHijacked
code
When
StaticStringHijacked
finishes, return to the caller directly (because the return address on the stack is the one used when
calling StaticString)
This method is not prone to the time effect as we don’t modify the metadata (we only modify the code
that gets executed). However, it is still prone to the inlining, as we can see with multi-tiered compilation
kicking in and inlining it directly:
Live
Also,Webinar and Q&Aworks
this technique - Introducing
for anyMARA: A Reference
method Architecture
as long as we can getfor
theKubernetes
address ofMicroservices
the machine(Live Webinar
code. It may
Sept 29th, 2022)
be harder for Seat
Save Your AOT-compiled methods (as the address is not stored directly in the 8th/16th byte of the
method descriptor) or external native code called with P/Invoke (as the code may not be writable and
we’ll need to call VirtualProtectEx or mprotect to modify it) but conceptually it works for all the cases.
Pros:
Cons:
Handling
StackOverflowException
via Vectored Exception Handling (VEH) mechanism to avoid stopping the test suite from being
killed
Injecting
try-catch
blocks for new thread creation to avoid getting the process terminated due to an unhandled
exception
Modifying the WinAPI wrapper to be able to execute the process in a different desktop.
Windows supports multiple desktops to isolate applications. This mechanism has been in place for over
20 years, but it was never exposed in the UI. There is an application called Desktops that allows us to
control multiple desktops and switch between them. This may be useful when we need to automate
applications that capture user input or steal focus (e.g., automated UI tests with Puppeteer in a headful
mode).
In order to run an application on a different desktop, we need to specify the lpDesktop field in the
STARTUPINFO structure.
Live Webinar
However, in and
C#,Q&A - Introducing
we don’t call thisMARA: A Reference
API directly: Architecture
we use for Kubernetes
the wrapping Microservices
code provided (Live Webinar
by the standard
Sept 29th, 2022)
library.
SaveUnfortunately,
Your Seat if we examine the .NET Framework code, it doesn’t allow us to set the value of
IpDesktop, and always initializes it to a null value.
If we want to run the application on a different desktop in C#, there are a few solutions:
We can call the WinAPI directly, but then we lose the support of the .NET API and need to control
processes on our own (including marshaling and input/output redirection)
We can copy the wrapping code on the side and modify it, but then we need to maintain it and keep
updated when it changes in the standard library.
We can modify the code directly in place to inject the
lpDesktop
value. To do that, we can override sealed methods with the technique described earlier.
In order to hijack the code, we need to find a way to inject some of our code before the STARTUPINFO
structure gets passed to the WinAPI but after it is created. We can use the constructor for that.
In the code above,the original constructor sets the cb field to the correct value directly. Then we provide a
new constructor, which sets the cb field via reflection and also sets the lpDesktop field to the name of the
desktop we want to use.
We call the
Process.Start()
method
Live Webinar and Q&A - Introducing MARA: A Reference Architecture for Kubernetes Microservices (Live Webinar
Process.Start()
Sept 29th, 2022)
creates
Save Youran instance of the STARTUPINFO structure
Seat
Instead of a regular constructor being called, our custom one gets executed, and we set the field
values via reflection
I have been using this technique with .NET Framework and Windows Server 2012/2016 for many years.
Summary
We can see that by getting our hands on the internal structures, we can change the behavior of the
platform. It requires an understanding of code generation, Operating System mechanisms and internals
of the .NET platform. However, at the end of the day, these are just bytes that we can modify to suit our
needs.
Thomas Betts
Lead Editor, Software Architecture and Design @InfoQ; Senior Principal Engineer
Write for InfoQ
Discuss