Faking Time in .NET by Hooking DateTime
This content was translated from Korean using AI.

The Need to Change Time

During development, there are times when you need to manipulate time for testing purposes.

On Windows, you can usually change the time through the Control Panel, and the updated time is immediately reflected in running applications.

However, there are various reasons why you might need to change the time within a program, such as company policies preventing time changes or situations where you cannot remotely access a running server.

To address this, .NET 8 introduced a feature called TimeProvider, which allows for time abstraction.
What is the TimeProvider class - .NET | Microsoft Learn

However, if significant development has already taken place and the codebase has grown large, you would need to find and modify all instances of DateTime. Unless you prevent the use of standard DateTime through static analysis, you could end up with a mix of abstracted DateTime and standard DateTime in your code.

Moreover, even with this management, there is the issue that you cannot abstract DateTime used in third-party libraries.

For these reasons, there is a need for functionality that allows you to change time by hooking DateTime without using .NET's time abstraction.

Hooking with Harmony

Generally, when you want to change the behavior of a method at runtime in .NET, you can use a library called Harmony.
GitHub - pardeike/Harmony: A library for patching, replacing and decorating .NET and Mono methods during runtime

For example, using a postfix, you can modify the result of a method. The code below is an example that adds 100 years to the result of DateTime.Now.

using System.Reflection;
using HarmonyLib;

internal class Program
{
    static void Main(string[] args)
    {
        var now = DateTime.Now;

        var harmony = new Harmony("TimeHook");
        harmony.PatchAll(Assembly.GetExecutingAssembly());

        var newNow = DateTime.Now;

        Console.WriteLine($"{now}");        // 2025-06-08 5:52:46 PM
        Console.WriteLine($"{newNow}");     // 2125-06-08 5:52:46 PM
    }

    const int YEAR_TEST = 100;

    [HarmonyPatch(typeof(DateTime), "get_Now")]
    class Patch
    {
        static DateTime Postfix(DateTime result)
        {
            return result.AddYears(YEAR_TEST);
        }
    }
}

However, this method cannot be applied everywhere. The ability to hook DateTime.Now and DateTime.UtcNow varies depending on the .NET version.

When I last tested it, hooking DateTime.UtcNow in a .NET 8 environment resulted in an exception for some unknown reason, while in .NET 7, the method was inlined due to optimization, and the behavior of DateTime.Now was not changed.

4_harmony_failed.png

Can't override property getter. · Issue #70 · pardeike/Harmony

Failed to patch DateTime.Now with .net 7.0 · Issue #546 · pardeike/Harmony

Hooking with s_pfnGetSystemTimeAsFileTime

Before explaining this method, it's important to note that it is only possible on Windows and cannot be used on other platforms like Linux.

.NET uses kernel32.dll to obtain time on Windows. While it's better to use the more precise GetSystemTimePreciseAsFileTime, it can lead to larger errors on poorly configured systems, so we should fallback to GetSystemTimeAsFileTime.
DateTime.Now is drifting away with computer's up time · Issue #9014 · dotnet/runtime

Thus, .NET includes code that determines whether there are issues with the Precise value at runtime and decides whether to fallback. To avoid performance issues from checking this every time, the code saves a function pointer in a static variable to decide which one to use.

// .NET 7 code
private static readonly unsafe delegate* unmanaged[SuppressGCTransition]<ulong*, void> s_pfnGetSystemTimeAsFileTime = GetGetSystemTimeAsFileTimeFnPtr();

private static unsafe delegate* unmanaged[SuppressGCTransition]<ulong*, void> GetGetSystemTimeAsFileTimeFnPtr()
{
    IntPtr kernel32Lib = Interop.Kernel32.LoadLibraryEx(Interop.Libraries.Kernel32, IntPtr.Zero, Interop.Kernel32.LOAD_LIBRARY_SEARCH_SYSTEM32);
    Debug.Assert(kernel32Lib != IntPtr.Zero);

    IntPtr pfnGetSystemTime = NativeLibrary.GetExport(kernel32Lib, "GetSystemTimeAsFileTime");

    if (NativeLibrary.TryGetExport(kernel32Lib, "GetSystemTimePreciseAsFileTime", out IntPtr pfnGetSystemTimePrecise))
    {
        for (int i = 0; i < 10; i++)
        {
            long systemTimeResult, preciseSystemTimeResult;
            ((delegate* unmanaged[SuppressGCTransition]<long*, void>)pfnGetSystemTime)(&systemTimeResult);
            ((delegate* unmanaged[SuppressGCTransition]<long*, void>)pfnGetSystemTimePrecise)(&preciseSystemTimeResult);

            if (Math.Abs(preciseSystemTimeResult - systemTimeResult) <= 100 * TicksPerMillisecond)
            {
                pfnGetSystemTime = pfnGetSystemTimePrecise; // use the precise version
                break;
            }
        }
    }

    return (delegate* unmanaged[SuppressGCTransition]<ulong*, void>)pfnGetSystemTime;
}

By changing this static variable to our time function, we can change the entire .NET time without a complicated process.

This static variable is not only private but also readonly, making direct modification impossible. However, when creating a method at runtime using DynamicMethod, it is possible to modify the field of an object regardless of these restrictions.

Therefore, we can use reflection to find s_pfnGetSystemTimeAsFileTime, create a method with DynamicMethod that takes a parameter to set this field, and execute it with the pointer of our custom time function.

Below is the implementation code, which also accommodates changes in the location of s_pfnGetSystemTimeAsFileTime depending on the .NET version.

