前言
由于Jonathan Afek的出色工作,我们现在可以使用QEMU来引导iOS映像。项目的当前状态允许执行任意二进制文件,比如bash(I / O通过模拟串行端口进行)。
尽管通过串行外壳控制OS对于PoC非常有用,但我们需要一个更可靠的解决方案。控制远程系统时首先想到的是SSH:它允许连接多路复用,文件传输等等。但是,要获得所有这些好处,我们必须与iOS建立TCP连接。并且由于我们的QEMU系统无法模拟网卡,建立TCP连接就确实是一项艰巨的任务。
这篇文章将描述我们开发的解决方案,以实现与模拟iOS系统的通信。
复杂的选择
在QEMU下运行常规Linux操作系统时,可以将virt计算机与仿真的网卡一起使用。不幸的是,使用iOS,将仿真的网卡代码复制到我们的n66机器中并不是一件简单的事情:iOS没有使用该网卡的适当驱动程序,更不用说需要仿真的其他硬件了,例如IO总线。
因此,为了向我们的iOS仿真添加适当的网络接口,我们有2个选择:
1. 从virt中获取I / O总线和网卡代码,并为iOS开发相关的驱动程序,该驱动程序将与此仿真硬件进行通讯。这种方法的主要难点是缺乏iOS驱动程序开发工具。
2. 利用iOS中的现有驱动程序,开发将要与驱动程序通信的仿真硬件。这需要针对iOS中的IO和网络驱动程序进行彻底的逆向工程。由于缺少符号,并且考虑到必须模拟真实硬件的大量功能,这种方法的成本很可能过高。
我们想要一种具有较少潜在并发症的解决方案,并从虚拟化领域中汲取到灵感。
QEMU客户服务
虚拟化101
在虚拟化软件(如VMWare Workstation,VirtualBox等)中,通常会有一个可安装在访客OS中的软件套件(提供程序之间的名称有所不同:VMWare称其为VMWare工具,VirtualBox称其为Guest Additions)。 该软件的目的是通过提供与主机的直接通信通道来丰富访客OS的功能。增强功能包括剪贴板共享,拖放文件复制等功能。实现上述功能的是特殊的虚拟化操作码,该操作码用于从访客到主机的直接通信。操作码因架构而异,有时也因制造商而异:Intel使用 vmcall,AMD使用vmmcall,ARM使用hvc。
QEMU呼叫操作码
由于执行iOS映像的QEMU系统类似于虚拟机管理程序,我们便选择采用类似的方法,并定义可被访客(iOS)用来调用主机(QEMU)的操作码,以获取任意功能(我们称之为QEMU Call)。我们希望将对QEMU核心代码的更改最小化,因此,我们不希望引入新的操作码。覆盖hvc的功能也是我们要避免的选项。但是,QEMU支持以用户自定义的实现方式来将系统寄存器定制化。这非常适合我们,并提供了一个绝佳的位置来引入在访客(iOS)需要来自主机QEMU的服务时执行的回调:
static const ARMCPRegInfo n66_cp_reginfo[] = { // Apple-specific registers N66_CPREG_DEF(ARM64_REG_HID11, 3, 0, 15, 13, 0, PL1_RW), N66_CPREG_DEF(ARM64_REG_HID3, 3, 0, 15, 3, 0, PL1_RW), N66_CPREG_DEF(ARM64_REG_HID5, 3, 0, 15, 5, 0, PL1_RW), N66_CPREG_DEF(ARM64_REG_HID4, 3, 0, 15, 4, 0, PL1_RW), N66_CPREG_DEF(ARM64_REG_HID8, 3, 0, 15, 8, 0, PL1_RW), N66_CPREG_DEF(ARM64_REG_HID7, 3, 0, 15, 7, 0, PL1_RW), N66_CPREG_DEF(ARM64_REG_LSU_ERR_STS, 3, 3, 15, 0, 0, PL1_RW), N66_CPREG_DEF(PMC0, 3, 2, 15, 0, 0, PL1_RW), N66_CPREG_DEF(PMC1, 3, 2, 15, 1, 0, PL1_RW), N66_CPREG_DEF(PMCR1, 3, 1, 15, 1, 0, PL1_RW), N66_CPREG_DEF(PMSR, 3, 1, 15, 13, 0, PL1_RW), // Aleph-specific registers for communicating with QEMU // REG_QEMU_CALL: { .cp = CP_REG_ARM64_SYSREG_CP, .name = "REG_QEMU_CALL", .opc0 = 3, .opc1 = 3, .crn = 15, .crm = 15, .opc2 = 0, .access = PL0_RW, .type = ARM_CP_IO, .state = ARM_CP_STATE_AA64, .readfn = qemu_call_status, .writefn = qemu_call }, REGINFO_SENTINEL, };
在以上代码段中,有一个名为 REG_QEMU_CALL的新的系统寄存器定义。与之前定义的Apple特定寄存器类似,我们将新的自定义寄存器定义为QEMU ARMCPRegInfo结构的实例。
字段opc0,opc1,crn,crm,和opc2用于识别系统寄存器,并且把它与其余的进行区分。这些字段被用来构造用于访问寄存器的操作码(mrs/ msr)。这些字段的选择受到一些限制,但是主要的优先事项是选择一个唯一的组合,该组合不会与现有的系统寄存器冲突。
该access字段根据当前PL(特权级别)指示QEMU有关对寄存器的访问限制。我们的寄存器将设置access为 PL0_RW,使其在PL0/ EL0或以上级别中可读写。
最后,字段readfn和writefn分别用来定义在读取和写入寄存器时执行的回调。虽然将来读取寄存器可能会有用,但我们现在仅需要写访问权限。因此,readfn是一个存根:
uint64_t qemu_call_status(CPUARMState *env, const ARMCPRegInfo *ri) { // NOT USED FOR NOW return 0; }
QEMU调用API
当用户空间应用程序需要执行由操作系统实现的操作(例如,文件系统访问、网络访问等)时,它会进行系统调用。这与访客操作系统执行对虚拟机管理程序的调用的方式非常相似(实际上,虚拟机管理程序的调用功能就是参照着系统调用功能而经过精心设计的)。系统调用和系统管理程序调用通常都通过第一个寄存器中传递的数字(即系统调用号或系统调用号)来标识所需的功能。附加参数通常在其他寄存器中或在寄存器所指向的内存中传递。
我们决定在进行QEMU通话时遵循类似的约定,但有一点点改动。为了最大程度地减少对内联汇编的需求(这通常是进行系统和虚拟机管理程序调用所必需的,因为参数必须存储在特定的寄存器中),我们选择将所有参数(包括QEMU调用的数量)存储在内存中。我们定义了以下结构以简化数据处理:
typedef struct __attribute__((packed)) { // Request qemu_call_number_t call_number; union { // File Descriptors API qc_close_args_t close; qc_fcntl_args_t fcntl; // Socket API qc_socket_args_t socket; qc_accept_args_t accept; qc_bind_args_t bind; qc_connect_args_t connect; qc_listen_args_t listen; qc_recv_args_t recv; qc_send_args_t send; } args; // Response int64_t retval; int64_t error; } qemu_call_t;
该结构以该call_number字段开头,该字段标识所请求的功能(qemu_call_number_t是一个枚举)。QEMU调用的参数放在这后面。由于每个呼叫号码都带有一组对应的参数,因此它们出现在联合中:不会出现两种或多种类型的参数需要同时访问的情况。结构的其余部分包含retval和error字段,用于向调用者发送QEMU调用结果的信号。
但是QEMU Call的处理程序如何知道在哪里查找该数据?由于我们通过对的(写入)访问实现QEMU调用REG_QEMU_CALL,因此我们可以简单地将QEMU调用数据的地址用作写入的值。这样,writefn执行回调时,我们只需从写入的地址中读取数据:
void qemu_call(CPUARMState *env, const ARMCPRegInfo *ri, uint64_t value) { CPUState *cpu = qemu_get_cpu(0); qemu_call_t qcall; // Read the request cpu_memory_rw_debug(cpu, value, (uint8_t*) &qcall, sizeof(qcall), 0);
至此,我们在qcall中有了用于QEMU调用的数据,并且可以通过解析它来选择正确的功能:
switch (qcall.call_number) { // File Descriptors case QC_CLOSE: qcall.retval = qc_handle_close(cpu, qcall.args.close.fd); break; // ... more cases ... default: // TODO: handle unknown call numbers break; }
处理完调用后,我们填充qcall的retval和error字段 ,并完成回调。控件返回给访客,访客在对REG_QEMU_CALL进行写入访问之后,从操作码恢复执行 。此时,由于已经提供了所需的功能,并且状态已经在内存中,因此访客可以简单地读取返回的数据并采取相应的措施。
实施系统API
为了让使用QEMU调用进行编程尽可能简单,我们需要避免使用复杂的功能。因此,我们选择将每个调用与POSIX系统调用进行匹配。通过采用这种方法,我们获得了两个重要的好处:
1. 在QEMU方面,每个调用的实现主要归结为进行适当的系统调用,并且保证了一些状态数据是存储在本地的。
2. 在访客端,它甚至更简单:对于每个已实现的QEMU调用,我们都有一个与签名中的基础系统调用匹配的包装器。它只是填充一个qemu_call_t结构,执行QEMU调用操作码,并从结果中读取返回值和/或错误。
以下是几个示例。
套接字API(QEMU端)
我们实现每个系统调用时(socket,accept,bind,connect,listen, recv,和send)有一个匹配的处理器功能,在这个基础上,从请求解析QEMU的调用号码执行。参数(从请求中获取)与基础系统调用API匹配,并添加了cpu参数(用于访问访客内存)。这些是处理程序声明:
int32_t qc_handle_socket(CPUState *cpu, int32_t domain, int32_t type, int32_t protocol); int32_t qc_handle_accept(CPUState *cpu, int32_t sckt, struct sockaddr *addr, socklen_t *addrlen); int32_t qc_handle_bind(CPUState *cpu, int32_t sckt, struct sockaddr *addr, socklen_t addrlen); int32_t qc_handle_connect(CPUState *cpu, int32_t sckt, struct sockaddr *addr, socklen_t addrlen); int32_t qc_handle_listen(CPUState *cpu, int32_t sckt, int32_t backlog); int32_t qc_handle_recv(CPUState *cpu, int32_t sckt, void *buffer, size_t length, int32_t flags); int32_t qc_handle_send(CPUState *cpu, int32_t sckt, void *buffer, size_t length, int32_t flags);
以下是socket处理程序的代码:
int32_t qc_handle_socket(CPUState *cpu, int32_t domain, int32_t type, int32_t protocol) { int retval = find_free_socket(); if (retval < 0) { guest_svcs_errno = ENOTSOCK; } else if ((guest_svcs_fds[retval] = socket(domain, type, protocol)) < 0) { retval = -1; guest_svcs_errno = errno; } return retval; }
实现非常简单:
1. find_free_socket在表示QEMU文件描述符的局部整数数组中查找未占用(即包含 -1)的单元格。
2. 如果未找到未占用的单元格,ENOMEM则将其设置为错误,然后函数完成。
3. 否则,socket将使用传递的参数进行调用。
4. 如果调用成功,则将结果(分配的文件描述符)存储在文件描述符数组中(在步骤1中找到的位置)。
5. 如果调用失败,则设置匹配的错误号。
另一个示例,send处理程序的代码:
int32_t qc_handle_send(CPUState *cpu, int32_t sckt, void *g_buffer, size_t length, int32_t flags) { VERIFY_FD(sckt); uint8_t buffer[MAX_BUF_SIZE]; int retval = -1; if (length > MAX_BUF_SIZE) { guest_svcs_errno = ENOMEM; } else { cpu_memory_rw_debug(cpu, (target_ulong) g_buffer, buffer, length, 0); if ((retval = send(guest_svcs_fds[sckt], buffer, length, flags)) < 0) { guest_svcs_errno = errno; } } return retval; }
此实现非常简单,并且socket在结构上类似:
1. VERIFY_FD确保传递的套接字号(实际上是文件描述符)有效。该数字实际上是文件描述符数组的索引,VERIFY_FD只需确保数组中匹配的单元格有效(即未设置为-1)。
2. 为简单起见,用于静态传输访客数据的缓冲区是静态分配的,我们只需确保发送的数据长度不超过该限制即可。
3. 将要发送的数据从访客处复制到本地分配的缓冲区中。
4. 使用传递的参数(和指向本地缓冲区的指针)对send进行调用。它的返回值用作QEMU调用的返回值,并且在发生错误的情况下,也会设置错误号。
套接字API(访客端)
以下是执行实际QEMU调用(即,写入REG_QEMU_CALL系统寄存器)的函数:
void qemu_call(qemu_call_t *qcall) { asm volatile ("mov x0, %[addr]"::[addr] "r" (qcall)); asm volatile (".byte 0x00"); asm volatile (".byte 0xff"); asm volatile (".byte 0x1b"); asm volatile (".byte 0xd5"); }
我们只需要将分配的qemu_call_t结构的地址放在x0中,然后移动x0到REG_QEMU_CALL(该指令无法以标准汇编形式编写,因此必须手动编码为4个字节)。
虽然QEMU调用的头文件在QEMU和访客开发之间部分共享(以使诸如qemu_call_t的结构保持同步),但实现的方法却不同。因此,除了QEMU调用的处理程序之外,还有一些与名称中的底层系统调用匹配的API:
int qc_socket(int domain, int type, int protocol); int qc_accept(int sckt, struct sockaddr *addr, socklen_t *addrlen); int qc_bind(int sckt, const struct sockaddr *addr, socklen_t addrlen); int qc_connect(int sckt, const struct sockaddr *addr, socklen_t addrlen); int qc_listen(int sckt, int backlog); ssize_t qc_recv(int sckt, void *buffer, size_t length, int flags); ssize_t qc_send(int sckt, const void *buffer, size_t length, int flags);
以上所有功能均实现为以下功能的包装,该功能仅对qemu_call传递的数据执行,设置错误号并返回返回值:
static int qemu_sckt_call(qemu_call_t *qcall) { qemu_call(qcall); guest_svcs_errno = qcall->error; return (int) qcall->retval; }
例如,以下是socket和send的实施方式:
int qc_socket(int domain, int type, int protocol) { qemu_call_t qcall = { .call_number = QC_SOCKET, .args.socket.domain = domain, .args.socket.type = type, .args.socket.protocol = protocol, }; return qemu_sckt_call(&qcall); }
ssize_t qc_send(int sckt, const void *buffer, size_t length, int flags) { qemu_call_t qcall = { .call_number = QC_SEND, .args.send.socket = sckt, .args.send.buffer = (void *) buffer, .args.send.length = length, .args.send.flags = flags, }; return qemu_sckt_call(&qcall); }
TCP隧道
提供上述套接字API(以及用于管理文件描述符的几个附加功能,即close和 fcntl)后,创建TCP隧道变得相当简单。以下提供一个算法,可以用于在QEMU主机上侦听隧道并将所有连接转发到访客的端口:
1. 在主机上创建一个TCP套接字,将其绑定到端口,然后侦听该套接字上的传入连接。通过QEMU调用来对应socket、bind、listen系统调用。这些调用的结果是主机上的套接字,正在侦听所需的端口。此外,使用fcntl QEMU调用将套接字标记为非阻塞。
2. 此时,我们进入无限循环,并继续通过accept QEMU调用轮询套接字 。建立连接(在主机端)后,对accept的调用将返回一个新的套接字,该套接字标识该连接。
3. 我们把到访客上目标端口的新套接字连接实例化(出于主要目的,这将是到iOS上SSH服务器的连接)。这是通过普通的connect系统调用(不是 QEMU调用)完成的。
4. 我们进入另一个无限循环,该循环通过recv依次调用每个套接字来轮询两个套接字(一个套接字连接到访客端口,另一个套接字连接到主机)。每当从其中一个套接字接收到数据时,数据也会被发送到另一个套接字。访问访客套接字时,send和recv是普通系统调用,而访问主机套接字时,使用QEMU调用。
5. 最后,当其中一个连接关闭时,我们也将关闭另一个连接(通过close system / QEMU调用),然后返回到 accept其他连接。
上面的算法是基本的,并且一次支持一个连接。可以通过在每个accept上分叉来增强,以允许多个同时连接。
当然,也可以将访客的连接转发到外界。使用常规系统调用实例化侦听套接字(来自步骤1),以及使用QEMU调用实例化第二个连接套接字(来自步骤3)来完成此操作。
SSH服务器
通过将任意TCP端口从主机转发到iOS的功能,我们可以在iOS上运行SSH服务器,并将其用作控制系统的外壳。这使我们可以同时打开多个外壳连接(与通过串行方式连接的单个外壳相比),并且可以在系统在线时轻松地传输文件。
幸运的是,我们先前已经把iosbinpack复制到我们的iOS映像中,其中包括一个名为Dropbear的基本SSH服务器。通过运行它,并将主机上的端口转发到iOS中的端口22,我们可以使用常规的SSH客户端并远程连接到我们的iOS系统:
$ /iosbinpack64/usr/local/bin/dropbear --shell /iosbinpack64/bin/bash -R -E $ /bin/tunnel 2222:127.0.0.1:22
请注意,dropbear要求对文件系统具有写访问权,以便生成其SSH密钥(或者,密钥可以在主机上离线生成,然后复制到映像上,并且dropbear可以定向使用这些生成的密钥)。因此,我们建议您使用更新的说明(包括通过写访问权安装的磁盘映像)。
未来的工作
尽管通过SSH实现了基本的网络连接,但我们的解决方案仍然存在一些缺陷,需要解决,并且需要开发一些缺少的功能:
1. 性能:目前,我们的iOS系统是非常基础的,并且不支持中断。因此,我们的TCP隧道不能只在空闲时等待数据包从主机到达,而是必须主动轮询主机以获取新数据。当然,为了避免严重浪费CPU周期,我们可以在两次轮询之间睡眠,但是在数据延迟和CPU使用率高之间要进行权衡。一旦实现了中断,就可以在等待主机数据时空闲。这将大大降低隧道的CPU使用率,而不会影响性能。
2. 当前,隧道一次仅支持一个连接。尽管SSH通过端口转发可以处理多路复用的其他连接,但增加对多个同时连接的支持可以使我们的隧道更加健壮、更加通用,并使其能够在不依赖SSH的情况下运行。
3. 使用SSH服务器控制iOS很棒,但是正如本文开头提到的那样,最终目标是建立VPN连接,这将提供对iOS的全功能网络访问,从而减轻了为每个连接设置TCP隧道的需求。很有可能,这还将提供对其他协议(如UDP)的支持。
本文翻译自:https://alephsecurity.com/2020/03/29/xnu-qemu-tcp-tunnel/如若转载,请注明原文地址: