攻破LedgerCTF的AES白盒挑战
引言
大约一个月前,我的伙伴b0n0n正在研究ledgerctf谜题,并挑战我查看ctf2二进制文件。最终我接受了挑战,本文讨论了保护方案以及我如何破解它。在深入之前,先提供一些背景信息。
ledger是一家成立于2014年的法国安全公司,专注于密码学、加密货币和硬件。他们最近在线上发布了三个不同的谜题,以庆祝其漏洞赏金计划的正式启动。第二个挑战称为ctf2,我们今天将讨论它。ctf2是一个ELF64二进制文件,可在此处下载(如果你想跟随操作)。该二进制文件大约11MB,用C++编写,甚至包含符号;很好。
开始吧!
目录
- 引言
- 整体概览
- 侦察
- 扩散
- 混淆
- 生成
- 深入分析
- 调度
- 案例0:编码
- 案例1、5、9、13、17、21、25、29、33、37:SubBytes
- 案例2、6、10、14、18、22、26、30、34、38:ShiftRows
- 案例3、7、11、15、19、23、27、31、35:MixColumns
- 案例4、8、12、16、20、24、28、32、36:AddRoundKey
- 案例39:解码
- 反轮
- 如何获胜?
- 结论
整体概览
侦察
首先,你肯定已经注意到二进制文件中包含大量数据,如下图所示。这意味着二进制文件要么是打包的,IDA难以识别代码部分,要么确实是真实数据。
由于我们已经知道二进制文件未被剥离,第一种假设很可能错误。通过浏览反汇编器中的代码,没有发现异常;一切看起来健康。没有混淆、代码加密或任何类型的打包迹象。此时我们确信这是一个纯粹的反向工程挑战,一帆风顺!
扩散
二进制文件期望一个序列作为输入,该序列是由32个十六进制字符组成的字符串,例如:00112233445566778899AABBCCDDEEFF。然后,有一个包含16轮的循环,逐字符遍历序列并构建15个 blob,每个16字节长;我称它们为i0、i1、…、i14(这非常自解释)。此循环的每一轮初始化每个i的一个字节(因此有16轮)。当前输入序列字节通过一个巨大的替换盒(我称为sbx,长度为11534336字节)发送。这基本上将输入序列扩散到这些blob中。如果上述解释不够清晰,以下是美化后的C代码:
|
|
混淆
之后,发生了一堆事情,目前不一定有太多意义。但就我而言,这还不关心,因为我还没有看到与输入序列字节或i的明确关系。由于这两者是唯一用户输入派生的数据,目前我只关心这些。
接下来,我们遇到这段代码:
|
|
我从这部分学到的是,有新玩家加入。基本上,三个16字节的blob,分别称为mask、mask3和shiftedmask,使用从rand()派生的值进行初始化。起初,看到伪随机值参与其中确实有点令人困惑,但我们可以假设这些操作稍后会被其他操作取消。让一些类似加密的算法产生非确定性结果是没有意义的。PRNG使用time(NULL)进行种子设置。
之后还有其他一些我们不关心的操作。你可以将它们视为黑盒,生成确定性输出。这意味着我们可以在需要时方便地转储生成的值。值得一提的是,它基本上在mask3内部混合了一堆值。
|
|
mul3和mul2基本上是数组,构造为mul2[idx] = idx * 2和mul3[idx] = idx * 3在GF(2**8)中。
一个可能有趣的事情是,其中有一个小的反调试。文件被打开并使用std::vector的构造函数读取,该构造函数接受std::ifstreambuf_iterator作为输入。生成某种校验和,并将在稍后的调度例程中使用。这意味着如果你要修补二进制文件,算法最终会生成错误的值。同样,这几乎不是一个不便,因为我们可以将其转储并继续我们的生活。
生成
此时,上述15个i用于初始化我称为s0、s1、…、s14的内容。再次,这是15个blob,每个16字节。它们被传递给调度函数,该函数将在s数组上执行大量算术操作。再次,尚不需要理解调度;就我们而言,它是一个黑盒,接受s作为输入并返回不同的s作为输出,句点。
每个这些16字节(方便的是,XMM寄存器是16字节长,允许编译器优化操作这些blob的代码)(s0、…、s14)被异或在一起,如果结果的xmmword遵守一堆约束,那么你会得到好消息。
这些约束看起来像这样:
|
|
这垃圾简单地转化为win = (mxor == 0x42424242696969693737373713131313ULL) :)。
深入分析
现在是时候深入并动手一点了。我们有点知道我们需要实现什么,但我们不确定如何到达那里。我们知道我们需要进行一些转储:mask、mask3、shiftedmask、crc、sbx、mul2和mul3。容易。机械。
最重要的未知部分是更多地理解调度。你可以将其视为挑战的核心。所以让我们这样做。
调度
乍一看,该函数看起来并不太糟糕,这总是好的。该函数的第一部分是随机选择s变量之一(变量i用于索引状态数组,其中所有s都在)。
|
|
随后的switch case对选定的s变量应用一种类型的转换(算术转换)。为了跟踪已应用于每个s变量的轮数,使用了一个称为调度的数组。当四十轮已应用于每个s时,算法停止。还值得指出的是,这里有一个小的反调试;一轮开始时启动计时器(t1),结束时停止(t2)。如果发现t1和t2之间的任何异常延迟,后续计算将产生错误结果。
我们可以在switch case中观察到6种不同类型的操作。其中一些看起来非常容易反转,而其他一些则需要更多工作。但在这一点上,它让我想起了我在2013年分析过的这个AES白盒。这个没有任何混淆,这使得处理起来容易得多。我当时做的是非常简单:分而治之。我将每轮分解为四个部分。每个这些四分之一轮作为一个黑盒函数,接受4字节输入并生成4字节输出(因此每轮将生成16字节/128位)。我需要找到4字节输入,这将给我想要的4字节输出。解决这些四分之一可以同时完成,从所需的输出开始,你可以从第N轮走回第N-1轮。这基本上是我对ctf2的计划。
此时,我已经将调度函数提取到我自己的程序中。我清理了代码并确保它产生与程序本身相同的结果(总是有趣的调试)。换句话说,我准备继续分析所有算术轮。
案例0:编码
这个案例非常简单,如下所示:
|
|
因此,反转它是一个简单的XOR操作:
|
|
案例1、5、9、13、17、21、25、29、33、37:SubBytes
这个案例与上一个相比可能看起来更令人生畏(笑)。一旦我清理并美化它,它看起来像这样:
|
|
我总是关注的是:输入和输出字节之间的关系。请记住,每轮作为一个函数,接受16字节blob输入(我的代码中的Slot_t)并返回另一个16字节blob作为输出。由于我们感兴趣的是编写一个可以找到生成特定输出的输入的函数,因此识别输出如何构建以及使用哪些输入字节来构建它非常重要。
让我们仔细看看输出的第一个字节是如何生成的。我们从函数末尾开始,并回溯引用,直到遇到输入状态的字节。在这种情况下,我们回溯v57的来源,然后v55和v56。v55是输入状态的第一个字节,很好。v56是一个编码轮数的数字。我们现在不一定关心它,但意识到轮数是此函数的一个参数是好的;而不仅仅是输入字节。好的,所以我们知道输出的第一个字节是通过输入的第一个字节构建的,容易。比我在查看Hex-Rays输出时最初预期的要简单。但我会接受简单:)。
如果你对每个字节重复上述步骤,你基本上会意识到输出的每个字节依赖于输入的一个字节。它们都彼此独立,这甚至更好。这意味着我们可以非常容易地暴力破解输入值以生成特定的输出值。这很好,因为它…非常便宜计算;如此便宜,我们甚至不麻烦,我们继续下一个案例。
理论上,我们甚至可以并行化以下内容,但可能不值得这样做,因为已经很快。
|
|
有趣的是,如果你修补了挑战二进制文件,这是另一个事情会出错的地方。crc值在函数末尾用于XOR输出状态,并会污染你的结果 here,狡猾:)。
案例2、6、10、14、18、22、26、30、34、38:ShiftRows
不错,我们已经弄清楚了六种案例中的两种。这个案例看起来也不坏,它非常短,编写反转看起来足够容易:
|
|
显然,通过快速查看此函数,你理解它是某种洗牌操作。出于某种原因,这是我不擅长的脑力体操。我通常使用的技巧是给它一个看起来像这样的输入:\x00\x01\x02\x03…并观察结果。
|
|
如果我们应用上述技巧,我们得到:
|
|
从这里,更容易(至少对我而言)弄清楚洗牌的效果。例如,我们已经知道我们不需要对前四个字节做任何事,因为它们没有被洗牌。我们知道我们需要取Output[7]并将其放入Input[4],Output[4]放入Input[5],依此类推。经过一点脑力体操,我最终得到这个例程:
|
|
下一个!
案例3、7、11、15、19、23、27、31、35:MixColumns
这个案例基本上是最烦人的一个。乍一看,它看起来非常类似于我们之前分析的案例1,但…不完全相同。
|
|
这次如果我们仔细看,我们注意到每四个字节的输出依赖于四个字节的输入。并且那四个输出字节的每个字节都依赖于那四个输入字节。
这意味着你不能像之前那样逐字节暴力破解。你必须暴力破解四个字节…这比我们上面看到的要昂贵得多。我们唯一的好处是我们可以并行暴力破解它们,因为它们彼此独立。每个一个线程应该可以完成工作。
在这个阶段,我已经在各种错误或愚蠢的事情上浪费了一堆时间;所以我决定编写这个非常简单的天真暴力破解函数(它既不漂亮也不快…但此时我已经与它和平相处):
|
|