dotnet host startup hook

警告
本文最后更新于 2023-01-03,文中内容可能已过时。

# DOTNET_STARTUP_HOOKS

dotnet core提供了一个底层的hook钩子,通过环境变量设置DOTNET_STARTUP_HOOKS=aaa.dll就可以在Main函数之前运行一些自定义代码

image.png

System.Private.CoreLib.dll!System.StartupHookProvider类中

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics;
using System.Diagnostics.Tracing;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;

namespace System
{
    internal static class StartupHookProvider
    {
        private const string StartupHookTypeName = "StartupHook";
        private const string InitializeMethodName = "Initialize";
        private const string DisallowedSimpleAssemblyNameSuffix = ".dll";

        private static bool IsSupported => AppContext.TryGetSwitch("System.StartupHookProvider.IsSupported", out bool isSupported) ? isSupported : true;

        private struct StartupHookNameOrPath
        {
            public AssemblyName AssemblyName;
            public string Path;
        }

        // Parse a string specifying a list of assemblies and types
        // containing a startup hook, and call each hook in turn.
        private static void ProcessStartupHooks()
        {
            string? startupHooksVariable = AppContext.GetData("STARTUP_HOOKS") as string;
            ...
            // Parse startup hooks variable
            string[] startupHookParts = startupHooksVariable.Split(Path.PathSeparator);
            StartupHookNameOrPath[] startupHooks = new StartupHookNameOrPath[startupHookParts.Length];
            for (int i = 0; i < startupHookParts.Length; i++)
            {
                string startupHookPart = startupHookParts[i];
                ...
                if (Path.IsPathFullyQualified(startupHookPart))
                {
                    startupHooks[i].Path = startupHookPart;
                }
                else
                {
                    // The intent here is to only support simple assembly names, but AssemblyName .ctor accepts
                    // lot of other forms (fully qualified assembly name, strings which look like relative paths and so on).
                    // So add a check on top which will disallow any directory separator, space or comma in the assembly name.
                    for (int j = 0; j < disallowedSimpleAssemblyNameChars.Length; j++)
                    {
                        if (startupHookPart.Contains(disallowedSimpleAssemblyNameChars[j]))
                        {
                            throw new ArgumentException(SR.Format(SR.Argument_InvalidStartupHookSimpleAssemblyName, startupHookPart));
                        }
                    }

                    if (startupHookPart.EndsWith(DisallowedSimpleAssemblyNameSuffix, StringComparison.OrdinalIgnoreCase))
                    {
                        throw new ArgumentException(SR.Format(SR.Argument_InvalidStartupHookSimpleAssemblyName, startupHookPart));
                    }

                    try
                    {
                        // This will throw if the string is not a valid assembly name.
                        startupHooks[i].AssemblyName = new AssemblyName(startupHookPart);
                    }
                    catch (Exception assemblyNameException)
                    {
                        throw new ArgumentException(SR.Format(SR.Argument_InvalidStartupHookSimpleAssemblyName, startupHookPart), assemblyNameException);
                    }
                }
            }

            // Call each hook in turn
            foreach (StartupHookNameOrPath startupHook in startupHooks)
            {
                CallStartupHook(startupHook);
            }
        }

        // Load the specified assembly, and call the specified type's
        // "static void Initialize()" method.
        [RequiresUnreferencedCode("The StartupHookSupport feature switch has been enabled for this app which is being trimmed. " +
            "Startup hook code is not observable by the trimmer and so required assemblies, types and members may be removed")]
        private static void CallStartupHook(StartupHookNameOrPath startupHook)
        {
            Assembly assembly;
            try
            {
                if (startupHook.Path != null)
                {
                    Debug.Assert(Path.IsPathFullyQualified(startupHook.Path));
                    assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(startupHook.Path);
                }
                else if (startupHook.AssemblyName != null)
                {
                    Debug.Assert(startupHook.AssemblyName != null);
                    assembly = AssemblyLoadContext.Default.LoadFromAssemblyName(startupHook.AssemblyName);
                }
                ...
            Type type = assembly.GetType(StartupHookTypeName, throwOnError: true)!;

            // Look for a static method without any parameters
            MethodInfo? initializeMethod = type.GetMethod(InitializeMethodName,
                                                         BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static,
                                                         null, // use default binder
                                                         Type.EmptyTypes, // parameters
                                                         null); // no parameter modifiers
            ...
            initializeMethod.Invoke(null, null);
        }
    }
}

截取了部分代码,其实就是在这个StartupHookProvider类中进行加载程序集,然后反射调用Initialize函数,堆栈如下

image.png

如果你跟不到这个地方,那么需要勾掉这个选项:工具-选项-启用“仅我的代码”

image.png

然后回溯栈帧的时候会让你选择符号服务器,勾上之后就可以跟进来了。

src\coreclr\vm\assembly.cpp在这个cpp文件中

image.png

调用了钩子

https://github.com/dotnet/runtime/blob/2619d1c8eeef4a881c3910c87c1a8903ed742c24/src/coreclr/vm/assembly.cpp#L1494

image.png

RunStartupHooks在RunMain函数之前运行。

# 思考

这个环境变量该如何使用?和java agent有什么区别?

想了想有几个用途

  1. 程序监控
  2. 后门
  3. 静态免杀

对于程序监控而言,aws的lambda就已经用上了这个东西

https://docs.aws.amazon.com/zh_cn/lambda/latest/dg/runtimes-modify.html

除此之外还有GitHub的一些开源项目,比如 https://github.com/newrelic/newrelic-dotnet-agent

再比如elastic的agent代理 https://www.elastic.co/guide/en/apm/agent/dotnet/current/setup-dotnet-net-core.html#zero-code-change-setup

对于后门来说,加一个环境变量应该不是很敏感吧

而对于静态免杀来讲,程序集通过dotnet runtime加载,而并非自己Assembly.Load引入,并且执行点不在Main函数中,可能相对效果好一些?

这些思考都是猜测,并没有实践过,读者自测吧。

对于java agent来说,java提供了动态attach的功能,而dotnet只能通过环境变量引入,需要重启,内存马可能不太现实。

暂时没想到什么好的利用面,留给读者吧。

# 参考

https://github.com/dotnet/runtime/blob/main/docs/design/features/host-startup-hook.md

文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。