MoeCTF 2023 - RE500 EzNet

一个用 C# 写的奇怪的 crackme, 其中包含成千上万个(最后统计大概有几十万个)随机命名的类,需要从这些类中找到正确的 flag 判别逻辑。

Patch

  1. ILSpy, 提取逻辑re-1.dll和runtimeconfig
  2. 修改runtimeconfig
/* 删除它  
    "includedFrameworks": [  
      {  
        "name": "Microsoft.NETCore.App",  
        "version": "6.0.0"  
      }  
    ],  

*/  
{  
  "runtimeOptions": {  
    "tfm": "net6.0",  

    "framework": { // 替换为  
      "name": "Microsoft.NETCore.App",  
      "version": "6.0.0"  
    },  
    "configProperties": {  
      "System.Reflection.Metadata.MetadataUpdater.IsSupported": false  
    }  
  }  
}
  1. ildasmilasm 进行 PATCH
  • 去掉反调试判断,把 CatFoodSeller.DoSomething 改为直接返回猫粮:
    .method public hidebysig static object DoSomething() cil managed  
    {  
      .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 )  
      .param [0]  
      .custom instance void [System.Linq.Expressions]System.Runtime.CompilerServices.DynamicAttribute::.ctor() = ( 01 00 00 00 )  
      // Code size       125 (0x7d)  
      .maxstack  2  
      // 中间全删  
      IL_0077:  newobj     instance void KoitoCoco.MoeCtf.CatFood::.ctor()  
      IL_007c:  ret  
    } // end of method CatFoodSeller::DoSomething
  • 把 Main函数 里多余的东西去掉直接进入主逻辑:
    .method private hidebysig static void  '<Main>$'(string[] args) cil managed  
    {  
      .entrypoint  
      // Code size       141 (0x8d)  
      .maxstack  5  
      .locals init (string V_0, int32 V_1, int32 V_2)  
      IL_0000:  ldstr      "PATCHED! I dont' require a flag any more!"  // <=  
      IL_0005:  call       void [System.Console]System.Console::WriteLine(string)  
    
      ldstr "FlagHelper::FLAG => {0}"  
      ldsfld string KoitoCoco.MoeCtf.FlagHelper::Flag  
      call  void [System.Console]System.Console::WriteLine(string, object)  
    
      ldstr "Throw an exception so that we can enter EHandler." // <=  
      call  void [System.Console]System.Console::WriteLine(string)  
      ldc.i4.0  
      throw  
    
    } // end of method Program::'<Main>$'
  • ilasm 重新打包成 dll
    EZNET> ilasm.exe /dll /output=re-1.dll /Resource=re-1.res re-1.il
    这个 dll 可以直接用 dotnet re-1.dll 来运行,也可以用 dnspy 来动态调试(指定宿主程序为 dotnet 的全路径)
    img1

逻辑

Main()

  • 主函数引用 FlagHelper 导致 FlagHelper 被静态构造
  • FlagHelper 的构造函数插入一个全局 UnhandledExceptionHandler
  • 主函数触发一个除0异常进入 FlagHelper 的 Handler
  • 调用 CatFoodSeller.DoSomething() 检查调试器状态,如果全没问题返回一个 CatFood 对象
  • 以 flag 体(moectf{})中的前4个字节(偏移为7)作为名字找到一个 FlagMachine 实例
  • FlagMachine 实例(派生类的实例)调用一次 SetFlag() 然后再调用 VmeFlag(input) 检查 flag.
    • 调用实例的 SetFlag 方法时以 FeedCat() 的返回作为初始参数
    • FeedCat() 根据传入的对象不同,返回不同的 IV ,正版猫粮返回的是 "mEow????"

