本文原文出自Quarkslab作者Carlo Ramponi,本公众号仅对文章进行翻译并搬运,仅用于技术学习与交流,可点击文后的原文链接查看原文章出处,原文地址:https://blog.quarkslab.com/starlink.html

这篇博文概述了 Starlink 的用户终端运行时内部结构,重点关注设备内发生的通信以及与用户应用程序和一些可以帮助进一步研究同一主题的工具。

介绍

Starlink 是 Space X 提供的基于卫星的互联网接入服务。该服务使用相同的基础设施,在全球拥有超过150 万用户。Starlink 依赖 3 个组件:

  • 与卫星通信的用户终端,是当前大多数研究的重点

  • 充当网状网络的卫星群

  • 将卫星连接到互联网的网关

已经针对该主题进行了大量研究[1,2,3 ] ,主要是在用户终端上。作为特伦托大学硕士学位课程的一部分,我在Quarkslab进行了为期 6 个月的实习,期间,我通过对其固件及其使用的各种协议进行逆向工程来对 Starlink 进行分析。实习结束时,我充分了解了该设备的内部工作原理,并开发了一套可以帮助其他研究人员研究同一主题的工具。这些工具将随本博客文章一起描述和发布。

为了进行这项研究,我们分析了两个常规用户终端版本 2(圆形一)和一个具有 root 访问权限(研究人员访问权限)的用户终端版本 3(方形),这是在我实习结束时由 SpaceX 安全团队提供的。

固件概述

第一步是转储设备的固件,因为它不是公开可用的,这要归功于鲁汶大学COSIC研究小组的博客文章。获得固件后,我们开始检查内容,试图了解内存的结构。我们还开始研究由 SpaceX 定制的 U-Boot 版本,它被用作用户终端的最终引导加载程序阶段 (BL33)。U-Boot 许可证要求对其代码进行任何修改才能使用相同的许可证发布,因此您可以在GitHub上找到它。

从文件中include/configs/spacex_catson_boot.h我们可以看到内存是如何分区的,这里是其中的一部分:

+-----------------+ 0x0000_0000

| bootfip0 (1 MB) |

+-----------------+ 0x0010_0000

[...]

+-----------------+ 0x0060_0000

| fip a.0 (1 MB) |

+-----------------+ 0x0070_0000

[...]

+-----------------+ 0x0100_0000

| linux a (32 MB) |

+-----------------+ 0x0300_0000

| linux b (32 MB) |

+-----------------+ 0x0500_0000

[...]

这使我们能够将图像分成小部分并分别分析每个部分。 该脚本可以帮助您自动完成此操作。从这里,我们还可以看到几乎每个分区都出现多次(例如linux a/b)。这是因为软件更新过程会覆盖当前未使用的分区,因此在出现错误的情况下,仍然会存在已知为“正确”的原始分区。主要分区的概述如下图所示。

分区Boot FIPFIP包含构成安全启动链的所有引导加载程序阶段,其中大多数基于ARM TF-A 项目,该项目不附带类似 GNU 的许可证,最后一个(BL33)是 U-Boot我们上面提到的引导加载程序。

清楚地了解启动过程对于执行COSIC 研究小组开发的故障注入攻击至关重要。启动链遵循 ARM TF-A 实现的经典多阶段安全启动,在下图中您可以看到它的概述。

引导阶段来自 eMMC 的不同分区,第一个代表信任根,来自主处理器的内部 ROM,可以使用来自 ARM TF-A 的fiptool从分区映像中提取它们。在启动过程结束时,BL31 将驻留在异常级别 3 (EL3) 并充当安全监视器,而 Linux 内核将在异常级别 1 (EL1) 下运行,在正常情况下在异常中运行用户态应用程序0 级 (EL0)。

然后,linux顾名思义,该分区包含 Linux 内核、其 ramdisk 映像和一些扁平设备树,每个设备树对应用户终端的每个硬件版本。该分区可以使用U-Boot 项目中的dumpimage进行解压,ramdisk 是一个cpio映像,FDT 以设备树 Blob (DTB) 的形式出现,可以使用设备“反编译”为设备树源 (DTS) 文本树编译器。该分区还包含一些纠错码(ECC)信息,您需要将其删除才能解压。ECC 机制是 SpaceX 定制的,您可以通过查看 U-Boot 中处理此验证的代码来了解它的工作原理,下一节解释了它的工作原理并提供了一个工具来实现这一点。

(SX 运行时)分区sx包含特定于用户终端的配置文件和二进制文件。该分区将由 Linux 的 init 脚本挂载/sx/local/runtime,之后该卷中的二进制文件将启动。在本例中,完整性验证是sxverity通过 SpaceX 的另一个定制工具完成的。下一节将解释其工作原理。

其他分区包括一些加密的分区,使用Linux统一密钥系统(LUKS),这是唯一具有写权限的分区,以及其他一些不值得一提的较小分区。

数据的完整性

正如我们从对 eMMC 转储内容的简要分析中看到的那样,SpaceX 正在使用一些定制的数据完整性机制,以及 ARM TF-A 和 U-Boot 中已包含的标准机制。以下是定制组件的概述。

ECC

纠错码机制只用在FIT镜像中,只提供数据完整性,不考虑真实性。这意味着,从理论上讲,如果您使用自己的过程实现ecc(或使用 ramdisk 中的二进制文件)提供格式正确的数据,则可以篡改该盘的某些受 ECC 保护的组件。但对于 FIT 映像,这是不可能的,因为最后一个引导加载程序阶段也会检查真实性。所以这只是用来防止eMMC存储出现错误。

它的工作原理与其最初的祖先 ECC RAM 类似,后者在内存的实际内容(最初是汉明码)之间嵌入了一些附加数据,这些数据是根据它们“保护”的数据进行计算的。然后,当访问某些数据时,会重新计算汉明码,如果它们与内存中保存的汉明码不对应,则会发生错误,并且根据错误翻转了多少位,可以纠正错误。此版本的 ECC 使用 Reed-Solomon 纠错(而不是汉明码)和最终哈希(即 MD5)来检查正在解码的整个文件的完整性。

在这里你可以找到一个简单的Python脚本,它从文件中删除ECC信息,而不检查数据的正确性,我们过去可以用它来解压FIT图像。在 ramdisk 内部,有一个二进制文件 ( unecc),它执行相同的操作,同时检查并尝试纠正可能的错误。

