分析汽车 ECU 固件有时是非常具有挑战性的,特别是当您无法模拟一些最有趣的功能以查找漏洞时,比如基于瑞萨 RH850 系统芯片的 ECU。本文详细介绍了我们如何成功将对这种特定架构的支持添加到独角兽引擎中,我们面临的各种挑战以及如何成功利用这项工作来模拟和分析任务中的特定功能。
介绍
瑞萨 RH850 架构在汽车 ECU 中非常常见,我们在工作中经常需要分析设计用于该特定架构的固件。逆向工程这种固件是一回事,能够模拟部分或全部固件是另一回事,这可能对进行代码覆盖分析或更普遍的模糊测试非常有价值。当涉及对嵌入式架构进行模糊测试时,人们首先想到的是 Unicorn Engine,那么为什么不改进这个引擎以支持 RH850 架构呢?
瑞萨 RH850 系统芯片依赖于 V850 CPU,结合各种硬件外设,提供以太网、RLIN、CAN 等功能。V850 系列中有不同变体的 CPU,其中一些仅支持特定指令集,并且与较新的变体不兼容。由于我们拥有一块 RH850 开发板,我们决定选择与我们板上相同的 CPU(V850e3,RH850 CPU 系列中的最新变体),以便能够检查模拟 CPU 与真实 CPU 的行为有何不同。
我们在 Github 上找到了一个由 iSYSTEM Labs 的 Marko Klopčič创建的 RH850 CPU 的现有实现,但这个实现似乎是不完整的,因为它不支持异常或 FPU 指令。但这是一个很好的起点,所以我们使用了这个实现并进行了改进,添加了缺失的部分,最终在 Unicorn Engine 中正确模拟了一个可工作的 CPU。
独角兽引擎,QEMU 和 TCG
独角兽引擎依赖于修改版的 Qemu 来提供 CPU 仿真和绑定,这意味着在独角兽引擎中添加新的 CPU 与在 Qemu 中添加新的 CPU 非常相似。在 Qemu 中,大多数 CPU 实现依赖于指令翻译而不是直接仿真。
在直接模拟中,每个指令都会被解码,然后被模拟,指令对寄存器、内存和标志的任何影响都会被模仿,就像在原始 CPU 中应该发生的那样。这种方法并不高效,因为每个指令在执行时都必须被解码和模拟,这会在指令处理级别引入一些延迟,这些延迟会累积,并通常导致明显的整体延迟,从而减慢程序或固件的模拟速度。
为了避免这种情况,Qemu 提供了一个非常重要的组件,名为 Tiny Code Generator 或 TCG,由 Fabrice Bellard 于 2008 年添加,它使用指令翻译将任何模拟指令转换为一组本机指令,可以在主机架构 CPU 上运行,同时还具有缓存和优化功能,以加快对原始指令的模拟。让我们深入了解 Qemu 的 TCG,了解它的工作原理以及如何将其用于 CPU 模拟。
微型代码生成器
Qemu 的 TCG 为每个模拟指令生成中间表示(IR)代码,然后将其翻译为本机代码,利用主机的执行速度。这个中间表示是由我们的目标 CPU 实现生成的,将目标指令翻译为它们的 IR 等效形式。此外,TCG 还将模拟代码分解为将被优化、缓存和链接的执行块。
QEMU TCG 客户机代码翻译
当 TCG 首次遇到一条指令时,它使用目标 CPU 实现来生成该指令及其后续指令的 IR 等效形式,直到遇到一条指令导致 CPU 跳转到内存中的另一个位置(基本上是跳转、条件跳转或过程调用),将它们分组在一个翻译块中。一旦生成了一个翻译块,它就可以被缓存和执行,因此如果以后再次调用它,那么 TCG 将执行相同的块,而无需再次翻译它(除非 CPU 状态不完全相同,但我们稍后会讨论这一点)。然后,延迟就会减少,整体性能会得到改善。如上图所示,翻译块是通过跟随执行流程动态生成并保存在缓存中的。
Qemu 的 TCG 提供了一组基本功能(API),允许 CPU 实现为每个支持的指令生成特定的 IR 代码。
为指令编写 IR 生成器
作为一个例子,我们将编写代码来生成 RH850 的 ADD 指令的中间表示,其第一个格式为(ADD reg1, reg2),如文档中定义的那样
RH850 ADD 指令定义
首先,我们需要一个特殊的函数来生成一些 IR 代码,将当前 CPU 寄存器的值检索到一个 TCG 变量中:
/* Wrapper for getting reg values - need to check of reg is zero since
* cpu_gpr[0] is not actually allocated
*/
void gen_get_gpr(TCGContext *tcg_ctx, TCGv t, int reg_num)
{
if (reg_num == 0) {
tcg_gen_movi_tl(tcg_ctx, t, 0);
} else {
tcg_gen_mov_tl(tcg_ctx, t, cpu_gpr[reg_num]);
}
}
此函数生成一个 TCG mov 指令,要么将提供的寄存器设置为零(如果请求 R0,因为在这个 CPU 中,R0 寄存器始终为零),要么根据其索引将提供的通用寄存器的当前值设置为零。编写此函数后,我们还需要编写一个函数来将一些值写入 CPU 的通用寄存器中。
/* Wrapper for setting reg values - need to check if reg is zero since
* cpu_gpr[0] is not actually allocated. this is more for safety purposes,
* since we usually avoid calling the OP_TYPE_gen function if we see a write to
* $zero
*/
void gen_set_gpr(TCGContext *tcg_ctx, int reg_num_dst, TCGv t)
{
if (reg_num_dst != 0) {
tcg_gen_mov_tl(tcg_ctx, cpu_gpr[reg_num_dst], t);
}
}
再次,我们使用 mov 指令将数据写入我们的通用寄存器。由于 TCG 只能使用自己的寄存器,所有我们的通用寄存器都被声明为 TCG 全局变量。
/* global register indices */
static TCGv cpu_gpr[NUM_GP_REGS];
一切都准备好实现 IR 代码生成功能。我们首先通过从传入参数中获取通用寄存器的值来开始,并将它们存储到两个名为 r1 和 r2 的新 TCG 临时变量中。
static void gen_intermediate_add_reg_reg(DisasContext *ctx, int rs1, int rs2)
{
TCGContext *tcg_ctx = ctx->uc->tcg_ctx;
TCGv r1 = tcg_temp_new(tcg_ctx);
TCGv r2 = tcg_temp_new(tcg_ctx);
TCGv tcg_result = tcg_temp_new(tcg_ctx);
gen_get_gpr(tcg_ctx, r1, rs1);
gen_get_gpr(tcg_ctx, r2, rs2);
然后,我们使用 TCG 的 tcg_gen_add_tl
函数实现算术加法:
tcg_gen_add_tl(tcg_ctx, tcg_result, r2, r1);
gen_set_gpr(tcg_ctx, rs2, tcg_result);
我们还根据当前寄存器状态计算标志
gen_flags_on_add(tcg_ctx, r1, r2);
最后但并非最不重要的是,我们释放了两个临时 TCG 变量:
tcg_temp_free(tcg_ctx, r1);
tcg_temp_free(tcg_ctx, r2);
}
这为 RH850 ADD 指令(格式 I)提供了以下最终功能:
static void gen_intermediate_add_reg_reg(DisasContext *ctx, int rs1, int rs2)
{
/* Retrieve the TCG context from Unicorn's disassembly context. */
TCGContext *tcg_ctx = ctx->uc->tcg_ctx;
/* Create two temporary TCG variables. */
TCGv r1 = tcg_temp_new(tcg_ctx);
TCGv r2 = tcg_temp_new(tcg_ctx);
gen_get_gpr(tcg_ctx, r1, rs1);
gen_get_gpr(tcg_ctx, r2, rs2);
/* Add r1 and r2 and write the result into tcg_result */
tcg_gen_add_tl(tcg_ctx, c, r2, r1);
/* Write the result into general-purpose register designed by index rs2 */
gen_set_gpr(tcg_ctx, rs2, tcg_result);
/* Update flags */
gen_flags_on_add(tcg_ctx, r1, r2);
/* Free all temporary variables. */
tcg_temp_free(tcg_ctx, r1);
tcg_temp_free(tcg_ctx, r2);
tcg_temp_free(tcg_ctx, tcg_result);
}
此函数必须使用从解码指令中提取的正确参数调用,并将生成相应的 IR 代码,以相应地修改我们的 CPU 通用寄存器和标志。
在我们的 RH850 实现中,我们将类似的算术函数分组到一个单独的中间表示生成器中,以尽可能因式分解代码。
标签、测试和跳转在 IR 中
有时需要在单个块内实现条件跳转,根据特定条件返回两个不同的值,例如。这种行为是在上述 gen_flags_on_add()
IR 生成器中实现的,如下所示:
static void gen_flags_on_add(TCGContext *tcg_ctx, TCGv_i32 t0, TCGv_i32 t1)
{
TCGLabel *cont;
TCGLabel *end;
TCGv_i32 tmp = tcg_temp_new_i32(tcg_ctx);
tcg_gen_movi_i32(tcg_ctx, tmp, 0);
// 'add2(rl, rh, al, ah, bl, bh) creates 64-bit values and adds them:
// [CYF : SF] = [tmp : t0] + [tmp : t1]
// While CYF is 0 or 1, SF bit 15 contains sign, so it
// must be shifted 31 bits to the right later.
tcg_gen_add2_i32(tcg_ctx, cpu_SF, cpu_CYF, t0, tmp, t1, tmp);
tcg_gen_mov_i32(tcg_ctx, cpu_ZF, cpu_SF);
tcg_gen_xor_i32(tcg_ctx, cpu_OVF, cpu_SF, t0);
tcg_gen_xor_i32(tcg_ctx, tmp, t0, t1);
tcg_gen_andc_i32(tcg_ctx, cpu_OVF, cpu_OVF, tmp);
tcg_gen_shri_i32(tcg_ctx, cpu_SF, cpu_SF, 0x1f);
tcg_gen_shri_i32(tcg_ctx, cpu_OVF, cpu_OVF, 0x1f);
tcg_temp_free_i32(tcg_ctx, tmp);
cont = gen_new_label(tcg_ctx);
end = gen_new_label(tcg_ctx);
tcg_gen_brcondi_i32(tcg_ctx, TCG_COND_NE, cpu_ZF, 0x0, cont);
tcg_gen_movi_i32(tcg_ctx, cpu_ZF, 0x1);
tcg_gen_br(tcg_ctx, end);
gen_set_label(tcg_ctx, cont);
tcg_gen_movi_i32(tcg_ctx, cpu_ZF, 0x0);
gen_set_label(tcg_ctx, end);
}
上面代码第 27 行的条件跳转需要定义两个标签,一个表示如果条件满足时要执行的代码,另一个表示如果条件不满足时要执行的代码。
标签的定义如第 3 行和第 4 行所示,并通过调用 gensetlabel()设置,如第 31 行和第 34 行所示。它们标记代码中可以通过跳转到达的特定位置。
条件跳转是通过特定的 TCG 原语生成的,例如在第 27 行所示的 tcg_gen_brcondi_i32()
。在这个例子中,如果零标志被设置(并且零标志将被取消设置),执行将继续到标签 cont
,或者在条件不满足时立即执行条件跳转。
链接翻译的块
翻译指令操纵执行流程,如过程调用、直接或条件跳转,需要告诉 QEMU 下一个要执行的翻译块。对于可能导致两个不同块的条件跳转,这一点尤为重要。每个翻译块有两个可用的跳转槽,IR 代码可以利用这些槽来操纵执行流程。
在简单跳转的情况下,使用以下代码:
tcg_gen_goto_tb(tcg_context, 0);
tcg_gen_movi_tl(tcg_context, cpu_pc, dest_address);
tcg_gen_exit_tb(tcg_context, ctx->base.tb, 0);
当首次执行此 IR 代码时,调用 tcg_gen_goto_tb()
时生成的 goto 指令仅分配第一个跳转槽。接下来的一行修改 CPU 状态,特别是其程序计数器,并调用 tcg_gen_exit_tb()
告诉 TCG 应生成处理当前已翻译块的退出和第一个跳转槽的 IR 代码。
翻译的块退出代码将评估 CPU 状态,然后使用第一个调用到 tcg_gen_goto_tb()
发出的 IR 跳转指令的对应目标翻译块地址进行修补。下次执行此翻译块时,执行将直接跳转到与此跳转槽相关联的下一个翻译块地址,同时相应地修改当前 CPU 状态。条件跳转的处理方式相同,只是它生成两个 goto IR 指令,一个用于每个跳转槽,当执行遵循其中一个路径时,这些 IR 指令将在运行时进行修补。
Airbus SecLab 在 QEMU 的 TCG 上撰写了一系列博客文章,涵盖了 TCG 的其他方面,如果您想更好地了解 TCG 以及它将 IR 代码转换为本机代码并处理内存访问的方式。QEMU 的 TCG 内部也在 QEMU 官方文档中有记录。
将新的 CPU 添加到独角兽引擎
将客户指令翻译成它们的 IR 等效物是一回事,将新的 CPU 添加到独角兽引擎中是另一回事。在独角兽引擎中的 CPU 行为与 QEMU 中的 CPU 相似:我们必须定义一组回调函数,处理对我们模拟的 CPU 上的不同操作,例如管理其寄存器和状态,或者翻译位于特定地址的指令。
声明一个新的 CPU 和它的回调
声明一个新的 CPU 是非常简单的,如下面的代码所示:
DEFAULT_VISIBILITY
void rh850_uc_init(struct uc_struct *uc)
{
uc->release = rh850_release;
uc->reg_read = rh850_reg_read;
uc->reg_write = rh850_reg_write;
uc->reg_reset = rh850_reg_reset;
uc->set_pc = rh850_set_pc;
uc->get_pc = rh850_get_pc;
uc->cpus_init = rh850_cpus_init;
uc->cpu_context_size = offsetof(CPURH850State, uc);
uc_common_init(uc);
}
此代码告诉独角兽引擎要为所有必需的操作使用不同的回调函数,包括在此通过 rh850_cpus_init()
函数执行的 CPU 初始化。该函数基本上初始化单个 CPU,如下所示:
static int rh850_cpus_init(struct uc_struct *uc, const char *cpu_model)
{
RH850CPU *cpu;
cpu = cpu_rh850_init(uc, cpu_model);
if (cpu == NULL) {
return -1;
}
return 0;
}
cpu_rh850_init()
函数负责初始化 CPU 状态,与 QEMU 相同,通过调用一组子函数来设置一些额外的回调和默认的 IR 生成例程:
void gen_intermediate_code(CPUState *cpu, TranslationBlock *tb, int max_insns)
{
DisasContext dc;
translator_loop(&rh850_tr_ops, &dc.base, cpu, tb, max_insns);
}
上述功能配置了将分析访客代码并生成翻译块的翻译器。支持的翻译操作定义如下:
static const TranslatorOps rh850_tr_ops = {
.init_disas_context = rh850_tr_init_disas_context,
.tb_start = rh850_tr_tb_start,
.insn_start = rh850_tr_insn_start,
.breakpoint_check = rh850_tr_breakpoint_check,
.translate_insn = rh850_tr_translate_insn,
.tb_stop = rh850_tr_tb_stop,
};
然后,译者可以通过 translate_insn 回调函数来翻译任何客户 CPU 指令。该函数基本上解析位于程序计数器地址的指令,并生成相应的 IR 代码。我们在本博文中不会涵盖指令解码在我们的 RH850 CPU 实现中是如何执行的。
独角兽引擎绑定
独角兽引擎的一个优势是它提供了许多语言的绑定,比如 Python、Java 或 Rust 等。这些绑定是基于每个支持的架构的 C 包含文件自动生成的。我们需要做的唯一一件事就是为我们的 RH850 架构添加一个新的头文件,告诉独角兽引擎要使用哪些寄存器索引来访问 CPU 状态:
//> RH850 global purpose registers
typedef enum uc_rh850_reg {
UC_RH850_REG_R0 = 0,
UC_RH850_REG_R1,
UC_RH850_REG_R2,
UC_RH850_REG_R3,
UC_RH850_REG_R4,
/** ... **/
//> RH850 system registers, selection ID 2
UC_RH850_REG_HTCFG0 = UC_RH850_SYSREG_SELID2,
UC_RH850_REG_MEA = UC_RH850_SYSREG_SELID2 + 6,
UC_RH850_REG_ASID,
UC_RH850_REG_MEI,
UC_RH850_REG_PC = UC_RH850_SYSREG_SELID7 + 32,
UC_RH850_REG_ENDING
} uc_cpu_rh850;
//> RH8509 Registers aliases.
#define UC_RH850_REG_ZERO UC_RH850_REG_R0
#define UC_RH850_REG_SP UC_RH850_REG_R3
#define UC_RH850_REG_EP UC_RH850_REG_R30
#define UC_RH850_REG_LP UC_RH850_REG_R31
这就是全部!独角兽引擎将根据这个包含文件处理所有支持的语言的绑定生成。
测试我们的实施
我们创建了一个小的 Python 程序来测试从我们拥有的各种 RH850 固件中提取的 RH850 函数的执行,即 strlen:
#!/usr/bin/env python
# Sample code for RH850 of Unicorn. Damien Cauquil <dcauquil@quarkslab.com>
#
from __future__ import print_function
from unicorn import *
from unicorn.rh850_const import *
'''
; Assembly code taken from our firmware (strlen implementation)
;
; r6 -> points to the target text string
; r10 -> computed string length
; r11 -> evaluated byte
0002876e 1f 52 mov -0x1,r10
00028770 41 52 add 0x1,r10
00028772 06 5f 00 00 ld.b 0x0[r6],r11
00028776 41 32 add 0x1,r6
00028778 60 5a cmp 0x0,r11
0002877a ba fd bne LAB_00028770
'''
# Inline bytecode for this function
RH850_CODE = b"\x1f\x52\x41\x52\x06\x5f\x00\x00\x41\x32\x60\x5a\xba\xfd"
# memory address where emulation starts
CODE_ADDRESS = 0x0
RAM_ADDRESS = 0x100
try:
# Initialize emulator in normal mode
mu = Uc(UC_ARCH_RH850, 0)
# map 2MB memory for this emulation and store our string
mu.mem_map(CODE_ADDRESS, 2*1024*1024)
mu.mem_write(RAM_ADDRESS, b'This is a test\0')
# write machine code to be emulated to memory
mu.mem_write(CODE_ADDRESS, RH850_CODE)
# initialize machine registers
mu.reg_write(UC_RH850_REG_R6, RAM_ADDRESS)
# emulate machine code in infinite time
mu.emu_start(CODE_ADDRESS, CODE_ADDRESS + len(RH850_CODE))
# Read string length (stored in R10)
print('Computed string length: %d' % mu.reg_read(UC_RH850_REG_R10))
except UcError as e:
print(e)
print("ERROR: %s" % e)
当运行时,此示例为文本字符串"This is a test"提供了正确的字符数
$ python3 rh850-strlen-example.py
Computed string length: 14
用例:代码覆盖率
由于我们经常采用灰盒/黑盒方法评估汽车 ECU,我们经常处理瑞萨 RH850 微控制器。在反向工程 ECU 固件时,能够模拟这种架构是非常有价值的,以找到或确认漏洞。
RH850 仿真器的第一个用例是作为 ECU 的一个网关,用于在车辆 CAN 网络和第三方网络之间进行特定适配。评估的一部分是确保固件的完整性和设备的校准。
一点背景 - UDS 协议
ECU 的更新通常使用 CAN/Automotive-Ethernet 网络上的 UDS 协议完成。更新过程的特权访问由 SecurityAccess
服务保护,该服务包括挑战-响应算法。在请求 SecurityAccess
时,诊断工具会请求一个 Seed,即 ECU 发送的挑战,并发送一个 Key,即对该挑战的响应。
在我们的情况下,制造商依赖于一种安全可靠的非对称加密方案来应对这种挑战,除非设备仍处于 Virginmode
状态,此时它使用静态种子/密钥。
我们的评估工作之一是确保攻击者无法将 ECU 恢复到 Virgin
状态,并检查生成的种子的熵,以避免任何重放攻击,通过对提供的固件进行逆向工程。
当涉及 UDS 时,我们的第一步是定位处理 UDS 请求的主要功能,通过查找 UDS 数据库,使用类似 binbloom 的工具。一旦我们确定了功能,我们就可以开始了解数据是如何处理/存储的,就像我们的 Virgin
状态。
建筑安全带
为了帮助我们进行逆向工程工作,能够进行一些动态分析是有用的。由于 ECU 的调试端口在生产模式下被锁定,我们无法使用连接到其上的调试器。然而,通过在 RH850 仿真器上进行的工作,我们可以模拟一些目标函数,以更好地了解它们的行为,或者通过操纵内存中的特定值来确认一些假设。
运行我们的模拟器的第一个任务是构建测试装置。为此,我们需要映射一些微控制器的地址,主要是 ProgramFlash
和 RAM
的部分,包括 stack
。这些信息通常在微控制器用户手册中提供,通常在内存映射部分下。
RH850 内存映射
在我们的情况下,固件是按照 ISO 22901-1 定义的 OpenDiagnosticDataExchange
标准提供的 PDX
包。 PDX
包中包含了两个二进制文件,一个用于应用程序,另一个用于校准,还有一个 ODX
文件指定了每个部分在内存中的位置。
应用程序:
0x0000C000
校准:
0x0000A000
根据微控制器数据表,我们还映射了 RAM
和 stack
,因此我们的仿真器将能够在这些地址读写。请注意,独角兽引擎仅支持各种内存区域的 4KB 块。
我们还需要为存储在 0x00008000
的引导加载程序添加内存区域,这在我们的评估过程中没有提供,以覆盖对这些地址的各种调用。
最后,我们需要在 RAM
和至少在 PC
寄存器中设置一些值,具体取决于我们想要测试和指定起始/结束地址的状态,例如使用服务 ReadDataByIdentifier
的 Virgin
状态。我们直接针对处理此服务的功能,我们在 0x00018DAE
找到了。
我们的基本挽具将如下所示:
#!/usr/bin/python3
import math
import logging
from pwn import *
from unicorn import *
from unicorn.rh850_const import *
# Memory map
BOOT_ADDRESS = 0x00008000
BOOT_LEN = 0x00001000
CODE_ADDRESS = 0x0000C000
CALIB_ADDRESS = 0x0000A000
RAM_ADDRESS = 0xFE000000
RAM_LEN = 0x02000000
STACK_ADDRESS = 0x60000000
STACK_LEN = 0x00010000
START_ADDRESS = 0x00018DAE
END_ADDRESS = 0x00018EAE
UDS_PAYLOAD = b'\x22\xF2\xAA'
def define_memory_size(size):
if size % 4096 != 0:
size = math.ceil(size/4096)*4096
return size
if __name__ == "__main__":
logging.basicConfig()
uc = Uc(UC_ARCH_RH850, UC_MODE_LITTLE_ENDIAN)
# Loading appli
with open("bin_files/appli.bin","rb") as f:
app = f.read()
f.close()
uc.mem_map(CODE_ADDRESS, define_memory_size(len(app)))
uc.mem_write(CODE_ADDRESS, app)
# Loading calib
with open("bin_files/calib.bin","rb") as f:
calib = f.read()
f.close()
uc.mem_map(CALIB_ADDRESS, define_memory_size(len(calib)))
uc.mem_write(CALIB_ADDRESS , calib)
# Bootloader memory initialization
uc.mem_map(BOOT_ADDRESS, BOOT_LEN)
# Stack initialization
uc.mem_map(STACK_ADDRESS, STACK_LEN)
uc.reg_write(UC_RH850_REG_SP, STACK_ADDRESS + STACK_LEN)
# RAM initialization
uc.mem_map(RAM_ADDRESS, RAM_LEN)
# Registers initialization
uc.reg_write(UC_RH850_REG_PC, START_ADDRESS)
# State data
uc.mem_write(0xFFFF0625, b'\x01') # UDS message length
uc.mem_write(0xFEDD93CD, UDS_PAYLOAD) # UDS message payload
uc.mem_write(0xFEDE0C03, b'\xFF') # Virgin status (0x00 or 0xFF)
# Emulate all the things
try:
logging.info(f"UDS payload: {UDS_PAYLOAD.hex().upper()}")
logging.info("Emulating function RDBI")
logging.info(f"Starting emulation @{START_ADDRESS:#010x} to {END_ADDRESS:#010x}\n")
uc.emu_start(START_ADDRESS, END_ADDRESS, timeout=0, count=0)
except unicorn.UcError as e:
logging.error(f"Crash - Address : {uc.reg_read(UC_RH850_REG_PC):#08x}")
logging.error(e)
# Exec cmd post run
logging.info("Execution ended")
virgin_value = int.from_bytes(uc.mem_read(0xFEDE0C03, 1), 'little')
logging.info(f" Virgin: {virgin_value:#03x}")
ptr = int.from_bytes(uc.mem_read(0xFFFF6630, 4), 'little') # Pointer to UDS response
logging.info(hexdump(uc.mem_read(ptr, 0x10)))
uc.emu_stop()
给出以下输出:
user@qb:~/RH850_fuzzing$ ./emulator_harness.py
[INFO] UDS payload: 22F2AA
[INFO] Emulating function RDBI
[INFO] Starting emulation @0x00018DAE to 0x00018EAE
[INFO] Execution ended
[INFO] Virgin: 0xFF
[INFO] 00000000 00 62 F2 AA FF 00 00 00 00 00 00 00 00 00 00 00 │·bòª│ÿ···│····│···│
独角兽和胡克船长
由于我们的基本模拟器测试工作正常,我们希望能够执行尽可能多的功能。
然而,在前面的例子中,只有 RAM
的一小部分被设置,导致应用程序在想要读取指针的值时出现许多错误,因为它们都没有被设置。我们还需要将一些校准数据设置到 RAM
中,比如 UDS 和 DID(由 ReadDataByIdentifier
使用的数据标识符)数据库,这些数据库由特定的 UDS 处理程序浏览到应用程序中。这些数据库是包含指向目标函数、触发条件(例如是否需要 SecurityAccess
、等待的输入长度等)和其他值的结构数组。
为了帮助我们修复我们的工具, Unicorn-engine
提供了有用的钩子,允许您在特定事件上触发回调:
UC_HOOK_INTR
:挂钩所有中断/系统调用事件
UC_HOOK_INSN
:挂钩特定指令(不支持所有指令)
UC_HOOK_CODE
:挂接一系列代码
UC_HOOK_BLOCK
:连接基本块
UC_HOOK_MEM_READ_UNMAPPED
:在未映射内存上进行内存读取的挂钩
UC_HOOK_MEM_WRITE_UNMAPPED
:用于无效内存写入事件的挂钩
UC_HOOK_MEM_FETCH_UNMAPPED
:用于执行事件的无效内存获取挂钩
UC_HOOK_MEM_READ_PROT
:用于读取受保护内存的内存读取挂钩
UC_HOOK_MEM_WRITE_PROT
:用于在写保护内存上进行内存写入的挂钩
UC_HOOK_MEM_FETCH_PROT
:用于在不可执行内存上进行内存获取的钩子
UC_HOOK_MEM_READ
:挂钩内存读取事件
UC_HOOK_MEM_WRITE
:挂钩内存写入事件
UC_HOOK_MEM_READ_AFTER
:挂钩内存读取事件,但仅成功访问
为了设置一个钩子,我们需要使用 Unicorn-engine
的 hook_add
函数。根据钩子的不同,回调将等待不同的参数。
例如,如果我们想要在内部 RAM
中的每次读取尝试上获得一些反馈,我们可以使用以下代码:
def mem_trace(uc, access, addr, size, value, user_data):
"""
mem_trace : basic hook to trace memory access (R/W)
:param uc: unicorn class
:param access: memory access type
:param addr: memory address
:param size: requested memory size
:param value: passed value for write request
:param user_data: custom data passed to the hook
"""
if access == 16 and addr >= RAM_ADDRESS:
logging.info(f"Read MEM error : {addr:#010x}")
logging.info(f" PC : {uc.reg_read(UC_RH850_REG_PC):#010x}")
logging.info(f" LP : {uc.reg_read(UC_RH850_REG_LP):#010x}")
# Set the following line before the `uc.emu_start` call
uc.hook_add(UC_HOOK_MEM_READ, mem_trace)
使用 UC_HOOK_CODE
,我们可以在我们的模拟器解析的每条指令上触发回调,从而可以跟踪执行路径:
def exec_trace(uc, address, size, user_data):
"""
exec_trace : callback to save reached addresses into a coverage file
:param uc: unicorn class
:param addr: value of PC
:param user_data: custom data passed to the hook
"""
global coverage_DB
if COVERAGE == True and address not in coverage_DB:
coverage_DB[address] = size
# Set the following line before the `uc.emu_start` call
uc.hook_add(UC_HOOK_CODE, exec_trace)
代码覆盖率
我们的最后一个钩子允许我们记录模拟器执行的每条指令的地址和长度。有了这些信息,我们可以生成一个覆盖文件,可以使用特定的扩展程序加载,比如 IDA 的 Lighthouse 或 Ghidra 的 Lightkeeper。
使用代码覆盖在逆向工程固件时非常有用,因为它可以让我们快速查看和理解执行路径、遗漏的条件以及许多其他内容。
为此,我们需要将我们记录的地址转换为适用于上述两个插件的兼容格式。在这次评估中,我们使用了 drcov
格式。
一个 drcov
文件的定义如下:
DRCOV VERSION: 2
DRCOV FLAVOR: drcov
然后,它提供了一个 Moduletable
,列出了所有已加载的模块,比如各种已编译的库。由于我们正在评估裸机固件,我们只有一个模块,即我们的固件。
Columns: id, base, end, entry, path
0, 0x00000000, 0x00177fff, 0x0000000000000000, appli.bin
各列如下:
id
:每个模块的增量值;
base
:模块的基地址
end
:模块的结束地址
path
:文件位置
最后, drcov
文件中有一个指令条目表,存储为一个结构,可以描述如下:
struct instruction_entry {
uint32_t address;
uint16_t size;
uint16_t id; // ID of the module where the instruction is executed
}
在我们的情况下,id 将始终为 0。
在指令表之前, drcov
文件头的最后一个条目指定存储的指令数量:
BB Table: 2036 bbs
例如,我们的模拟器生成的一个 drcov
文件可能是以下内容:
DRCOV VERSION: 2
DRCOV FLAVOR: drcov
Module Table: version 2, count 1
Columns: id, base, end, entry, path
0, 0x00000000, 0x00177fff, 0x0000000000000000, appli.bin
BB Table: 2036 bbs
<instruction entries in binary format>
为了在我们的 Python 脚本中生成一个覆盖文件,我们使用了以下代码:
DRCOV_HEAD = """DRCOV VERSION: 2
DRCOV FLAVOR: drcov
Module Table: version 2, count 1
Columns: id, base, end, entry, path
0, 0x00000000, 0x00177fff, 0x0000000000000000, appli.bin
BB Table: {X} bbs
"""
def save_coverage():
cov = DRCOV_HEAD.replace("{X}",str(len(coverageDB))).encode('utf-8')
for address in coverage_DB:
cov += int(address).to_bytes(4,'little')
cov += int(coverage_DB[address]).to_bytes(2,'little')
cov += int(0).to_bytes(2,'little')
with open("coverage/" + COVERAGE_FILENAME+".cov","wb") as coverage_file:
coverage_file.write(cov)
coverage_file.close()
回到 Virgin
状态的分析,如果我们模拟一个简单的 WriteDatabyIdentifier
服务来将这些数据从 0x00
设置为 0xFF
,并将生成的覆盖文件加载到 Ghidra 中,它会给我们以下结果:
在 Ghidra 上使用 Lightkeeper 进行代码覆盖率清单
一旦显示为函数图形,我们就能快速识别未触发的路径。
在 Ghidra 上使用 Lightkeeper 的功能图
有了这样的信息,我们可以调整我们的模拟器,以评估是否可能重置 Virgin
状态,这可能会导致 ECU 上的一个漏洞(剧透:制造商已经正确地完成了这项工作)。
不仅可以使用我们的 RH850 仿真器和 Unicorn-engine
生成代码覆盖率,而且我们还能够对提供的固件进行模糊处理,以自动化发现可能导致潜在漏洞的崩溃。
免责声明
由于传播、利用本公众号渗透安全团队所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,公众号渗透安全团队及作者不为此承担任何责任,一旦造成后果请自行承担!如有侵权烦请告知,我们会立即删除并致歉。谢谢!
知识星球
如果你是一个车联网攻防的长期主义者,欢迎加入我的知识星球,我们一起往前走,每日都会更新,精细化运营,微信识别二维码付费即可加入,如不满意,72 小时内可在 App 内无条件自助退款。