SetFlag()

  • 实例的 SetFlag() 递归调用基类的 SetFlag(), 最终基类是 FlagMachine.SetFlag(),这个 SetFlag() 的作用就只是把 IV 转成 byte[] (该层无操作,但 IV transformation 不是从这层开始的,见下。)

  • 递归开始向派生传递,FlagMachine 有两个直接子类 ButAnotherFlagMachineYetAnotherFlagMachine ,这一层(#BY层)中将 IV 进行一次异或, key 分别为 0x15511551155115510xC0DEC0DEC0DEC0DE (应该是源码手写指定的)

  • (#RF层)下一层,ButAnotherFlagMachineYetAnotherFlagMachine 各有两个直接子类:

    • ButAnotherFlagMachine <- RealFlagMachine
    • ButAnotherFlagMachine <- FakeFlagMachinePlus
    • YetAnotherFlagMachine <- RealFlagMachinePlus
    • YetAnotherFlagMachine <- FakeFlagMachine
    • 该层的所有 SetFlag() 调用形式均为 base.Setflag(flag^val) 异或某个 64bit 数。IV 传递到该层会先被处理再往基类传。
  • (#GOOD层)再下一层,每个类派生两个子类,该层所有类都以「 good 」 的变体单词作为命名。

    • RealFlagMachine <- FlagMachine_gOOd ‘0x1551155095511551’
    • RealFlagMachine <- FlagMachine_gOOD ‘0x1551155905511551’
    • FakeFlagMachinePlus <- FlagMachine_goOd ‘0x1550155115519551’
    • FakeFlagMachinePlus <- FlagMachine_goOD ‘0x1550155115519551’
    • RealFlagMachinePlus <- FlagMachine_gOod ‘0x1551105115911551’
    • RealFlagMachinePlus <- FlagMachine_gOoD ‘0x1551150119511551’
    • FakeFlagMachine <- FlagMachine_good ‘0x1051155115511591’
    • FakeFlagMachine <- FlagMachine_gooD ‘0x1501155115511951’
    • 该层所有 SetFlag() 调用形式均为 base.SetFlag(BitConverter.ToUInt64((byte[])flag) + val),将 byte[] 转成了 64bit 整数并加上了一个值,所加的值放在上面列表。 IV 传递到该层会先被处理再往基类传。
    • 可以看到所所有「real」 flag machine 累加值都以 1551 开头 1551 结尾,make sense :)
  • (#RANDOM层)再下一层,每个「 good 类 」都派生一个 「 Good 类 」 (尾部3个字符pattern一致,单词开头变为大写),以及若干随机字符作为后缀的类。从该层开始每层的 SetFlag() 实现中都会先调用 base.SetFlag(flag) 再对 this.Flag (SelfClass::Flag)原地异或。

    • 8个「Good 类」异或值都为 0x1145141919810000 (恼)
  • 综上, IV"mEow????") 进入调用链后先被 #GOOD 层加上某个数,再异或 #RF#BY 两层的数,然后开始递归返回,在 #RANDOM 层中依次与派生类中设置的数异或。由于所有异或可以调换顺序,所以在解密代码中一次性一起处理。

VmeFlag()

  • 只有 #RF 层的 4 个类重载了 VmeFlag() ,4个重载完全一样(否则应该可以直接根据逻辑排除错误的 flag 处理流了),其中调用 KoitoMagicalShop.IsRealFlag() 进行检查。
  • 再上一层 BY 层的 VmeFlag() 会输出两种不同的 mumble#RF::VmeFlag() 中会先调用它们,然后才进行判断。
  • IsRealFlag() 开头先判定 flag 长度必须为 72. 结合 Main() 流程的 7 偏移判断, flag 结构大致为 moectf{<4:selector><60:key>}selector 是 4 位字母,用于选择一个类开始执行变换流;60位的 key 为任意字符串。
  • 使用 SetFlag() 留下的 64bit 数作为参数调用 ResetState() , 这个函数重设一个 int[512] States 的数组,然后调用 233 次 GetNextSpellcard()
  • 每次 GetNextSpellcard() 都将 States 弹出一位并在末尾填上一个新数 num,因此 ResetState() 后, States 的前 512-233=279 位为固定数值。
  • 每次 GetNextSpellcard() 都返回一个新 num, 这个数由将 States 作为全查表再加一些变换得到:
    public static byte GetNextSpellcard() // params 是 SetFlag() 得到的 8 个bytes  
    {  
      int num = 233;  
      int[] @params = Params;  
      foreach (int num2 in @params)  
      {  
        num = (num ^ States[num2]) + 1;  
      }  
      for (int l = 0; l < 511; l++)  
      {  
        States[l] = States[l + 1];  
      }  
      States[511] = num;  
      return (byte)num;  
    }
  • 最后再用 GetNextSpellcard() 抽出 72 个数,分别与一个预设置数组异或后判断与输入 flag 是否相等

调试

想了很多种姿势。一开始尝试提取末尾状态用 z3 来算,然后跑了一个小时没结果感觉不对。

兜兜转转最后还是想办法克服了 dnSpy 反编译的一点小问题,用修改原程序的方式找到了突破口。

  • 从前面逻辑分析可知, IsRealFlag() 判断最关键的点是用一个 8 字节 IV 初始化 SBOX. 必须想办法得到 IV 的值,否则 SBOX 的初始状态和用于变换的 Spell 都是未知的,就算已知 flag 前面的部分也不可能算出下一状态。

  • 那么既然程序中 #RANDOM 层的类数量也不是很大(最多26**4 => 456976),完全可以尝试遍历一遍看能不能跑出可以匹配 flag 前 7 位的 IV.

    • PATCH 掉程序所有多余的 Console.Writeline() 提高速度
    • SetFlag() 前输出当前遍历的类名:
    AppDomain.CurrentDomain.UnhandledException += delegate(object sender, UnhandledExceptionEventArgs args)  
    {  
      // ...  
      Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();  
      for (int i = 0; i < assemblies.Length; i++)  
      {  
        foreach (Type type in from t in assemblies[i].GetTypes()  
          where t.Name.Contains("FlagMachine_")  
          select t)  
        {  
          IFlagMachine flagMachine = Activator.CreateInstance(type) as IFlagMachine;  
          if (flagMachine != null)  
          {  
            Console.WriteLine("Now => " + type.Name);  
            flagMachine.SetFlag(Encoding.UTF8.GetBytes("mEow????"));  
            flagMachine.VmeFlag(@string); // whatever. IsRealFlag() 已patch  
          }  
        }  
      }  
      Console.WriteLine(Encoding.UTF8.GetString(Convert.FromBase64String("aGV5IGJybywgdGhpcyBpcyBhIGZha2UgZmxhZyBtYWNoaW5lISB3ZSBhcmUgY2hlYXRlZCE=")));  
    };
    • PATCH 掉 IsRealFlag() 的逻辑,只判断前 7 位,成功后抛一个异常,中断程序
    // disassembled  
    byte[] array2 = new byte[72];  
    int num = 0;  
    for (int k = 0; k < 7; k++)  
    {  
      array2[k] ^= GetNextSpellcard();  
    }  
    for (int l = 0; l < 7; l++)  
    {  
      if ((array2[l] ^ flag[l]) == array[l])  
      {  
        num++;  
      }  
    }  
    if (num == 7)  
    {  
      Console.WriteLine("Found => [" + string.Join(", ", Params) + "]");  
      throw new Exception();  
    }
  • 拿到 IV 后就可以直接让程序算出最后的 flag 了(直接用 dnSpy 重编译):

    public static bool IsRealFlag(byte[] flag, byte[] paramaters)  
    {  
        if (flag.Length != 0x48)  
        {  
            return false;  
        }  
        paramaters = new byte[] { /* IV */ };  
        KoitoMagicalShop.ResetState(paramaters);  
        byte[] array = new byte[]  
        {  
            0x8F, 0x4B, 0x82, 0x23, 0xFB, 0x33, 0x33, 0x31, 0x5C, 0x91,  
            0x97, 0xD, 0x1E, 0xC8, 0x2F, 0xE, 0xE7, 0x64, 0x31, 0xA9,  
            0x38, 0x19, 0x5E, 0xB0, 0x74, 0xB, 0x80, 0xA, 0xBA, 0x3F,  
            0xB9, 0x2D, 0xD8, 0x37, 0xBE, 0x48, 0x82, 0xC8, 0x8B, 0xFC,  
            0x3A, 0xFA, 0x25, 0x97, 0xB3, 0xDC, 0xC8, 0x23, 0x6F, 0x29,  
            0x64, 0x57, 0xCB, 0x36, 7, 0x51, 0x3B, 0x99, 0xA5, 0x47,  
            byte.MaxValue, 0xC3, 0xDC, 0x90, 0x70, 0xF3, 0xE3, 0xFB, 0xE4, 0xE8,  
            0xF6, 0xFB  
        };  
        byte[] array2 = new byte[0x48];  
        for (int i = 0; i < 0x48; i++)  
        {  
            array2[i] = KoitoMagicalShop.GetNextSpellcard() ^ array[i];  
        }  
        Console.WriteLine("Flag => " + Encoding.UTF8.GetString(array2));  
        throw new Exception();  
    }