ECC保护文件的内容被组织成不同类型的块,上图显示了每个块的结构。该文件以标头块 (a) 开头,其中包含幻数和协议版本,以及一些数据和相应的控制代码。然后,可以有零个或多个仅包含数据和控制代码的数据块(b)。最后一个数据块 (c) 由其块类型字段($,而不是*)识别,标记有效负载的结尾,如果需要,可以在此处添加一些填充。最后,页脚块 (d) 包含有效负载的大小(需要知道填充字节的数量)、整个有效负载的 MD5 校验和,当然还有页脚块本身的 ECC 码字。

sxverity

sxverity是 device-mapper-verity (dm-verity) 内核功能的自定义包装器,它提供块设备的透明完整性检查。该工具的源代码不公开,因此我们必须逆向编译的二进制文件才能了解其内部结构。通过验证设备全部内容的签名检查,可以提供数据完整性和真实性。 sxverity内部使用 dm-verity 内核功能,通过/dev/mapper/control设备直接与其交互。

SpaceX 只解决了根哈希的验证问题,底层由内核处理的所有内容都没有被重新实现,关于其工作原理的一个很好的解释可以在这里找到。

正如我们在前面几节中所看到的,sxverity用于验证驻留在持久内存中的某些分区,这是为了防止持久漏洞利用。但正如我们将在下一节中看到的,它也用于验证该盘的软件更新。因此,它是设备整体安全的关键组件。

在上图中您可以看到sxverity图像的结构。它由重复 4 次的标头组成,可能使用不同的公钥进行签名,其中包含:

  • 神奇的字节"sxverity"

  • 版本和一些标志指示哪些算法已用于签名和散列

  • 根哈希,间接覆盖整个有效负载(通过哈希树)

  • 用于签署图像的公钥

  • 上述所有字段的签名,使用椭圆曲线 (ED25519)

该过程执行的解析和验证过程将在模糊测试部分进行描述。

运行时概述

在本节中,我们将讨论引导加载程序链之后发生的事情,从 Linux 的初始化脚本开始到处理用户终端运行时的进程。

init脚本是内核启动的第一个进程,通常其进程标识符(PID)等于1。它的主要任务是启动系统可用所需的所有其他运行时进程,并保持运行直到系统关闭向下。它将是任何其他进程的“最古老”祖先,并且一旦系统启动并运行,用户也可以使用它来启动、停止和配置守护进程。在最终用户 Linux 发行版中可以找到的最常见的 init 脚本是systemd,它是管理系统整个运行时的工具集合(例如systemctl),其中也有init脚本。

SpaceX 的人喜欢实现自己的软件,因此他们实现了自己的 init 脚本,可以在 ramdisk 中找到该脚本,网址为/usr/sbin/sxruntime_start。这使用自定义格式的配置文件,其中包含要启动哪些进程、如何启动它们以及启动顺序的说明。

#######################################

# user terminal frontend

#

# Wait until dish config partition is set up before launching.

#######################################

proc user_terminal_frontend

apparmor user_terminal_frontend

start_if$(cat /proc/device-tree/model) !="spacex_satellite_starlink_transceiver"

startup_flags wait_on_barrier

user sx_packet

custom_param --log_wrapped

上面的代码片段显示了一个被调用的进程user_terminal_frontend是如何启动的:

  • $(cat /proc/device-tree/model) != "spacex_satellite_starlink_transceiver"仅当条件满足时才启动

  • 它在最后一个标有该标志的进程退出后启动barrier

  • 它以 Linux 用户身份执行sx_packet

  • 命令行参数--log_wrapped传递给它

初始化脚本解析的两个配置文件可以在/etc/runtime_init(ramdisk)和/sx/local/runtime/dat/common/runtime(runtime image)中找到。第一个处理系统级服务和配置,例如安装分区(例如运行时)以及设置网络、控制台和记录器服务。相反,第二个处理更高级的进程和配置,例如启动运行时映像中包含的所有进程并初始化加密设备。

此外,init 脚本还通过遵循另一个配置文件中列出的一些规则,为这些进程分配优先级和特定的 CPU 内核,该文件可以在/sx/local/runtime/dat/common/runtime_priorities.

文件系统和挂载点

首先,根文件系统由最后一个引导加载程序(U-Boot、BL33)复制到 RAM 中,并挂载在/. 系统可以通过文件来访问 eMMC 的分区,/dev/blk/mmcblk0pN其中mmcblk0是 eMMC 的名称,N是分区索引,从 1 开始。为了方便起见,脚本将创建一些指向这些分区的符号链接以使用更明确的名称, 如下所示。

# [...]

ln -s /dev/mmcblk0p1 /dev/blk/bootfip0

ln -s /dev/mmcblk0p2 /dev/blk/bootfip1

ln -s /dev/mmcblk0p3 /dev/blk/bootfip2

ln -s /dev/mmcblk0p4 /dev/blk/bootfip3

# [...]

由于几乎每个分区都是重复的,因此另一个脚本将在文件夹/dev/blk/current/dev/blk/other中创建附加链接,第一个包含系统当前正在使用的分区,第二个包含其他分区,将在软件情况下使用更新。/proc/device-tree/chosen/linux_boot_slot系统通过查看引导加载程序填充的分区来了解哪些分区已用于当前引导。

然后,runtime使用解压分区sxverity并将内容提取到/sx/local/runtime. 该分区包含两个文件夹:

  • bin包含二进制文件和可执行脚本

  • dat包含许多附加数据,例如 AppArmor 规则、特定于硬件的配置和通用配置文件,例如dat/common/runtimeinit 脚本使用的(第二个)配置文件

