一个用 C# 写的奇怪的 crackme, 其中包含成千上万个(最后统计大概有几十万个)随机命名的类,需要从这些类中找到正确的 flag 判别逻辑。
Patch
- ILSpy, 提取逻辑re-1.dll和runtimeconfig
- 修改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
}
}
}
- 用
ildasm
和ilasm
进行 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
这个 dll 可以直接用EZNET> ilasm.exe /dll /output=re-1.dll /Resource=re-1.res re-1.il
dotnet re-1.dll
来运行,也可以用dnspy
来动态调试(指定宿主程序为dotnet
的全路径)
逻辑
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
有两个直接子类ButAnotherFlagMachine
和YetAnotherFlagMachine
,这一层(#BY层)中将 IV 进行一次异或, key 分别为0x1551155115511551
和0xC0DEC0DEC0DEC0DE
(应该是源码手写指定的)(#RF层)下一层,
ButAnotherFlagMachine
和YetAnotherFlagMachine
各有两个直接子类: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
(恼)
- 8个「Good 类」异或值都为
综上, 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(); }