Pwn2Own Miami 2022:利用ICONICS Genesis64中的零点击远程内存破坏漏洞

本文详细描述了作者在Pwn2Own Miami 2022竞赛中利用ICONICS Genesis64软件中的零点击远程内存破坏漏洞的过程,包括目标选择、漏洞研究、利用工程构建及竞赛经历。

在Pwn2Own ICS 2022 Miami竞赛中竞争:利用ICONICS Genesis64中的零点击远程内存破坏漏洞

🧾 引言

在2021年参加Pwn2Own Austin并未能成功利用远程内核漏洞Zenith(可在此处阅读)后,我渴望再次尝试。这很有趣,并迫使我研究那些我原本不会关注的事物。我在2021年最后一次参与时无法亲临现场并沉浸在整个体验中。我想要在舞台上获得巨大的肾上腺素激增(而不是在舒适的家中),与其他人交流、社交并向其他参赛者学习。

因此,当ZDI宣布2022年在迈阿密举行现场比赛时,我非常兴奋,但我对工业控制系统(ICS)软件一无所知(现在仍然如此😅)。在谷歌搜索后,我意识到有几个目标运行在Windows上😮,这是我最熟悉的操作系统,考虑到时间线,这是一个巨大的优势。ZDI最初在2022年10月底宣布了比赛,预计大约三个月后,即2023年1月举行。

在这篇博客文章中,我希望带您了解我参与并在迈阿密舞台上演示获胜的零点击远程入口的旅程🛬。如果您想跳过细节直接查看漏洞利用代码,所有内容都可以在我的GitHub仓库Paracosme中找到。

目录:

  • 🧾 引言
  • ⚙️ 目标选择
  • 🐛 漏洞研究
    • 理解目标
    • 利用目标
    • 筛选发现
  • 🔥 利用工程:构建Paracosme
    • 理解漏洞
    • 能否用受控数据重新分配内存块?
    • 劫持控制流并通过ROP获取任意本地代码执行
  • 🎊 参加比赛
  • ✅ 总结

⚙️ 目标选择

好吧,让我来设定场景。2021年11月在西雅图;太阳早早落下,室内温暖舒适;我决定尝试参加比赛。正如我在介绍中提到的,我有大约三个月的时间来发现一个可利用的漏洞并为其编写足够可靠的利用程序。老实说,考虑到我平均每个工作日只能投入一两个小时(周末可能翻倍),这个时间线有点紧张。因此,进展会很慢,并且需要纪律性在全天工作后投入时间🫡。如果没有任何进展,那就算了。生活中事情往往不会成功,没什么新鲜的🤷🏽‍♂️。

让我兴奋的一件事是选择一个运行在Windows上的目标,以便使用我最喜欢的调试器WinDbg。考虑到时间线,我觉得不需要与gdb和/或lldb斗争是件好事🤢。但正如我上面所说,我对任何与ICS软件相关的东西都没有经验。我不知道它应该做什么,在哪里、如何、何时做。尽管我通过阅读所有能找到的文献尽力记录自己,但我很快意识到信息安全社区对此覆盖不多。

关于比赛,ZDI将其分为四个主要类别,每个类别有多个目标、向量和现金奖励。阅读规则时,我并没有真正认出任何供应商,一切对我来说都非常陌生。因此,我开始寻找符合以下条件的某些东西:

  • 我需要在一个常规的Windows虚拟机中运行软件的演示版本,以便通过调试器轻松检查目标。我从Zenith漏洞利用中吸取了教训,当时我无法在真实目标上调试我的利用程序。这次,我希望能够在真实目标上调试利用程序,以便在比赛中有机会成功。
  • 目标是用内存不安全的语言(如C或C++)编写的。这样更容易进行逆向工程,并且肯定包含我可以利用的内存安全问题。事后看来,这可能不是最好的选择。大多数其他参赛者利用了逻辑漏洞,这些漏洞通常:更可靠地利用(失去现金奖励的机会更少,构建利用程序的时间更少),并且可能更容易找到(更多工具机会?)。
  • 现有的研究/文档/任何我可以在此基础上构建的东西都会非常棒。

经过一两周的尝试,我决定通过0点击网络向量以控制服务器类别中的ICONICS Genesis64为目标。一根以太网电缆将您连接到目标设备,您将利用程序发送到Genesis64的监听套接字之一,并需要在不进行任何用户交互的情况下演示代码执行🔥。