在这些操作之后,根文件系统将使用ro(只读)标志重新安装。然后安装其他分区,例如:

  • /dev/blk/current/version_info/mnt/version_info(通过sxverity

  • /dev/blk/dish_cfg/mnt/dish_cfg(通过LUKS

  • /dev/blk/edr/mnt/edr(通过LUKS

守护进程

经过精心设计的启动过程和系统配置后,我们最终达到了一些进程正在运行的状态,每个进程都有一个独特的任务,为用户提供设备构建的服务,即卫星互联网连接。正如您可能已经猜到的,为了确保足够稳定的互联网连接,后台会发生许多事情,例如向卫星发送和接收流量、选择要连接的卫星、在当前卫星时更换卫星(不中断互联网连接)此外,这些进程需要不断地相互通信以实现同步和协作,并与 Starlink 的云服务实现后端功能。

大多数二进制文件都是用 C++ 实现的,其中一些也是静态链接的。因此,对这些二进制文件进行逆向工程具有挑战性,并且由于时间限制,我们无法完全理解所有这些二进制文件。使用二进制比较技术完成了一些工作来识别静态链接库函数。这些程序也很可能是使用状态机设计模式实现的,该模式大量使用了面向对象编程的功能,例如多重继承、虚拟方法和泛型类型。由于编译器在使用这些功能时生成的复杂结构,使得逆向工程过程变得更加困难。

我们尝试将用户终端的网络堆栈与已知的 ISO-OSI 堆栈进行比较,以下可能是正确的映射:

  • phyfw(也许是物理固件)处理卫星通信的物理层,其中包括射频信号的调制/解调

  • rx_lmactx_lmac(rx/tx 可能较低的介质访问控制)属于数据链路层,并处理对介质的物理访问,分别用于接收和发送

  • umac(也许是上层媒体访问控制)可以代表网络层。它在更高级别上处理对介质的访问,并在帧的传输和接收之间进行协调。它还可能负责选择连接到哪个卫星

  • connection_manager可以代表传输层,如果是这样的话,它处理碟形天线和卫星之间的状态连接,其中将交换流量

  • ut_packet_pipeline可能用于创建加密隧道,其中将使用盘上的安全元件交换用户流量以进行握手。这可能与已知协议(例如 TLS、DTLS、IPsec)或自定义协议相关

除了这些与网络相关的进程之外,还有一些处理系统遥测、软件更新、系统健康状态和中断检测/报告的进程,最后是充当所有其他进程的协调器的“控制”进程。

另一方面,其中一个二进制文件user_terminal_frontend是用 Go 实现的,Go 是 Google 的一种开源(编译)编程语言。Go 二进制文件是静态链接的,并且包含 go 运行时,因此它们相当大,但幸运的是,它们还包含运行时用于全面运行时错误报告的符号,其中包括函数名称、源代码行号和数据结构。所有这些宝贵的信息都可以使用名为GolangAnalyzer的 Ghidra 插件来恢复这非常有效。该扩展还可以恢复复杂的数据类型并在 Ghidra 中创建相应的类似 C 的结构,这在使用 OOP 语言时非常有用。由于 Go 使用的自定义调用约定,需要额外的手动分析,但在此之后,生成的反汇编 C 代码很容易阅读。我们的主要关注点是运行时的更高级别组件,其中包括此进程。

总结本节,在上图中您可以看到运行时架构的草图(不完整),其中您可以看到底部“更接近”硬件的进程是静态链接的,可能是为了性能由于原因,仅与控制进程通信,而其他进程也通过 gRPC 与 Starlink 的云服务通信。在通信部分,我们将更详细地处理此图所示的所有通信。最后,还有 go 二进制文件(技术上也是静态链接的,但只是因为语言限制),它与用户使用的前端应用程序进行通信。

运行时仿真

由于我们的故障注入攻击产生了负面结果,我们无法访问实时设备来检查我们的发现或执行一些动态分析。因此,我们尝试建立一个与真实设备尽可能相似的模拟环境,该环境能够执行运行时二进制文件。我们正在仿真整个系统(全系统仿真),从内核开始,使用 QEMU 作为仿真器引擎。在下面的段落中,我们描述了在设置环境和最终结果时必须应对的每个挑战,包括我们无法解决的挑战。

首先要选择的是我们希望 QEMU 模拟哪个硬件。当您想要使用 QEMU 模拟 IoT 设备时,您通常会寻找 QEMU 特定设备的硬件实现,该硬件实现通常可用于常见的现成设备,例如 Arduino、Raspberry Pi 和不太知名的板卡出色地。当然,我们设备的硬件实现不可用,因此我们使用了(aarch64) virt机器,这是最通用的机器。模拟整个设备的正确方法是为 QEMU 构建此机器规范,并为板上存在的每个硬件实现模拟器。问题是设备上的大多数外设都不是开放硬件,即使是,在 QEMU 中实现所有外设也需要大量工作。相反,使用virt机器和调整设备树要容易得多,但代价是没有大多数硬件外围设备,因此存在一些限制。

另一个问题是选择在 QEMU 中运行的内核。我们尝试使用从固件的 FIT 中提取的原始版本,但这在模拟环境中不起作用。所以我们决定自己编译一个。不幸的是,SpaceX 发布的 Linux 开源版本是 5.10.90,而碟子上找到的 Linux 版本是 5.15.55,所以我们使用了主流的 Linux 内核。必须在编译时配置中进行大量调整才能启动,其中一些是 QEMU 需要的,另一些是 Starlink 软件需要的。可以使用extract-ikconfigLinux 内核存储库中的脚本从已编译的内核映像中提取此配置,该脚本用于查找默认配置与 SpaceX 配置的配置之间的差异。

设备树不仅包含有关硬件外设的信息,还包含运行时使用的数据,例如sxverity. 此外,U-Boot 引导加载程序还在引导 Linux 内核之前填充 FDT,例如添加当前引导中使用的分区集、主网络接口的名称等。所有这些信息当然不包含在 QEMU 为机器设置的 FDT 中virt,因此我们使用dumpdtb标志提取此 FDT,并添加缺少的信息,如下所示,然后可以使用设备树编译器重新编译(dtc)并使用标志赋予 QEMU -dtb

# ...

model="spacex_satellite_user_terminal";

compatible="st,gllcff";

chosen{

linux_boot_slot="0";

ethprime="eth_user";

board_revision="rev2_proto3";

bootfip_slot=<0x00>;

boot_slot=<0x0006>;

boot_count=<0x0010>;

# ...

};

security{

dm-verity-pubkey=;

# ...

};

# ...

作为根文件系统,我们使用从盘中提取的文件系统,并进行了一些修改。

  • 由于我们想要访问模拟盘,它必须认为它是开发版本,以便启用密码访问,因此我们修补了脚本is_production_hardware。这可以通过多种方式完成,例如直接编辑文件/etc/shadow,或将我们的公钥添加到 SSHauthorized_users文件中,但我们所做的更有效,因为模拟开发硬件还可以启用其他调试功能

  • 我们还包含了提取的运行时,它将被安装在其中,并从文件中删除了完整性验证和安装步骤,/etc/runtime_init以便也能够篡改该分区的内容

  • 在该/etc/runtime_init文件中,我们还添加了一些自定义步骤,例如,设置模拟网络的步骤,以及将读写分区安装为模拟卷的步骤

其他程序需要额外的补丁才能在模拟环境中启动。我们还提供了一些用于测试目的的附加软件,例如gdbserver. 但为了使这些程序能够运行,我们要么必须使用相同的构建工具链交叉编译它们,要么静态地交叉编译它们。

尽管Linux内核启动时根文件系统和运行时都已放置在内存中,但许多进程直接访问eMMC的某些分区。因此,我们还指示 QEMU 创建一个原始虚拟块设备,其中包含从物理板转储的原始映像。但是,由于内核不将其视为 eMMC 芯片,因此分配的名称与物理设备分配的名称不同。因此,我们必须将每个引用更改mmcblk0vda,这是模拟器中内核分配的名称。幸运的是,正如我们在上一节中看到的,设备仅在脚本中使用设备名称,该脚本为每个分区创建一些符号链接,因此我们只需修补该脚本和内核的命令行参数。以写入权限安装的分区会被映射到主机上的文件夹,以便以后可以检查其内容。

至于网络,没有必要复制该盘的确切网络配置(因为我们不太清楚),我们只需要有正确的接口名称和互联网访问权限即可。这是通过使用一个 Tap 接口来完成的,该接口桥接到充当 NAT 网关的主机,如以下脚本所示。

主持人:

#!/bin/bash

ifconfig$10.0.0.0 promisc up

brctl addbr virbr0

brctl addif virbr0$1

ip linkset dev virbr0 up

ip linkset dev$1 up

ip addr add192.168.100.2/24 dev virbr0

echo1 > /proc/sys/net/ipv4/ip_forward

iptables -t nat -A POSTROUTING -o wlp0s20f3 -j MASQUERADE

客人:

#!/bin/sh

ip linkset dev eth0 name eth_user

ip linkset dev eth_user up

ip addr add192.168.100.1/24 dev eth_user

route add -net0.0.0.0 netmask0.0.0.0 gw192.168.100.2 dev eth_user

仿真结果

如前面部分所述,大多数硬件并不存在于模拟环境中,因此尝试使用它的每个组件都会失败。较低级别的进程,例如phyfw[rx/tx]_lmac,其主要任务是与 Starlink 的硬件交互,在这种环境中将无法工作。但其他二进制文件也需要一些硬件,最常见的是安全元件,它在大多数加密交换中使用。因此,为了让这些二进制文件正常工作,我们修补了每条可能导致程序崩溃的指令,但如果该过程的大部分部分都需要硬件,则该解决方案毫无意义。最后,我们成功地模拟了守护进程小节中讨论的主要进程,只有user_terminal_frontend, starlink_software_update,umac以及与遥测相关的以及较小的过程。关于此主题的进一步工作可以逐步增加对电路板某些外围设备的支持,从而能够模拟越来越多的流程。在这里您可以看到模拟环境中启动过程最后阶段的控制台输出。

# [...]

# kernel messages and (a lot of) errors from applications

# trying to access missing hardware peripherals

# [...]

setup_console: Development login enabled: yes

SpaceX User Terminal.

user1 login: root

Password:

*

+

+ +

+ +

+ +

+++++ + +

+ + + +

+ ++ +

+ + +

+ ++

+ + +

+ + + +

+ + + +

+++++ +++++

The Flight Software doesnotlog to the console. If you wish to view

the output of the binaries, you can use:

tail-f/var/log/messages

Or view the viceroy telemetry stream.

[root@user1~]#

上面提供的所有脚本和文件都可以在这里找到,但我们不会发布整个 UT 的固件,因此您仍然需要自己提取它才能运行模拟器。

通讯

在物联网设备中,与其他设备的通信即使不是主要功能,也是一项至关重要的功能。我们的设备也是如此,它基本上是一个互联网网关,正如您可以想象的那样,在这种情况下,设备和卫星之间的通信是用户终端的主要功能。我们简单分析了这种通信的物理层,但没有重点关注。在较高层,与卫星的通信分为两个“平面”:

  • 数据平面,包含进出互联网的用户流量

  • 控制平面,包含所有其他类型的流量,例如天线和卫星之间的控制消息(例如连接握手)

但这并不是设备中发生的唯一通信,在接下来的部分中,我们还将看到设备如何与用户前端应用程序交互,以及设备内部的不同组件如何相互通信。

前端应用

与前端应用程序的通信由 process 处理user_terminal_frontend,我们能够在模拟环境中运行该进程并进行逆向工程,这要归功于它在 (Go) 中实现的语言。从前端应用程序中,用户可以看到设备的一些统计数据,并且可以更改一些高级设置,例如 Wi-Fi 凭据、重新启动或收起盘子等。这些交互使用 gRPC(Google 的远程过程调用),这又在protobuf下面使用。Protobuf 定义可以通过从二进制文件中提取(使用pbtk)或通过询问进程本身的反射服务器(grpcurl例如,使用 )来收集。 一些 工具已被实现为使用此协议的替代前端应用程序。上述应用程序以 Python 实现,使用前端二进制文件公开的 gRPC API,为用户提供了另一种用户界面来检查菜肴的统计信息。这些应用程序的作者可能从移动应用程序或使用反射服务器收集协议定义。

在下面的代码片段中,您可以看到消息的(部分)定义Request,其中包含一些列出的 ID 和一个特定请求。每个内部请求都有其定义,其中包含服务器处理请求所需的参数以及保存结果的相应响应。

messageRequest {

uint64id=1;

uint64epoch_id=14;

stringtarget_id=13;

oneof request {

GetNextIdRequestget_next_id=1006;

AuthenticateRequestauthenticate=1005;

EnableDebugTelemRequestenable_debug_telem=1034;

FactoryResetRequestfactory_reset=1011;

GetDeviceInfoRequestget_device_info=1008;

GetHistoryRequestget_history=1007;

GetLogRequestget_log=1012;

GetNetworkInterfacesRequestget_network_interfaces=1015;

GetPingRequestget_ping=1009;

PingHostRequestping_host=1016;

GetStatusRequestget_status=1004;

RebootRequestreboot=1001;

SetSkuRequestset_sku=1013;

SetTrustedKeysRequestset_trusted_keys=1010;

SpeedTestRequestspeed_test=1003;

SoftwareUpdateRequestsoftware_update=1033;

DishStowRequestdish_stow=2002;

StartDishSelfTestRequeststart_dish_self_test=2012;

DishGetContextRequestdish_get_context=2003;

DishGetObstructionMapRequestdish_get_obstruction_map=2008;

DishSetEmcRequestdish_set_emc=2007;

DishGetEmcRequestdish_get_emc=2009;

DishSetConfigRequestdish_set_config=2010;

DishGetConfigRequestdish_get_config=2011;

// [...]

}

}

与此 gRPC 通信的方式有两种,一种是使用不安全通道,另一种是使用安全通道,其中涉及 TLS 和使用存储在安全元素中的证书进行相互身份验证。移动应用程序和 Web 界面都使用不安全的通道,因此加密的通道必须被其他东西使用。

在可以向服务器发出的请求中,许多请求是针对前端应用程序的,例如:

  • FactoryResetRequest,它请求将碟子恢复出厂设置

  • GetDeviceInfoRequest,它返回有关设备的一些信息

  • GetStatusRequest,它请求菜肴的状态

  • RebootRequest,这会要求盘重新启动

但有些请求看起来并不像是被这些应用程序使用的,例如:

  • SetTrustedKeysRequest,据说它设置了提供的公钥以供进程或 SSH 代理将来使用。

messageSetTrustedKeysRequest {

repeated PublicKeykeys=1;

}

  • GetHeapDumpRequest,据说它返回进程的堆部分的转储

  • SoftwareUpdateRequest,据称它会使用提供的更新包启动软件更新

不幸的是,大多数这些请求都没有在我们正在分析的二进制文件中实现(例如SetTrustedKeysRequestGetHeapDumpRequest),并且其中一些请求需要在传输层(安全 gRPC 通道)和应用层(通过使用)进行身份验证(例如,)DishGetContextRequest。我们不完全确定谁应该使用这些请求以及为什么大多数请求没有在二进制文件中实现,它们可以由另一个 Stalink 产品(例如 Wi-Fi 路由器)使用,或者在部分情况下由 Starlink 支持使用变砖设备,用于远程协助。最有趣的请求是已实现且不需要身份验证的请求,这将在模糊测试部分中更好地解释。DishGetEmcRequestAuthenticateRequestSoftwareUpdateRequest

关于这个主题的进一步工作将是进一步分析每个请求处理程序中的错误,或者更好地对它们进行模糊测试,因为 protobuf 协议存在有效的变异器,例如libprotobuf-mutator.

进程间通信

运行时中运行的每个进程不断地与其他进程共享信息,以进行协作和共享统计数据。从运行时架构图中可以看出,每个进程仅与用户终端控件通信,用户终端控件充当整个运行时的协调器。他们使用的协议是由 SpaceX 设计的,称为Slate Sharing.

它使用 UDP 进行传输,每个进程开始侦听环回接口上的不同端口,并将接收来自该端口上的控制进程的消息。另一方面,控制进程开始监听多个端口,每个需要与其通信的进程都有一个监听端口,这样通信是双向的,进程之间不会发生冲突。端口号是通过配置文件进行配置的,该文件可以在以下位置找到/sx/local/runtime/common/service_directory,在下面的代码片段中,您可以看到其中的一部分,其中列出了软件更新和控制之间以及前端和控制之间通信的端口号。

################################################################################

# Software update

software_update_to_control localhost27012 udp_proto

control_to_software_update localhost27013 udp_proto

################################################################################

# Frontend

control_to_frontend localhost6500 udp_proto

frontend_to_control localhost6501 udp_proto

每对进程都会交换不同类型的数据,并且与 JSON 或 XML 等其他协议不同,消息不包含有关其传输的数据内容或结构的任何信息。因此,为了理解消息的内容,我们必须对使用该协议的二进制文件之一进行逆向工程,在我们的例子中,最好的选择还是用户终端前端,它是用 Go 实现的。

数据以二进制形式交换,通过发送原始打包(无填充)C 结构,采用大端字节序。每条消息都包含一个标头(包含有关该消息的一些信息)和一个正文(包含要共享的实际数据)。在下面的代码片段中,您可以从 GolangAnalyzer Ghidra 插件中看到表示消息标头的数据结构。使用相同的技术,我们还提取了前端进程和控件之间的消息体的结构。

**************************************************************

* Name: sx_slate.SlateHeader *

* Struct: *

* + 0x0 0x4 uint BwpType *

* + 0x4 0x4 uint Crc *

* + 0x8 0x8 longlong Seq *

* + 0x10 0x4 uint Frame *

**************************************************************

标头包含:

  • BwpType是一个固定值,充当协议 ( 00 00 01 20) 的“幻数”。

  • Crc,顾名思义它是循环冗余检查,所以是消息体的一种错误检测代码,但是通过逆向工程和嗅探消息,这个字段似乎也是固定的,但是每对消息都是不同的。

  • Seq,这是一个序列号,每条消息都会递增,但该协议不包括任何重新发送丢失消息的确认机制

  • Frame,用于分片的情况,即当消息大于 MTU(最大传输单元)时,通常设置为 1500 字节。在这种情况下,消息的主体被分成多个帧,除了字段之外,每个帧都有相同的标头,Frame该字段从 0 开始每帧递增

消息正文以相同的方式进行编码,作为示例,在下面的代码片段中,您可以看到由前端进程控制发送的消息的部分结构。

**************************************************************

* Name: slate.FrontendToControl *

* Struct: *

* + 0x0 0x1 bool AppReboot *

* + 0x1 0x1 bool TiltToStowed *

* + 0x4 0x4 uint StartDishCableTestRequests *

* + 0x8 0x1 bool IfLoopbackTest *

* + 0x10 0x8 longlong Now *

* + 0x18 0x8 longlong StarlinkWifiLastConnected *

[...]

由于这些信息,我们已经能够实现消息解码器(我们已经做到了),但这仅适用于前端进程和控制之间的通信。为了解码其他通信,我们需要手动对每个其他二进制文件进行逆向工程,以找出消息的结构,甚至可能不需要找到字段名称,但正如前面部分所述,很难从 C++ 二进制文件中获取有用的信息。在这里您可以看到我们如何使用 Pythonctypes包解析 Slate 消息的标头。

classSlateHeader(BigEndianStructure):

_pack_=1

_fields_= [

("BwpType", c_uint32),

("Crc", c_uint32),

("Seq", c_longlong),

("Frame", c_uint32)

]

然后,为了了解 C++ 二进制文件中协议的处理方式,我们在控制过程中查找我们已知的结构(前端进程的结构)的一些字段,这些字段应该具有它们以便解码这些传入消息。我们在二进制文件中找不到它们,但我们发现了更好的方法,在该文件夹中/sx/local/runtime/common有一组配置文件,例如frontend_to_control,其中包含进程交换的每条消息的结构。这是包含上述配置文件的一部分的片段。

# Slate share message from gRPC frontend process to main control process.

app_reboot bool

tilt_to_stowed bool

start_dish_cable_test_requests uint32

if_loopback_test.enabled bool

now int64

starlink_wifi_last_connected int64

# [...]

这样,实现一个更通用的解码器来解析这些协议定义并相应地解码消息就容易多了。这样的工具已经开发出来,并将在下一节中讨论。

Slate 监听&注入

Slate 消息发送速度非常快,因此如果没有适当的可视化工具,很难理解正在发生的情况。这就是为什么我们实现了Slate 嗅探器,它是一种嗅探环回接口上的 UDP 数据包并动态解码它们的工具,突出显示连续消息之间的差异。在下图中,您可以看到新工具的整体架构,我们将对其进行更详细的描述。该工具是针对模拟环境实现的,但我们在设计它时牢记它也需要在真实的菜肴上工作。因此,大部分工作是在 Sniffer 中完成的,而不是在设备中,并且 Sniffer 和 Dish 之间的所有通信都通过 SSH 进行。

启动嗅探器时使用的第一个组件是协议定义解析器,它将解析配置文件:

  • service_directory了解哪些“服务”(即消息定义)可用以及它们将在哪些 UDP 端口上进行通信

  • [P1]_to_[P2]对于每个可用的进程对P1P2,了解将交换的消息的格式

该组件将创建SlateMessageParser对象,稍后将使用这些对象来解码消息。解码是使用structpython 包进行的。

此后,TCPdump将通过 SSH 在盘上启动,并将侦听环回接口,仅捕获具有解析器找到的目标端口之一的 UDP 数据包。的输出TCPdump通过管道传输到Scapy它将解码数据包,通过读取目标端口了解它来自哪个服务,然后提取 UDP 有效负载并将其传递给消息解码器。当消息被正确解析后,它将存储在存储组件中,在本例中,存储组件是一个简单的内存数据库,仅保存最近的消息(可配置,基于可用内存)。最重要的是,有一个 Flask 服务器,公开一些 API,以了解哪些服务可用,了解消息的架构,当然还可以获取消息。我们还实现了一个前端,作为一个简单的 Web 界面,如下图所示。从当前的前端,可以实时查看消息,通过突出显示的更改发现差异,选择要显示的字段并对它们进行过滤或排序。

在找到查看消息的方法之后,我们认为能够通过编辑我们收到的消息或从头开始创建新消息来注入自定义消息会很有趣。因此,我们实现了 Slate 注入器,它与嗅探器共享大部分代码库。该工具的架构如下所示。进程接收的消息需要来自环回接口,因此我们不能直接从注入器(位于设备外部)发送消息。这就是为什么喷油器会启动socat盘上的服务器,它将侦听“外部”网络接口上的 UDP 消息,然后通过将源地址更改为 localhost 将它们转发到正确的 UDP 端口。一些 API 端点已实现能够从前端注入消息,当前界面允许您编辑和发送消息或创建新消息。

能够检查进程之间的消息有助于我们了解每个进程的功能,而无需对其进行完全逆向工程。此外,有了协议定义和注入消息的简单方法,项目的自然发展就是对协议进行模糊测试,这将在模糊测试部分中解决。Slate 嗅探器、注入器和模糊器的完整代码可以在这里找到,但同样没有协议定义,您必须从盘中提取协议定义。

模糊测试

在我在 Quarkslab 实习的最后一部分,我们确定了软件的哪些部分已经过足够的分析,适合模糊测试。为了找到一个好的模糊目标,我们必须考虑多个方面:

  • 发现错误时可能的攻击向量:

    • 经过身份验证的用户可以触发该错误吗?

    • 攻击者是否需要连接到碟子的 Wi-Fi 网络?

    • 可以直接从互联网触发该错误吗?

    • 攻击者是否需要已经有权访问该盘?在这种情况下,该漏洞可用于在盘内进行横向移动或权限升级

  • 目标中可能存在的错误会产生什么影响?

  • 目标的模糊测试有多容易,以及哪种模糊测试最适合目标:

    • 输入是如何提供的?

    • 目标程序可以独立运行吗?如果程序使用许多硬件外围设备和/或与运行时的其他组件交互,则很难在隔离环境中对其进行模糊测试

    • 我们对目标进行了多深入的分析以了解其内部运作?

sxverity

正如我们在通信部分中看到的,可以通过SoftwareUpdateRequest从内部网络向前端进程发送 a 来触发软件更新。这是一个有趣的请求,因为它是唯一一个看起来不像是针对用户且不需要身份验证的请求。此外,该请求的输入是更新包,它可能非常大,而其他请求的输入通常为空或非常简单。更新包在发送到盘子之前必须分成块,这里是发送此消息的 python 脚本。

CHUNK_SIZE=16384

defupdate(data):

channel= grpc.insecure_channel("192.168.100.1:9200")

stub= device_pb2_grpc.DeviceStub(channel)

stream_id=int.from_bytes(os.urandom(4), byteorder="little")

for iinrange(0,len(data), CHUNK_SIZE):

chunk= data[i:min(len(data), i+ CHUNK_SIZE)]

request= device_pb2.Request(id=1, epoch_id=1, target_id="unknown",

software_update= common_pb2.SoftwareUpdateRequest(

stream_id= stream_id,

data= chunk,

open= i==0,

close= i+ CHUNK_SIZE>=len(data)

)

)

每条消息都需要有相同的stream_id,可以随机生成,那么第一条消息有flag open,而最后一条消息有flagclose标志和中间的所有标志都没有。消息的接收者是前端进程,它将把更新包保存在临时文件夹中,而不读取它的内容,因此不执行任何类型的输入清理,它只会检查包的大小是否达到硬编码的阈值。之后,前端进程将通过 Slate 消息通知控制进程已准备好应用旁载更新,并且控制进程将对软件更新进程执行相同的操作。一旦后者收到消息,就可以开始更新,下图显示了消息的整体流程和触发的操作SoftwareUpdateRequest

在通知软件更新过程软件更新包已准备好应用后,更新过程开始。从这一刻起,这种软件更新与标准软件更新没有任何区别,标准更新是从 Starlink 后端下载更新包。更新包是一个sxverity镜像,将由同名程序验证并rom1fs挂载内部文件系统。安装后,软件更新过程将在安装点中查找分区映像。每个分区映像还将有一个 SHA512 哈希和,用于额外的完整性验证。最后,每个可用的分区映像将闪存到相应的/dev/blk/other/*eMMC 逻辑分区上。

软件更新进程不会直接访问更新包,因此第一个实际读取所提供输入内容的进程是sxverity。因此,任何模糊测试都可以直接在该二进制文件上执行,跳过所有前面的步骤。在下图中,您可以看到验证过程是如何执行的sxverity。可模糊化代码非常有限,因为签名验证是由库进行的,这超出了范围,成功签名验证后发生的任何事情都被认为对我们来说是无法访问的,因为如果我们能够达到该状态,则意味着我们能够制作一个可以刷新的更新包,这样我们就不需要在那里找到其他错误。

被测试代码将解析的输入的唯一部分是图像的标题,因此这将是模糊器将改变的输入的唯一部分。由于程序的这一部分可以完全独立地执行,因此我们在 中对其进行了模糊测试unicorn,这是一个轻量级 CPU 模拟器,可以通过使用其绑定从 Python 进行指令。第一步是能够模拟我们想要测试的代码并为我们的模糊器设置工具,其中包括:

  • 加载二进制文件

  • 在代码中确定一个良好的起点,在其中可以轻松设置整个环境,例如将输入放置在正确的内存位置并设置测试中的代码将使用的所有其他内存结构。作为示例,以下代码片段显示了如何将输入放置在内存中以及如何设置保存输入位置地址的寄存器

defplace_input_cb(mu: Uc, content:bytes, persistent_round, data):

content_size=len(content)

if content_size< INPUT_MIN_LEN:

returnFalse

pubkey, header= key_and_header_from_data(content)

# write data in memory

mu.mem_write(PUBKEY_ADDR, pubkey)

mu.mem_write(HEADER_ADDR, header)

# prepare function arguments

mu.reg_write(UC_ARM64_REG_X2, PUBKEY_ADDR)# pubkey address

mu.reg_write(UC_ARM64_REG_X3,0x40)# nblocks

mu.reg_write(UC_ARM64_REG_X4, HEADER_ADDR)# header buffer address

returnTrue

  • 识别函数调用,挂钩它们并在Python中模拟它们,这样我们就不必花时间测试库代码,也不必将它们加载到内存中并处理动态加载的库。作为示例,以下是如何memcpy挂钩和模拟 libc 函数

if address== MEMCPY_ADDR:

# read arguments from registers

dest= mu.reg_read(UC_ARM64_REG_X0)

src= mu.reg_read(UC_ARM64_REG_X1)

n= mu.reg_read(UC_ARM64_REG_X2)

# read the data from src

data= mu.mem_read(src, n)

# write data in dst

mu.mem_write(dest,bytes(data))

# return the address of dest

mu.reg_write(UC_ARM64_REG_X0, dest)

# jump to the return address

lr= mu.reg_read(UC_ARM64_REG_LR)

mu.reg_write(UC_ARM64_REG_PC, lr)

  • 确定终点,该终点必须是程序中我们停止仿真的点,因为运行成功(没有错误)

使用 Unicorn 的最大好处,除了非常容易配置和指导之外,是对我们用作模糊器的AFL++ (American Fuzzy Lop plus plus)的完美支持。AFL++ 与 Unicorn 合作可以检测崩溃,最重要的是,以透明的方式收集覆盖信息,以便它可以执行覆盖引导的突变,并且使用 Unicorn 设置模糊器非常简单。模糊器还需要一些初始测试用例(称为种子),因为我们使用了一些有效的标头(取自盘中的 sxverity 图像)以及一些随机生成的标头。

模糊器运行了大约 24 小时,执行了超过一百万次执行,但不幸的是,没有记录到崩溃。这是预期的,因为测试的代码库非常有限,并且输入的结构非常简单,没有复杂的数据结构或可变长度字段,避免了最常见的与内存相关的错误。

Slate 消息

我们用模糊测试测试的另一个组件是进程间通信(IPC)——它在上一节中进行了深入分析——因为我们已经开发了一套工具来分析和篡改这种通信。在这种情况下,我们不会对单个二进制文件进行模糊测试,而是对构成设备运行时的整个进程集进行模糊测试,因为它们中的每个进程都使用 Slate 消息进行通信。这种模糊测试方法与我们用于 sxverity 的灰盒模糊测试方法完全不同,因为:

  • 我们尝试测试的代码库非常庞大。

  • 我们无法准确识别每个二进制文件中处理石板消息的代码,更重要的是,由于错误解释的输入导致程序状态中的一些不一致,因此还可以在该代码之外找到错误。

  • 二进制文件需要在类似盘的环境中运行,因为它们不断与系统的其他组件交互,其中大多数甚至不在我们的模拟环境中运行。

  • 此外,记录覆盖范围也具有挑战性,因为为此我们需要对二进制文件进行检测,因为我们没有源代码来重新编译它们,并且模糊器需要在盘上运行。

由于上述原因,我们使用了黑盒模糊测试,没有覆盖引导的突变,这通常被称为“哑”模糊测试。 Boofuzz用作模糊器,它是一个简单易用的模糊器,专为网络协议设计,非常适合我们所寻找的东西。Boofuzz 不会以完全随机的方式生成输入,因为您为其提供了要在通信中使用的协议定义,并且可以使用有限状态机定义消息序列。在我们的例子中,每条消息都与其他消息断开连接(除了序列号),因此定义消息的格式就足够了。然后,模糊器将通过尝试一些可能触发错误的值来改变消息的每个字段,例如int32模糊器将尝试诸如 之类的值{0, 1, -1, INT_MAX, -INT_MAX, ...}。作为示例,以下是 Slate 消息的某些字段如何“翻译”为 Boofuzz 协议定义的方式。

if param.dtype.name=="BOOL":

return Simple(

name=param.name,

default_value=b"\\x00\\x00\\x00\\x00",

fuzz_values=[b"\\x00\\x00\\x00\\x00", b"\\x00\\x00\\x00\\x01"],

)

if param.dtype.name=="INT8"or param.dtype.name=="UINT8":

return Byte(name=param.name)

if param.dtype.name=="INT32"or param.dtype.name=="UINT32"or param.dtype.name=="FLOAT":

return DWord(name=param.name)

Slate 消息协议中使用的每种数据类型都可以使用 Boofuzz 的标准类型进行编码,除了序列号之外,序列号需要存储内部状态以在每次迭代时自增,您可以在下面的代码片段中看到它的实现。诸如Bwptype和之类的静态字段Crc可以使用StaticBoofuzz 中的类型进行编码。

classSequenceNumber(Fuzzable):

def __init__(self,*args,**kwargs):

super().__init__(*args,**kwargs, fuzzable=False, default_value=0)

self._curr=0

defencode(self, value:bytes=None, mutation_context=None)->bytes:

curr=self._curr

self._curr+=1

returnint.to_bytes(curr, length=8, byteorder="big")

一旦定义了消息结构,模糊器就可以使用 Slate 注入器中的代码来发送消息。此时需要实现的唯一组件是可以检测消息发送后程序是否崩溃的组件。起初,我们通过 SSH 发出pgrep命令,但这会增加开销,从而减慢模糊器的速度。因此,我们实现了一个在盘上运行的简单脚本,打开 TCP 套接字并等待连接,然后使用该连接直接与模糊器进行通信。将在客户端(fuzzer 机器)上运行的进程监视器部分可以通过继承 BoofuzzBaseMonitor并实现其方法来集成到 fuzzer 中,例如alive(检查目标进程是否仍然存在)和restart_target(重新启动目标进程)。最终的架构如下图所示。

通过模糊协议发现了一些崩溃control_to_frontend,但除了简单地使程序崩溃,导致前端应用程序拒绝服务之外,似乎没有一个可以通过其他方式利用。这是因为前端进程是 Go 二进制文件,Go 运行时使进程崩溃(通过函数panic),因为它检测到有问题正在发生。

作为示例,以下是其中一次崩溃的详细信息。在下面的代码片段中,您可以看到 Go 运行时在崩溃时产生的部分堆栈跟踪,从中您可以了解到崩溃是由 function 引起的,该函数尝试分配过多的内存UpdateObstructionMap

fatalerror:runtime:outof memory

goroutine5[running]:

[...]

runtime.(*mheap).alloc(0x5046800?,0x28234?,0xd0?)

[...]

main.(*DishControl).UpdateObstructionMap(0x40003be000, {0x7a90f8?,0x4000580380?})

[...]

通过进一步检查这个函数,我们了解了障碍图是如何传输到前端进程的。首先,障碍物图是天线上方天空的 3D 地图,指示天线是否有清晰的天空视野,或者是否被树木或其他建筑物等障碍物遮挡,用户可以从前端应用程序。该地图不是由前端进程生成的,因此必须通过 Slate 消息发送给前端进程。

obstruction_map.config.num_rows uint32

obstruction_map.config.num_cols uint32

obstruction_map.current.obstructed bool

obstruction_map.current.index uint32

在上面的代码片段中,您可以看到消息结构定义的一部分,其中包含有关障碍图的信息。障碍图在内存中表示为矩阵,其中每个点都可以被遮挡或不被遮挡。控制进程通过为矩阵中的每个点发送一条 Slate 消息来发送此信息,方法是将 rightindex和设置设置obstructedtruefalse。矩阵的大小不是固定的,其维度可以由控制进程使用消息中的num_rows和字段来设置。num_cols这就是错误所在,事实上,当在这两个字段中发送大值时,程序会尝试为矩阵分配足够的内存,并因此出现恐慌。

len= ObstructionMapConfigNumCols* ObstructionMapConfigNumRows;

if (len== (this->obstructionMap).snr.__count)

goto LAB_0050b7f4;

(this->obstructionMap).numRows= ObstructionMapConfigNumRows;

(this->obstructionMap).numCols= ObstructionMapConfigNumCols;

puVar5= runtime.makeslice(&datatype.Float32.float32,len,len);

上面的代码片段显示了前端二进制文件的反编译和注释代码,该代码在接收到石板消息时处理阻塞的大小。第 1 行计算矩阵的大小,第 2 行将其与程序内存中矩阵的当前大小进行比较,如果两者不同,则在第 4 行和第 5 行的内部内存结构中更新维度,然后更新新的维度。矩阵的分配使用makeslice第 6 行 Go 运行时的方法。如您所见,没有对要分配的大小执行检查,也没有对两个给定维度之间的乘法结果执行检查。这在 C 中是非常危险的,但是 Go 运行时会通过检查所请求的内存大小是否为正且不太大来自动处理所有极端情况。Go 运行时还会检查每个数组访问,否则,通过使用索引和矩阵的大小可能可以进行任意写入。

请注意,此错误只能通过将精心设计的 UDP 数据包发送到仅绑定到本地主机的服务来触发。因此不可能从外部网络触发它。此外,iptablesUT 的配置会过滤掉传入的 UDP 数据包,因此具有本地主机源 IP 的欺骗数据包也不起作用。因此,我们并不认为这是一个漏洞,而只是一个错误。

在我们实现模糊器并在模拟器中使用它之后,Starlink 为我们提供了一个 root UT,然后我们确认了真实设备上存在上述错误,并对一些在模拟器中无法运行的进程进行了模糊测试。

结论

更多详情请参阅我将于今年年底发表的硕士论文,敬请期待!所提供的工具和脚本可以在此存储库中找到。

这项工作和我们发布的工具旨在重复用于星链用户终端的进一步研究。不幸的是,由于一些技术问题和时间限制,我们未能全面检查卫星通信中使用的网络堆栈和协议,但希望运行时的高级管理功能的这个知识库可以用于未来将协助这一努力。

我鼓励对这个主题进行研究,也是因为 SpaceX 的安全团队会为你提供帮助,并且他们会提供一些丰厚的赏金。

非常感谢:

  • Maxime Rossi Bellom,我的实习导师,指导我进行这项研究

  • Lennert Wouters 是有关 Starlink 用户终端固件转储和故障注入攻击的博客文章的作者,他在这项研究的早期阶段为我们提供了帮助

  • SpaceX 安全团队的 Tim Ferrell 向我们发送了具有 root 访问权限的测试盘

  • Ivan Arce、Salwa Souaf 和 Guillaume Valadon 审阅了我的博客文章

  • 许多其他出色的同事在他们专业领域的主题上为我提供了帮助

声明:本文来自卫星黑客,版权归作者所有。文章内容仅代表作者独立观点,不代表安全内参立场,转载目的在于传递更多信息。如有侵权,请联系 anquanneican@163.com。