If you are in a multi-threaded environment, it is recommended to perform the initialization in the Main function before the threads are separated. I am not entirely sure if this is due to compiler optimization, but there have been cases where applying the Init after the threads are separated did not work well in Release builds.

Implementation of HookedSystemTime

using System.Diagnostics;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.InteropServices;

namespace TimeTest;

public static class NativeMethods
{
    [StructLayout(LayoutKind.Sequential)]
    public struct FileTime
    {
        public uint dwLowDateTime;
        public uint dwHighDateTime;
    }
    
    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern void GetSystemTimeAsFileTime(out FileTime lpSystemTimeAsFileTime);
}
    
public unsafe class HookedSystemTime
{
    private static int _days;
    private static int _hours;
    private static int _minutes;
    private static int _seconds;

    public static bool Initialized { get; private set; }

    public static Action? OnChanged;

    public static long Ticks
    {
        get
        {
            long ticks = 0;
            ticks += TimeSpan.TicksPerDay * Volatile.Read(ref _days);
            ticks += TimeSpan.TicksPerHour * Volatile.Read(ref _hours);
            ticks += TimeSpan.TicksPerMinute * Volatile.Read(ref _minutes);
            ticks += TimeSpan.TicksPerSecond * Volatile.Read(ref _seconds);
            return ticks;
        }
    }

    public static bool Modified => Initialized && Ticks > 0;

    public static void Init()
    {
        if (Initialized)
            return;

        ulong testTime;
        GetCustomSystemTime(&testTime);
        if (testTime <= 0)
            return;

        IntPtr customFuncPtr = GetCustomSystemTimePtr();
        if (customFuncPtr == IntPtr.Zero)
            return;

        FieldInfo? fieldInfo = null;

        foreach (FieldInfo fi in typeof(DateTime).GetRuntimeFields())
        {
            if (fi.Name == "s_pfnGetSystemTimeAsFileTime" && fi.FieldType == typeof(IntPtr))
            {
                fieldInfo = fi;
                break;
            }
        }

        if (fieldInfo == null)
        {
            Type? leapClass = typeof(DateTime).GetNestedType("LeapSecondCache", BindingFlags.NonPublic);
            if (leapClass != null)
            {
                foreach (FieldInfo fi in leapClass.GetRuntimeFields())
                {
                    if (fi.Name == "s_pfnGetSystemTimeAsFileTime" && fi.FieldType == typeof(IntPtr))
                    {
                        fieldInfo = fi;
                        break;
                    }
                }
            }
        }

        if (fieldInfo == null)
        {
            Debug.Fail("Failed to find s_pfnGetSystemTimeAsFileTime field for hooking.");
            return;
        }

        var method = new DynamicMethod(
            name: "HookDateTime",
            returnType: null,
            parameterTypes: new[] { typeof(IntPtr) },
            restrictedSkipVisibility: true
        );

        var gen = method.GetILGenerator();
        gen.Emit(OpCodes.Ldarg_0);
        gen.Emit(OpCodes.Stsfld, fieldInfo);
        gen.Emit(OpCodes.Ret);

        var fieldSetter = (Action<IntPtr>)method.CreateDelegate(typeof(Action<IntPtr>));
        fieldSetter(customFuncPtr);

        Initialized = true;
    }

    private static IntPtr GetCustomSystemTimePtr()
    {
        var methodInfo = typeof(HookedSystemTime).GetMethod(nameof(GetCustomSystemTime), BindingFlags.Static | BindingFlags.Public);
        return methodInfo == null ? IntPtr.Zero : methodInfo.MethodHandle.GetFunctionPointer();
    }

    public static void GetCustomSystemTime(ulong* ptr)
    {
        NativeMethods.GetSystemTimeAsFileTime(out NativeMethods.FileTime fileTime);
        ulong combined = ((ulong)fileTime.dwHighDateTime << 32) | fileTime.dwLowDateTime;
        combined += (ulong)Ticks;
        *ptr = combined;
    }

    public static void AddSeconds(int seconds)
    {
        AddOffset(0, 0, 0, seconds);
    }
        
    public static void AddMinutes(int minutes)
    {
        AddOffset(0, 0, minutes, 0);
    }
        
    public static void AddHours(int hours)
    {
        AddOffset(0, hours, 0, 0);
    }
        
    public static void AddDays(int days)
    {
        AddOffset(days, 0, 0, 0);
    }

    public static void AddOffset(int days, int hours, int minutes, int seconds)
    {
        if (!Initialized)
            return;
            
        if (days == 0 && hours == 0 && minutes == 0 && seconds == 0)
            return;

        if (days != 0) Interlocked.Add(ref _days, days);
        if (hours != 0) Interlocked.Add(ref _hours, hours);
        if (minutes != 0) Interlocked.Add(ref _minutes, minutes);
        if (seconds != 0) Interlocked.Add(ref _seconds, seconds);
            
        OnChanged?.Invoke();
    }

    public static DateTime GetOriginalNow()
    {
        return Modified ? DateTime.Now.AddTicks(-Ticks) : DateTime.Now;
    }
}

Test Code

You can test it as follows:

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine($"[Main] Before: {DateTime.Now}");
        
        for (int i = 0; i < 4; i++)
        {
            int index = i;
            new Thread(() =>
            {
                Console.WriteLine($"[Thread {index}] Before: {DateTime.Now}");
                Thread.Sleep(3000);
                Console.WriteLine($"[Thread {index}] After: {DateTime.Now}");
            }).Start();
        }
        
        HookedSystemTime.Init();
        HookedSystemTime.AddDays(100);
        
        Console.WriteLine($"[Main] After: {DateTime.Now}");
    }
}

4_test_result.png