Luigi Auriemma在2011年发布了大量影响GenBroker64.exe服务器(Genesis64的一部分)的漏洞。其中许多漏洞看起来强大且浅显,这让我相信今天仍然存在更多漏洞。同时,这是我找到的唯一公开内容,而且是十年前的事,这是很久以前的事了。

🐛 漏洞研究

我在官方宣布几周后开始了这次冒险,下载了软件的演示版本,安装在虚拟机中,并开始以激光般的专注对GenBroker64.exe服务进行逆向工程。GenBroker64.exe是一个常规的Windows程序,有32位和64位版本,但最终将在现代Windows 10 64位默认配置下运行。事后看来,我犯了一个错误,没有花足够的时间枚举可用的攻击面。相反,我攻击了与Luigi相同的攻击面,而可能有更好/较少探索的候选目标。活到老学到老😔。

我在IDA中打开了文件,起初感到困惑,因为它认为这是一个.NET二进制文件。这与我之前查看的Luigi的发现相矛盾🤔。

我忽略了这一点,并寻找管理端口38080上监听TCP套接字的代码。我找到了那个入口点,它肯定是用C++编写的,所以二进制文件可能只是.NET和C++的混合体🤷🏽‍♂️。无论如何,我没有花时间试图理解原因,而是开始埋头苦干。对其进行逆向工程,逐个函数,逐渐理解各种结构和软件抽象。你知道这是怎么回事。让你的Hex-Rays输出更美观,有十个不同的变量命名为dunno_x等等有趣的事情。

理解目标

经过一个月的日常逆向工程,我进展顺利,感觉更好地理解了端口38080暴露的一阶攻击面。这并不意味着我理解所有事情,但我正在积累专业知识。GenBroker64.exe似乎是在客户端和某些ICS硬件之间代理对话。谁知道呢。我很好地理解了这一层,它接收由更原始类型组成的自定义“消息”:字符串、字符串数组、整数、VARIANT等。这一层看起来正是Luigi在2011年攻击的区域。我可以看到这里和那里添加了额外的检查。我想我走对了路。

我还看到了许多与Microsoft Foundation Class(MFC)库相关的东西,我需要熟悉它们。比如CArchive、ATL::CString等。

我开始看到错误和低严重性的安全问题,比如除以零、空指针解引用、无限递归、越界读取等。虽然这在一分钟内让人感到安慰,但这些问题远非我需要的无需用户交互远程弹出计算器的方法。仍然在正确的轨道上,但没有成功。时间在流逝,我开始想知道模糊测试是否有帮助。反序列化层表面适合进行模糊测试,并且由于积累的专业知识,我可能可以快速利用目标。我不久前发布的wtf模糊测试器似乎是一个很好的候选者,所以我使用了它。当你编写的工具解决你的一个问题时,总有一种特殊的感觉🙏。计划是在我继续手动探索表面的同时快速启动一些模糊测试。

利用目标

GenBroker64.exe接收的自定义消息存储在一个接收缓冲区中,该缓冲区看起来如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct TcpRecvBuffer_t {
  TcpRecvBuffer_t() { memset(this, 0, sizeof(*this)); }
  uint64_t Vtbl;
  uint64_t m_hFile;
  uint64_t m_bCloseOnDelete;
  uint64_t m_strFileName;
  uint32_t m_dFoo;
  uint32_t m_pTM;
  uint64_t m_nGrowBytes;
  uint64_t m_nPosition;
  uint64_t m_nBufferSize;
  uint64_t m_nFileSize;
  uint64_t m_lpBuffer;
};

m_lpBuffer指向从套接字接收的原始字节,因此在内存中注入测试用例应该很简单。我组装了一个客户端,发送一个大的数据包(0x1'000字节长),以确保缓冲区中有足够的存储空间进行模糊测试。我在相关的WSOCK32!recv调用之后对GenBroker64.exe进行了快照,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GenBroker64+0x83dd0:
00000001`40083dd0 83f8ff          cmp     eax,0FFFFFFFFh

kd> ub .
00000001'40083dc0 4053            push    rbx
00000001'40083dc2 4883ec30        sub     rsp,30h
00000001'40083dc6 488b4908        mov     rcx,qword ptr [rcx+8]
00000001`40083dca ff15b8aa0200    call    qword ptr [GenBroker64+0xae888 (00000001`400ae888)]

kd> dqs 00000001`400ae888
00000001`400ae888  00007ffb`f27e1010 WSOCK32!recv

kd> r @rax
rax=0000000000001000

kd> kp
 # Child-SP          RetAddr               Call Site
00 00000000`0a48fb10 00000001`4008a9fc     GenBroker64+0x83dd0
01 00000000`0a48fb50 00000001`40086783     GenBroker64+0x8a9fc
02 00000000`0a48fdf0 00000001`4008609d     GenBroker64+0x86783
03 00000000`0a48fe20 00007ffc`0cd07bd4     GenBroker64+0x8609d
04 00000000`0a48ff30 00007ffc`0db0ce71     KERNEL32!BaseThreadInitThunk+0x14
05 00000000`0a48ff60 00000000`00000000     ntdll!RtlUserThreadStart+0x21

然后,我编写了一个简单的模糊测试模块,将测试用例写入接收缓冲区的末尾,以确保越界内存访问在访问其后的保护页时触发访问冲突。我还更新了recv接收的字节数以及起始地址(m_lpBuffer)。TcpRecvBuffer_t结构存储在栈上。模块如下所示:

 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
bool InsertTestcase(const uint8_t *Buffer, const size_t BufferSize) {
  const uint64_t MaxBufferSize = 0x1'000;
  if (BufferSize > MaxBufferSize) {
    return true;
  }

  struct TcpRecvBuffer_t {
    TcpRecvBuffer_t() { memset(this, 0, sizeof(*this)); }
    uint64_t Vtbl;
    uint64_t m_hFile;
    uint64_t m_bCloseOnDelete;
    uint64_t m_strFileName;
    uint32_t m_dFoo;
    uint32_t m_pTM;
    uint64_t m_nGrowBytes;
    uint64_t m_nPosition;
    uint64_t m_nBufferSize;
    uint64_t m_nFileSize;
    uint64_t m_lpBuffer;
  };

  static_assert(offsetof(TcpRecvBuffer_t, m_lpBuffer) == 0x48);

  //
  // Calculate and read the TcpRecvBuffer_t pointer saved on the stack.
  //

  const Gva_t Rsp = Gva_t(g_Backend->GetReg(Registers_t::Rsp));
  const Gva_t TcpRecvBufferAddr = g_Backend->VirtReadGva(Rsp + Gva_t(0x30));

  //
  // Read the TcpRecvBuffer_t structure.
  //

  TcpRecvBuffer_t TcpRecvBuffer;
  if (!g_Backend->VirtReadStruct(TcpRecvBufferAddr, &TcpRecvBuffer)) {
    fmt::print("VirtWriteDirty failed to write testcase at {}\n",
               fmt::ptr(Buffer));
    return false;
  }

  //
  // Calculate the testcase address so that it is pushed towards the end of the
  // page to benefit from the guard page.
  //

  const Gva_t BufferEnd = Gva_t(TcpRecvBuffer.m_lpBuffer + MaxBufferSize);
  const Gva_t TestcaseAddr = BufferEnd - Gva_t(BufferSize);

  //
  // Insert testcase in memory.
  //

  if (!g_Backend->VirtWriteDirty(TestcaseAddr, Buffer, BufferSize)) {
    fmt::print("VirtWriteDirty failed to write testcase at {}\n",
               fmt::ptr(Buffer));
    return false;
  }

  //
  // Set the size of the testcase.
  //

  g_Backend->SetReg(Registers_t::Rax, BufferSize);

  //
  // Update the buffer address.
  //

  TcpRecvBuffer.m_lpBuffer = TestcaseAddr.U64();
  if (!g_Backend->VirtWriteStructDirty(TcpRecvBufferAddr, &TcpRecvBuffer)) {
    fmt::print("VirtWriteDirty failed to update the TcpRecvBuffer.m_lpBuffer "
               "pointer\n");
    return false;
  }

  return true;
}

当使用wtf利用目标时,有许多事件或API调用无法在运行时环境中正确执行。I/O和上下文切换是一些例子,但还有更多。知道如何处理这些事件通常完全取决于目标特定。这可能像nop掉一个调用一样简单,也可能像模拟复杂API的效果一样棘手。这是一个棘手的平衡行为,因为你希望避免强制你的目标行为与真实执行时不同。否则,你可能会遇到只存在于你构建的现实中的错误👾。

幸运的是,GenBroker64.exe并不太糟糕;我nop掉了一些导致I/O的函数,但它们没有影响我正在模糊测试的代码:

 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
bool Init(const Options_t &Opts, const CpuState_t &) {
  //
  // Make ExGenRandom deterministic.
  //
  // kd> ub fffff805`3b8287c4 l1
  // nt!ExGenRandom+0xe0:
  // fffff805`3b8287c0 480fc7f2        rdrand  rdx
  const Gva_t ExGenRandom = Gva_t(g_Dbg.GetSymbol("nt!ExGenRandom") + 0xe4);
  if (!g_Backend->SetBreakpoint(ExGenRandom, [](Backend_t *Backend) {
        DebugPrint("Hit ExGenRandom!\n");
        Backend->Rdx(Backend->Rdrand());
      })) {
    return false;
  }

  const uint64_t GenBroker64Base = g_Dbg.GetModuleBase("GenBroker64");
  const Gva_t EndFunct = Gva_t(GenBroker64Base + 0x85FCC);
  if (!g_Backend->SetBreakpoint(EndFunct, [](Backend_t *Backend) {
        DebugPrint("Finished!\n");
        Backend->Stop(Ok_t());
      })) {
    return false;
  }

  if (!g_Backend->SetBreakpoint(
          "combase!CoCreateInstance", [](Backend_t *Backend) {
            DebugPrint("combase!CoCreateInstance({:#x})\n",
                       Backend->VirtRead8(Gva_t(Backend->Rcx())));
            g_Backend->Stop(Ok_t());
          })) {
    return false;
  }

  const Gva_t DnsCacheIsKnownDns(0x1400794F0);
  if (!g_Backend->SetBreakpoint(DnsCacheIsKnownDns, [](Backend_t *Backend) {
        DebugPrint("DnsCacheIsKnownDns\n");
        g_Backend->SimulateReturnFromFunction(0);
      })) {
    return false;
  }

  const Gva_t CMemFileGrowFile(0x14009653B);
  if (!g_Backend->SetBreakpoint(CMemFileGrowFile, [](Backend_t *Backend) {
        DebugPrint("CMemFile::GrowFile\n");
        g_Backend->Stop(Ok_t());
      })) {
    return false;
  }

  if (!g_Backend->SetBreakpoint("KERNELBASE!Sleep", [](Backend_t *Backend) {
        DebugPrint("KERNELBASE!Sleep\n");
        g_Backend->Stop(Ok_t());
      })) {
    return false;
  }


                                [](Backend_t *Backend) {

                                  g_Backend->Stop(Ok_t());
                                })) {
    return false;
  }

  //
  // Install the usermode crash detection hooks.
  //

  if (!SetupUsermodeCrashDetectionHooks()) {
    return false;
  }

  return true;
}

我手动制作了几个数据包作为语料库,在笔记本电脑上运行,然后上床睡觉,结束了一天😴。第二天醒来,我迎来了一些发现。令人兴奋。就像圣诞节早晨早早醒来,希望能在树下找到礼物🎄。不过,看完它们后,现实很快回来了。我意识到所有的发现都是我前面提到的一些低严重性问题。哦,好吧,不管怎样;有时候就是这样。我稍微改进了语料库,让模糊测试器深入代码。

随着截止日期的临近,压力越来越大。我感觉我的进展停滞了,这感觉不好。我多次进行逆向工程,知道我需要休息一下来充电。对我来说,最好的方法是完成一些简单且可衡量的事情,以获得多巴胺的供应。我决定回到我一直 unsupervised 运行的模糊测试器。

筛选发现

wtf不知道如何处理I/O,并在上下文切换时停止以防止执行来自不同进程的代码。这些行为结合意味着模糊测试器经常遇到导致上下文切换的情况。通常,这是利用不良的症状,因为你的测试用例的执行在可能应该之前被中断。

我有很多这样的测试用例,所以仔细查看它们既有回报,也是改进模糊测试活动的好方法。通常,这非常耗时,因为它突出了你不太了解的代码区域,你需要回答“如何正确处理它”的问题。不幸的是,在wtf中“调试”测试用例是基本的;你有一个跨越用户和内核模式的执行跟踪。它通常有千兆字节长,所以你 literally 是在滚动寻找大海捞针🔎

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计