Wednesday, November 10, 2021

ARMored CoreSight: Towards Efficient Binary-only Fuzzing

Japanese version is here.

Written by:

Introduction

At Ricerca Security, we are currently working on fuzzing and its development. Today, we released the AFL++ CoreSight mode in public as a part of our study. This project adds a new feedback mechanism using CoreSight (a CPU feature available on some ARM-based processors) to the standard fuzzing tool, AFL++.

Generally, fuzzing without source code (binary-only fuzzing) requires instrumentation to target binaries. The AFL++ CoreSight mode enables more performant binary-only fuzzing by employing CoreSight for instrumentation.

The AFL++ CoreSight mode is available on:

In this blog post, we describe the AFL++ CoreSight mode and background.

Binary-only Fuzzing on ARM

Coverage-guided fuzzing is the most common method used by industry-standard fuzzing tools such as American Fuzzy Lop (AFL) and its well-known fork AFL++. It searches for inputs given to the program under test (PUT) to find new branch transitions using code coverage as feedback.

If the PUT source code is available, fuzzing tools can instrument the code for feedback generation at the build time. In the case where the source code is not available (binary-only fuzzing), they require a binary instrumentation tool or binary rewriting tool to instrument the target binaries. Although we can obtain better results than the binary-only fuzzing if the source code is available, binary-only fuzzing is more practical because most proprietary software or firmware do not provide the source code.

Instrumentations for binary-only fuzzing are categorized into static and dynamic instrumentation as shown in Figure 1. Static instrumentations statically rewrite target binaries to patch PUTs using static rewriting tools, such as e9patch. Dynamic instrumentation inserts the code for generating feedback into the PUTs at run time. One example of this instrumentation type is the de facto standard binary-only fuzzing tool AFL++ QEMU mode. Static instrumentation does not support the ARM architecture, and dynamic instrumentation has a performance issue. Therefore, we have ended up relying on slow QEMU-based instrumentation for ARM binary-only fuzzing.

Figure 1. Static and dynamic types of fuzzing instrumentations
Figure 1. Static and dynamic types of fuzzing instrumentations

We focus on the CPU feature CoreSight implemented on some ARM-based system-on-chips (SoCs) to address this limitation. CoreSight captures the executions of branch instructions at runtime. Intel processors have a similar CPU feature known as Intel Processor Trace (Intel PT). Several existing studies, such as kAFL, employ Intel PT for x86 binary-only fuzzing. We compared both the features to adopt the lessons learned from the Intel PT-based methods to the new CoreSight-based method. Figure 2 illustrates the QEMU and CoreSight modes. Both the modes instrument the PUTs at runtime. The QEMU mode executes a PUT on the VM and generates feedback at every edge of the basic blocks synchronously (Figure 2 (b-1)). On the contrary, the CoreSight mode executes PUTs on the native CPU and generates feedback asynchronously (Figure 2 (b-2)). Therefore, it performs better than the QEMU mode by nature.

Figure 2. QEMU and CoreSight modes
Figure 2. QEMU and CoreSight modes

Introduction to CoreSight

Some modern CPUs have a feature that traces processor behavior at runtime for debugging or performance monitoring purposes. Both Intel PT and ARM CoreSight have sufficient features to provide trace data, such as branch information for fuzzing feedback generation. CoreSight differs from Intel PT in some respects. Its specification defines more functions than the Intel PT, and it is provided as a set of multiple hardware components, while the Intel processor integrates the Intel PT as a single function.

The CoreSight implementations differ from SoC to SoC because the vendor configures the CoreSight hardware components when designing the processor. The system consists of three types of components: trace source, trace sink, and trace link. A trace source is a trace generator, and a trace sink stores or transfers traces. A trace link connects trace sources and trace sinks. In CoreSight, a set of bits known as a packet represents a trace, and a series of packets is referred to as trace data. To reconstruct the program behaviors from the trace data, we have to decode the packets in the trace data.

For more details of the CoreSight, please refer to the following ARM official CoreSight introduction:

To summarize, we need a tracer that controls the components and a decoder for the given trace data to generate feedback from the CoreSight.

AFL++ CoreSight mode

We developed a new tracer coresight-trace and decoder coresight-decoder for the AFL++ CoreSight mode. A characteristic of the tracer is that it runs entirely on user-space and does not depend on the Linux kernel CoreSight device drivers. Based on this design, even if the Linux kernel on the system cannot recognize the CoreSight devices because of the lack of device tree registration, the tracer can communicate with the CoreSight components to retrieve the traces. The decoder’s highlighted features include the following: 1) it supports the path coverage scheme proposed in the PTrix for Intel PT and the traditional AFL-style edge coverage; 2) it aims for high performance by providing a multi-layer cache mechanism and by supporting concurrent execution with the tracer. The decoder design is based on the best practice in Intel PT, because the decoding tends to be the bottleneck.

The tracer and decoder are available on:

The following chapters describe the technical issues we solved to develop a CoreSight-based fuzzing feedback in tracer and decoder.

Tracer: coresight-trace

A tracer is responsible for retrieving the traces required to generate feedback by monitoring a PUT and by manipulating the CoreSight components. It controls the tracing of the PUT process as it starts and stops.

We implemented the tracer as a derivation of the afl-proxy proxy application. As indicated in the original AFL, calling many exec() for fuzzing PUTs decreases the performance due to the overhead of the system call. Thus, we also implemented a fork server mode to the tracer in the same manner as the PTrix implementation.

Figure 3 shows the workflow of the AFL++ CoreSight mode, which comprises the AFL fuzzer, proxy application, and PUT. The proxy application consists of a tracer and decoder, and they asynchronously run on independent threads.

Figure 3. AFL++ CoreSight mode workflow
Figure 3. AFL++ CoreSight mode workflow


We addressed some of the technical issues caused by the CoreSight specifications. @retrage describes our approaches in the following sections.

Filtering Trace

To obtain the appropriate trace data, a filter must be set up, such that CoreSight outputs the PUT process-related trace data. PID filtering is a well-known approach that compares the PID of the target process to the CONTEXTIDR register, in which the OS sets the PID of the running processes to the register. However, this method requires reconfiguring the filter for every fuzzing loop because the PID to be traced changes every fork and decreases the performance. We noticed that the address layout of each forked child process did not change in the fork server mode that we implemented. Therefore, to avoid this issue, we configure the filter at the beginning so that CoreSight outputs trace data only within the address range of the PUT.

Embedded Trace Router for Storing Traces in DRAM

As mentioned earlier, CoreSight uses a component known as the trace sink to store or transfer the trace data. Because a typical fuzzing algorithm traces everything from the start to the end of the PUT process, a trace sink should have a sufficient buffer capacity. In addition, because fuzzing requires high performance, the transfer speed of the traces is essential. However, inexpensive SoCs may not have a trace sink with sufficient capacity and transfer speed. Figure 4 shows a CoreSight configuration typically used for inexpensive SoCs.

Figure 4 is a CoreSight configuration typically used for inexpensive SoCs.

Figure 4. CoreSight configuration storing trace data in ETB (based on ID010111 Revision r0p1 Figure 1-4)
Figure 4. CoreSight configuration storing trace data in ETB (based on ID010111 Revision r0p1 Figure 1-4)


A trace sink known as an embedded trace buffer (ETB) stores the trace data to its SRAM in this configuration. The trace data from each trace source are aggregated by a trace link known as Funnel and are transferred to the ETB. The ETB has an advantage in its low latency; nonetheless, the buffer size is small for fuzzing. For instance, tracing libxml2 generates approximately 450 KB of trace data, whereas ETB only has a buffer of 4 KB to 64 KB, which is not large enough to store the entire trace. Fuzzing with such a small buffer requires that the PUT is suspended periodically to save traces before it overflows. However, because the size of a target PUT increases, the frequency of the interruption increases, which deteriorates the performance.

To avoid this problem, we focused on a trace sink known as the embedded trace router (ETR). Some high-end SoCs have this CoreSight component which stores trace data in DRAM like Intel PT. The ETR dynamically allocates buffers up to 4 GB from the DRAM, whereas the ETB has a fixed SRAM buffer capacity. Figure 5 shows an example of the CoreSight configuration with the ETR.

Figure 5. CoreSight configuration stores the trace data in the DRAM using ETR via ETF (based on ID010111 Revision r0p1 Figure 1-8)
Figure 5. CoreSight configuration stores the trace data in the DRAM using ETR via ETF (based on ID010111 Revision r0p1 Figure 1-8)


Because the ETR uses DRAM to store the trace data, it may have latency when other high-priority DRAM accesses are requested. Figure 5 configuration uses the embedded trace FIFO (ETF) as a FIFO buffer to avoid trace loss if a delay occurs. The ETF transfers the trace data to the ETR via a trace link known as the Replicator. Although the ETR has latency compared to the ETB, our preliminary experiment on Jetson TX2 reveals that the ETR achieves higher throughput than the ETB as shown below. (The ETB in this table is strictly an ETF; nonetheless, the ETF can also be used as an ETB by setting the mode described in the next section.)

Size (KB) Read (MB/sec)
ETB 32 23.3
ETR 1024 65.8

Due to these CoreSight hardware limitations, our coresight–trace requires configuration with ETR as shown in Figure 5.

Handling Overflow Packets

Trace sinks have several different role modes. For example, the circular buffer mode uses a trace sink as a ring buffer to read the user. In this mode, the overflow status register bit is set when the buffer is full, making it easy to detect trace data loss at the runtime.

If a trace sink is used as a FIFO buffer such as the ETF shown in Figure 5, we set the trace sink to the hardware FIFO mode. The trace sink will notify behind the components when the buffer is about to overflow; we refer to this behavior as back pressure. When this occurs, the trace source sends an overflow packet and waits for the trace sink until the overflow is resolved. Figure 6 shows the behavior of the ETF when back pressure occurs.

Figure 6. ETF Behavior on back pressure
Figure 6. ETF Behavior on back pressure


Because the CPU continues to run while the trace source is suspended, the trace may be missing. Therefore, trace data containing overflow packets are incomplete and should be discarded. However, we have seen overflow packets at high frequencies during our fuzzing experiment, and discarding such trace data decreased the fuzzing performance. To overcome this, we made a simple assumption on the trace data. Because the period during which tracing is stopped is relatively short, the number of missing traces is negligible. Moreover, because the undiscovered execution paths are rarer than the known ones, all the missing traces are known execution paths with high probability. Therefore, it is unlikely that the missing traces will prevent the fuzzer from finding a new execution path. Thus, we decided to decode the trace data based on this assumption, even if an overflow packet was detected. Although this approach contradicts the CoreSight usage guidelines, we experimentally confirmed that this method could find new execution paths.

By adopting the PTrix-based architecture, trace filtering, use of ETR, and handling of overflow packets, we developed a tracer that takes full advantage of CoreSight.

Decoder: coresight-decoder

A decoder is responsible for generating feedback from the trace data for fuzzing.

Because a decoder can be a bottleneck in fuzzing with hardware tracers, it is necessary to use some techniques for better efficiency. The following sections were written by @mmxsrup.

Decoder Overview

The purpose of a decoder is to recover the execution history from the trace data and compute the coverage used as feedback for fuzzing. Hardware tracers such as CoreSight and Intel PT do not store complete execution history, and they encode only partial information in the form of packets. This design improves the trace performance; however, it cannot recover the execution history using only the trace data. For example, suppose a CPU executes an indirect branch instruction. In this case, the trace data will only contain information about whether the branch occurred at that point, not the branch destination address. The decoder must disassemble the PUT binary to obtain the branch destination address.

Figure 7 illustrates the decoding procedure for the assembly and trace data. The code consists of a loop starting at 0x71c and a function that calls the loop starting at 0x900. The trace data contains address and atom packets that represent the branch information. An address packet is a packet that records the address of the indirect branch instruction branches. An atom packet represents the result of the direct branch instruction. The E bit represents that the branch was taken, and the N bit represents that the branch was not taken. In addition, an atom packet may contain multiple branches to compress packets for efficiency. For example, EEEN indicates that it branches three times in a row and does not branch the fourth time.

The first address packet is the starting address of the trace, which indicates that the execution starts at address 0x0000aaaaceaa0900. Because the address in the packet is a physical address, it needs a memory map for the program to know where the binary is on the memory. By calculating the actual location from the memory map, it was found that the execution started at assembly code 0x900. The second packet is an atom packet, indicating a direct branch execution. There is a direct branch bl at 0x908 if you observe the code from the forward direction. This indicates that the atom packet shows that it branches at the branch instruction, and the control flow changes to 0x71c. The third packet is an atom packet that indicates branches three times and does not branch at the fourth time. We find a direct branch instruction b.ne at 0x730, which conditionally jumps to 0x72c by observing from the 0x71c forward direction. This packet shows that the instruction branches three times and jumps to 0x71c. The last N bit indicates that it was not branched at the fourth time and continued to 0x734. The fourth packet is an atom packet, corresponding to an indirect branch ret that is branched. A branch destination address cannot be determined solely from the atom packet and assembly code for indirect branch instruction. Therefore, a subsequent address packet must be generated just after the atom packet for the indirect branch, where the indirect branch instruction branches to 0x0000aaaaceaa090c, and the control flow returns to 0x90c.

Figure 7. Decoder behavior
Figure 7. Decoder behavior


Because an address in an address packet is a physical address, it can be changed for each fuzzing campaign, even if they have the same control flow. Therefore, an offset on the ELF file is used instead of the physical address to compute the coverage. In addition, to support more than one program binary to trace, binaries are identified using unique IDs.

There is a slight difference in the decoding process between Intel PT and CoreSight. CoreSight atom packets correspond to Intel PT TNT packets, and the address packets correspond to TIP packets. The decoder preprocesses the raw trace data to retrieve only the trace data needed to generate feedback before starting the decoding because the raw trace data include traces from all CPU cores.

Optimizing the Decoder

In the previous section, we showed how to restore the edge coverage from the CoreSight trace data. Fuzzing runs a PUT repeatedly, which indicates that the decoding of trace data occurs several times. Therefore, decoding processes can be bottlenecks for fuzzing with hardware tracers. Unfortunately, our preliminary experiments show that the simple decoding technique described above is impractical, considering the performance. As such, we introduced concurrent decoding and two software cache layers to achieve a better decoding performance.

Concurrent decoding reduces the latency of a single fuzzing cycle during the runtime between tracing and decoding. If a decoder starts the process after all the tracing is complete, the latency of the fuzzing cycle increases. Therefore, we made it possible to start and resume at each partial completion of the tracing process. This indicates that the tracer and decoder can perform concurrently. This design reduces the runtime from the start of the tracing process to the completion of the decoding process; thereby, improving the efficiency of the entire fuzzing process. Therefore, we implemented a decoder to decode the fragmented trace data.

The cache layers record the results generated during a fuzzing cycle for the decoder to reuse them. A fuzzer computes edge coverages by decoding the trace data multiple times for the same PUT binary. Because the coverage obtained for each fuzzing cycle is often almost identical, the piece of the binary to be disassembled in each fuzzing cycle and the trace data are approximately identical. Additionally, the programs that contain loops disassemble the same code multiple times. This indicates that fuzzing often consists of repeated works on a PUT, and caching the results of such work contributes to more efficient decoding.

Moreover, libxdc is a decoder for Intel PT that uses software caching for optimized decoding. In our decoder, we introduce two cache layers that are similar to libxdc. However, the libxdc codebase contains many unnecessary parts; therefore, we have developed a decoder from scratch while referring to libxdc.

The first cache layer stores the results of the program binary disassembly to avoid redundant disassembly processes. As illustrated in the previous section, an atom packet representing the conditional branch does not have a branch destination address. Therefore, a decoder must disassemble the program binary, find the branch instruction, and parse the branch destination address. This sequence of tasks for the same code is performed several times during the fuzzing cycle; therefore, the results of this sequence of tasks can be cached to eliminate unnecessary disassembly. This cache uses a hash table, where the key is the starting address, and the data is the destination address of the branch instruction closest to the starting address.

The second cache layer stores the edge coverage calculation results to eliminate redundant atom packet parsing. As explained in the previous section, an atom packet may contain multiple branch records. Regarding this case, the decoder must parse each branch address from the EN bits stored in the Atom packet to compute the edge coverage. Because the trace data are often the same, this sequence of operations is performed many times during the fuzzing cycle. If the second cache layer does not exist, the decoder needs to compute the same result using the data stored in the hash table to restore the edge coverage. Therefore, the calculated edge coverage is cached to eliminate unnecessary atom packet parsing. This cache uses a hash table, where the key is the starting address and the atom packet and data are the results of the calculated edge coverage.

Figure 8 shows how these two caches work. In this example, the cache stores the decoding results described in Figure 7. It decodes the packets shown in Figure 8 from the second fuzzing campaign. (1) The E Atom bit is in a trace starting from 0x900, and the decoder searches for the address’s edge coverage from the first-stage cache. (2) The cache hits and finds the edge coverage. (3) The set of EEEN atom bits is in a trace starting from 0x71c, and it looks for the edge coverage at the address from the first-stage cache. (4) The cache hits and finds the edge coverage. (5) The E atom bit is in a trace starting from 0x900, and the decoder looks for the address’s edge coverage from the first-stage cache. (6) The cache misses. The decoder searches for the branch destination address of the first branch instruction after 0x71c in the second-stage cache. (7) The cache hits, and the destination address is 0x72c, where the branch is taken, and it computes the edge coverage. This shows that even if the traces follow the same control flow, the trace data do not always match because of external factors such as interruptions. In such cases, the second-stage cache is valuable. (8) The first-stage cache is updated to add the decoding result. (9) The E atom bit is in the trace starting from 0x750, and it searches for the edge coverage at the address in the first-stage cache. (10) Because of the cache miss, it searches for the branch destination address of the first branch instruction after 0x750 in the second-stage cache. (11) Because the second-stage cache misses, the decoder disassembles the target binary and searches for branch instructions from 0x750 in the forward destination. (12) It finds the branch instruction b.ne 780, specifies the branch destination address, and calculates the edge coverage. (13) Update the cache by adding the decoding results to the first and second caches.

Figure 8. Decoder behavior with two caches
Figure 8. Decoder behavior with two caches


By applying the best practice of the Intel PT decoders to CoreSight, we developed an optimized CoreSight decoder for fuzzing.

Design and Implementation of PTrix-style Path Coverage

This section describes the design and implementation of PTrix-style path coverage for CoreSight.

In the PTrix paper, they point out that it is inefficient to generate AFL edge coverages from Intel PT trace data because it requires disassembling the PUT binaries as already mentioned in the previous section. They proposed a method that directly generates fuzzing feedback from the trace data without disassembling the binaries to solve this issue. This method computes a hash from the TNT and TIP packets and encodes the resulting hash to update the bitmap. Because the hash value represents the history of the trace packets, it can be used as a path coverage. Their experiments showed that the coverage representation captured better execution paths than the AFL edge coverage.

We implemented this PTrix-style path coverage by making the decoder compute the hash using atom and address packets. The decoder converts EN bits of the atom packets to a “0” and “1” to optimize the hash calculation for better performance.

Benchmark

How efficient are the many optimizations introduced in previous chapters? We conducted a benchmark to measure execution performance.

We chose four fuzzers for the benchmark: AFL++ LTO (instrumentation at build time), AFL++ QEMU, AFL++ CoreSight (edge coverage), and AFL++ CoreSight (path coverage) modes. The target PUT is a simple application void main(void) {}, and we measured the number of executions in 60 s. The PUT is a simple application to ensure that the fuzzers are not affected by the nature of the paths.

# of exec exec/sec
LTO mode 169169 2819.5
QEMU mode 64307 1071.8
CoreSight mode (Edge cov.) 96490 1608.2
CoreSight mode (Path cov.) 99744 1662.4

The results show that both CoreSight modes outperform the QEMU mode. The result of the PTrix-style path coverage mode is slightly higher than that of the edge coverage mode, and this result is consistent with that shown in the PTrix paper. Unfortunately, they cannot outperform the source code instrumented fuzzing because of the CoreSight operation-related overhead. However, we believe that this CoreSight-based binary-only fuzzing method is quite promising because the source code for ARM binaries, especially firmware, is unavailable in most practical situations.

Efficiency of the Path Coverage

We ran the four fuzzers described in the previous section with the PUT shown below to assess the efficiency of the path coverage mode. We measured the number of executions and generated seeds until the first crash occurred. This PUT crashes only if the first ten bytes of the input match aaaaabbbbb. A fuzzer feedback must be path-sensitive for reaching a crash. We performed this PUT based on the toy program from PTrix implementation.

  read(0, src, SIZE);

  for (int i = 0; i < SIZE; i++) {
    if (src[i] == 'a') {
      dst[i] = src[i];
      continue;
    }
    if (src[i] == 'b') {
      dst[i] = src[i];
      continue;
    }
    break;
  }

  if (!strncmp(dst, "aaaaabbbbb", 10)) {
    crash(0);
  }

# of exec # of seeds
LTO mode 153260 11
QEMU mode 144645 12
CoreSight mode (Edge cov.) 146844 16
CoreSight mode (Path cov.) 135308 98

The results show that the CoreSight path coverage mode reaches a crash with the smallest executions in the fuzzers. In addition, the CoreSight path coverage mode generated more seeds than the other fuzzers. This indicates that the path coverage mode is more path-sensitive than the edge coverage modes, as intended.

Conclusions

We introduced our work on binary-only fuzzing using CoreSight. We demonstrated the availability of efficient binary-only fuzzing for ARM, which forms a part of our research on fuzzing. We hope you are safe and stay tuned for more.

Acknowledgements

This project has received funding from the Acquisition, Technology & Logistics Agency (ATLA) under the Innovative Science and Technology Initiative for Security 2021 (JPJ004596). We would like to thank Kaoru Otsuka for the prototype development of this system.

ARM CoreSightを用いた効率的なBinary-only Fuzzing

英語版はこちら: ARMored CoreSight: Towards Efficient Binary-only Fuzzing

著者:

はじめに

わたしたちRicerca Securityはファジングの研究開発に取り組んでいます。このたび、その一環として開発したAFL++ CoreSight modeをOSSとして公開しました。これは、ファジングツールのデファクトスタンダードであるAFL++に対して、CoreSightという一部のARMプロセッサで有効なCPU機能を活用したフィードバック機構を追加したものです。

ファジングとは、プログラムの入力に変異を施し、その脆弱性を自動的に発見する技術です。一般に、プログラムのソースコードが手元にない場合のファジング (Binary-only Fuzzing) では、QEMUなどによるバイナリへのinstrumentationを通じてプログラムの実行をトレースする必要があります。今回開発した手法では、QEMUの代わりにCoreSightをinstrumentationに用いることで、より高速なBinary-only Fuzzingを実現できました。

AFL++ CoreSight modeは以下のリポジトリで公開しています。

本記事では、AFL++ CoreSight modeの概要と開発の経緯を紹介します。

ARMにおけるBinary-only Fuzzing

ファジングツールの代表例として知られるAFLや、その高機能版forkであるAFL++は、Coverage-Guided Fuzzingと呼ばれる手法を用いています。これはファジング対象Program Under Test (PUT) 実行時にカバレッジをフィードバックとして取得し、新しい分岐に遷移してカバレッジが拡大するような入力を探索する手法です。

PUTのソースコードが手元にある場合、ファジングツールはそのビルド時にフィードバック取得用のコードをinstrumentします。一方で、PUTのソースコードがなくバイナリのみが手元にある場合、すなわちBinary-only Fuzzingでは、ファジングツールはbinary instrumentation toolやbinary rewriting toolを用いたinstrumentationを行います。Binary-only Fuzzingよりもソースコードありのファジングの方が効率的ですが、プロプライエタリソフトウェアや、特にファームウェアの解析においてはBinary-only Fuzzingが前提となってくることが多いです。

さて、Binary-only Fuzzingにおけるinstrumentationの方式には、図1のように静的な方式と動的な方式の2種類が存在します。前者は、e9patchなどを用いてPUTのバイナリにパッチを当て、静的に書き換える方式です (図1 (a))。後者は、QEMUやDynamoRIOなどを用いてPUTのバイナリの実行時にフィードバック取得用のコードをinstrumentationする方式です(図1 (b))。Binary-only Fuzzingのデファクトスタンダードとして知られるAFL++ QEMU modeもこの方式に該当します。残念ながら、前者の方式はARMバイナリに対応しておらず、後者の方式は低速であるという問題があります。そのため、これまでARMにおけるBinary-only FuzzingはQEMUによる低速なinstrumentationに頼らざるを得ませんでした。

図1. Fuzzingのinstrumentationにおいて静的な方式と動的な方式
図1. Fuzzingのinstrumentationにおいて静的な方式と動的な方式

わたしたちは、この制限を打破する手がかりとして、ARMベースのSystem on Chip (SoC) の一部で実装されているCoreSightというCPU機能に着目しました。CoreSightは分岐命令の実行を捕捉する機能であり、QEMUなどの代替として利用できると考えたのです。すでに、Intelプロセッサ版のCoreSightともいえるIntel Processor Trace (Intel PT) は、kAFLをはじめとしてBinary-only Fuzzingに活用されています。そこで、わたしたちはIntel PTとARM CoreSightの差分を整理し、前者を活用した手法の優れた点をCoreSight向けに取り入れることをめざしました。図2に示すように、CoreSightを用いたinstrumentationはQEMUを用いた手法同様に、実行時にinstrumentationを行います。QEMUを用いた手法 (図2 (b-1)) ではPUTをVM上で実行し、フィードバックの生成を各ベーシックブロックの遷移時に同期的に実行するため低速ですが、CoreSightを用いた手法 (図2 (b-2)) ではPUTはCPU上でネイティブに実行され、フィードバックの生成は非同期に行われるため、QEMUを用いた手法よりも高速です。

図2. QEMU modeとCoreSight mode
図2. QEMU modeとCoreSight mode

CoreSight概説

CoreSightやIntel PTは、デバッグやパフォーマンス計測を目的とした、CPUの挙動を実行時にトレースするしくみです。両者はいずれもファジングに必要な分岐先情報などのフィードバックを得るのに十分な機能を備えています。しかし、CoreSightはIntel PTと比較して仕様上定義されている機能が豊富である点、Intel PTでは機能全体が統合されて1つの機能として提供されているものの、CoreSightは複数種類のハードウェアコンポーネントの組み合わせとして提供されている点が異なります。

CoreSightのコンポーネント構成はSoCの設計時に決定され、どのようなコンポーネントがどのように接続されているかはSoCによって大きく異なります。ただし、共通的なコンポーネントとしてTrace Source、Trace Sink、Trace Linkの3種類が存在します。Trace Sourceはトレースの生成元となるコンポーネントであり、Trace Sinkはトレースを保存または転送するコンポーネントです。Trace LinkはTrace SourceとTrace Sinkを仲介するコンポーネントです。CoreSightではトレースをパケットと呼ばれるビット列で表現し、一連のパケットをまとめてトレースデータと呼称します。トレースデータからプログラムの挙動を復元するには、各パケットをデコードする必要があります。

より詳細なCoreSightの紹介は、以下のようなARM公式ドキュメントを参照してください。

かいつまんでまとめると、CoreSightからフィードバックを得るには、その各コンポーネントを制御するトレーサと、トレーサが生成したパケットのデコーダを実装しなければなりません。

AFL++ CoreSight mode

今回のAFL++ CoreSight modeでは、トレーサとデコーダをそれぞれcoresight-traceとcoresight-decoderというsubmoduleとして新たに開発しました。coresight-traceの特徴は、CoreSightの操作を含むすべての動作をユーザー空間で行っており、LinuxカーネルのCoreSightデバイスドライバに依存していないことです。これにより、Device Treeなどにコンポーネントが登録されておらずLinuxカーネルがCoreSightを認識できないシステムでもトレースを取得可能となっています。 coresight-decoderの特徴は、フィードバックにAFLが用いるカバレッジ表現であるエッジカバレッジと、PTrixというIntel PT向けのカバレッジ圧縮手法を転用したパスカバレッジの2種類が選択できること、複数のキャッシュ機構を備えトレーサと並列に動作し高速化を図っていることです。ハードウェアトレーサを使ったファジングではデコード処理がボトルネックになるため、Intel PTにおけるプラクティスを踏まえた形です。

トレーサとデコーダは以下のリポジトリで公開しています。

以降の章ではCoreSightをファジングに利用する上で解決した技術的課題について、トレーサとデコーダの2つのコンポーネントの観点から解説します。

トレーサ

トレーサはPUTプロセスを監視してフィードバックに必要なトレースを過不足なく取得できるようにCoreSightコンポーネントを制御する役割を担います。トレーサはPUTプロセスが作られるとトレースを開始、終了したらトレースを停止・取得してデコーダにそのデコードを依頼します。

今回開発したトレーサは、実体としてはafl-proxyをベースとしたプロキシアプリケーションとして実装されています。オリジナルのAFLで指摘されているように、PUTの実行に際して大量のfork+execを行うとexecのオーバーヘッドによるパフォーマンス低下が知られています。このため、PTrixの実装と同様にexecを回避するfork server modeを実装しています。

AFL++ CoreSight modeの動作フローを図3に示します。このモードはAFLファザー、プロキシアプリケーション、PUTから構成されます。プロキシアプリケーションはトレーサとデコーダから構成されており、トレーサとデコーダは独立したスレッドで非同期にトレースとデコードをします。

図3. AFL++ CoreSight modeの動作フロー
図3. AFL++ CoreSight modeの動作フロー


トレーサを実装する過程で、CoreSightの仕様に起因する数々の技術的課題にアプローチする必要がありました。本章の以降の節では@retrageがその工夫を解説します。

トレースのフィルタリング

フィードバックの生成に必要なトレースを得るにあたって、PUTプロセスのトレースのみを出力するためのフィルタを設定する必要があります。広く知られている方法は、CONTEXTIDRレジスタに設定されるPIDの値を比較して対象のPIDのプロセスのみフィルタする方法です。しかし、ファジングループのたびにPIDが変動することから、この方法ではそれに応じてその都度フィルタを再設定する必要があるため、結果としてパフォーマンスの低下を招きます。今回実装したfork server modeでは、fork先のPUTのプロセスのアドレスレイアウトは変化しないことに注目し、初回に単一のアドレス範囲のみをフィルタに設定することでこの問題を回避しています。

Embedded Trace Routerを使ったDRAMへのトレースの保存

先に説明したようにCoreSightではTrace Sinkという種類のコンポーネントがトレースデータを保存または転送します。典型的なアプリケーションのファジングでは、ひとつのプロセスの開始から終了までのすべてをトレースするため、Trace Sinkは十分な容量のバッファを持っていることが望ましいです。加えて、ファジングでは高速に動作することが求められるため、トレースデータの転送速度も重要です。しかし、CoreSightではどのようなTrace SinkがあるかはSoCによって大きく異なり、安価なSoCでは十分な容量と転送速度を持ったTrace Sinkを持っていない場合があります。 比較的安価なSoCに多いCoreSightの構成例を図4に示します。

図4. ETBにトレースを保存するCoreSightの構成例 (ID010111 Revision r0p1 Figure 1-4をもとに作成)
図4. ETBにトレースを保存するCoreSightの構成例 (ID010111 Revision r0p1 Figure 1-4をもとに作成)


この例ではEmbedded Trace Buffer (ETB) というTrace Sinkが持つSRAMにトレースを保存します。複数の各Trace Sourceが出力したトレースはFunnelと呼ばれるTrace Linkで統合されてからETBに送られます。ETBは専用のSRAMをバッファとするため低遅延という利点がありますが、バッファの容量が小さいという欠点があります。たとえば、libxml2の実行では450KB程度のトレースが生成されるのに対し、ETBのバッファは4KBから64KB程度しかなく、トレースを記録するのに十分な容量がありません。このような環境でのファジングでは、定期的にPUTを一時停止させてトレースを退避する必要が生じます。しかし、PUTの規模が大きくなるとともにトレース退避の頻度は高くなるため、ETBでは実用的な実行速度を得ることができません。

そこで、一部のSoCで実装されているEmbedded Trace Router (ETR) を利用します。ETRはIntel PTのようにDRAMにトレースを保存するTrace Sinkです。ETBではSRAMバッファの容量は固定である一方、ETRは最大4GBまで動的にDRAMの領域を確保します。 ETRを備えたCoreSightの構成例を図5に示します。

図5. ETFを経由してETRでDRAMにトレースを保存するCoreSightの構成例 (ID010111 Revision r0p1 Figure 1-8をもとに作成)
図5. ETFを経由してETRでDRAMにトレースを保存するCoreSightの構成例 (ID010111 Revision r0p1 Figure 1-8をもとに作成)


ETRはDRAMを使うため大容量である一方で、ほかの優先度の高いDRAMへのアクセスがあった場合には遅延が発生するという欠点があります。そこでEmbedded Trace FIFO (ETF) をFIFOバッファとして利用することで遅延発生時にトレースが失われないように構成されています。この例では、ETFはReplicatorと呼ばれるTrace Linkを介してETRに転送します。ETRはETBと比較して遅延があるものの、次に示すようにJetson TX2を使った簡単な実験ではETRを使った方が高スループットである、ということが判明しています(ここでのETBは厳密にはETFですが、ETFは後述するモード指定によってETBとして利用できるためこのような表示としています)。

Size (KB) Read (MB/sec)
ETB 32 23.3
ETR 1024 65.8

今回開発したcoresight-traceはこのようなCoreSightのハードウェア上の制約から、現在は図5のような構成のシステムを前提としています。

Overflow Packetの扱い

Trace Sinkには複数の動作モードがあり、どのように使うかによって指定するモードが異なります。ユーザーのトレース読み出し先のTrace SinkはCircular Buffer modeというリングバッファとして使うモードを指定します。このモードではバッファが溢れるとオーバーフローを示すステータスレジスタのビットが立つため、実行時に容易にトレースデータの欠損が検知できます。

一方、図5のETFのようにTrace SinkをFIFOバッファとして使う場合、Trace SinkをHardware FIFO modeというモードに設定します。このような中間にあるTrace Sinkはバッファが溢れそうになると後方に配置されているコンポーネントに対してその旨を通知します。これをBack Pressureといい、Back Pressureが通知されるとTrace SourceはOverflow Packetという特別なパケットを送信してからオーバーフローが解消されるまで待機します。ETFにおいてBack Pressure発生時の挙動を図6に示します。

図6. ETFにおけるBack Pressure発生時の動作
図6. ETFにおけるBack Pressure発生時の動作


パケットの送信が中断されている間もCPUの実行は続行しているため、トレースは欠損している可能性があります。このため、Overflow Packetを含むトレースデータは厳密には不完全であるため破棄すべきであるとされています。しかし、実験では高い頻度でOverflow Packetが見られ、厳密にOverflow Packetを含むトレースを破棄して再実行する方法では現実的な実行速度を得られないことが判明しました。ここで、ファジングでは新しい実行パスを検出するまでに何回もPUTの実行を繰り返し、かつ生じた欠損はトレース全体で見た場合にごくわずかであることから、トレースが欠損した期間の実行は高い確率で既知の実行パスであり、欠損により新しい実行パスが検出できない可能性は低いと考えられます。そこで、この仮定のもとOverflow Packetが検出されてもトレースを破棄せずあえてそのままデコードする方法を採用することにしました。この方式はCoreSight利用上の指針には反するものの、この方法でも新しい実行パスを検出できることを実験的に確かめています。

以上、PTrix準拠のアーキテクチャ採用、トレースのフィルタリング、ETRの利用、Overflow Packetの扱いの工夫を通じて、CoreSightの利点を最大限に活用するトレーサを作り込みました。

デコーダ

デコーダはトレーサが取得したトレースデータを受け取り、ファジングのフィードバックを生成する役割を担います。今回開発したデコーダcoresight-decoderは、ライブラリとしてトレーサに提供されています。

デコーダはハードウェアトレーサを用いたファジングにおけるボトルネックとなりうる箇所であるため、高速化のための工夫を要します。本章の以降の節では@mmxsrupがその工夫を解説します。

デコーダ概説

デコーダの目的は、トレースデータから実行履歴を復元し、ファジングのフィードバックとして利用するカバレッジを計算することです。CoreSightやIntel PTなどのハードウェアトレーサは完全な実行履歴を保存せず、部分的な情報のみをパケットの形式でエンコードしてトレースバッファに保存します。これにより、トレースのパフォーマンスは向上するものの、トレースデータのみを利用してPUTの実行履歴を復元できなくなります。たとえば、間接分岐命令の実行が記録される場合、トレースデータにはその分岐命令で分岐したかどうかの情報しか記録されず、分岐先アドレスは記録されません。分岐先アドレスを得るには、デコーダからPUTのバイナリを解析する必要があります。

実際に、下の図7で示すアセンブリコードとそのアセンブリコードを含むプログラムバイナリのトレース結果をもとに、デコードの手順を説明します。以下のアセンブリコードは0x71cから始まるループ構造を含むコードと、0x900から始まるそれを呼び出すためのコードから構成されています。トレース結果には、分岐情報を示すパケットであるAddressパケットとAtomパケットが記録されています。Addressパケットは間接分岐で分岐した先のアドレスの情報を記録するパケットです。Atomパケットは直接分岐命令の結果を示すもので、その分岐で分岐した場合、E(taken)が記録され、分岐しなかった場合、N(not-taken)が記録されます。 また、パケットの圧縮効率などのために、1つのAtomパケットに複数の分岐情報が含まれることもあります。例えば、AtomのEEENは、3回連続で分岐し、4回目は分岐しなかったことを示します。

1番目に記録されているAddressパケットは、トレースの開始アドレスを示すものです。つまり、この例ではアドレス 0x0000aaaaceaa0900 から実行が開始されたことを示しています。Addressパケットが示すアドレス情報は実アドレスであるため、実際にプログラムバイナリのどこを実行しているか知るためには、テスト対象のプログラムのメモリマップ情報が必要になります。メモリマップ情報から実際の位置を計算すると、アセンブリコードの0x900から実行が開始されたことを特定できます。次に記録されているパケットはAtomパケットであり、分岐命令で分岐したことを示しています。アセンブリコードの 0x900 から順方向にコードを見ていくと、0x908 に直接分岐命令である bl があります。つまり、このAtomパケットはこの分岐命令で分岐し、0x71c に制御フローが変わったことを示しています。3つ目のパケットはAtomパケットであり、分岐命令で3回分岐し4回目に分岐しなかったことを示しています。アセンブリコードの 0x71c から順方向にコードを見ていくと、0x730 に直接分岐命令である b.ne があります。これはこの分岐命令による分岐が発生し、制御フローが 0x72c に変わったことを示してい、ます。同様にアセンブリコードの 0x71c から順方向にコードを見ていくと0x730b.ne があり、この命令で残り2回分岐し、最後に分岐せず制御フローが 0x734 に変わったことがわかります。続いて4つ目のパケットはAtomパケットであり、次の分岐で分岐したことを示しています。アセンブリコードの 0x734 から順方向にコードを見ていくと、0x73c に間接分岐命令である ret があり、この分岐命令で分岐したことを示しています。間接分岐命令の場合、Atomパケットとアセンブリコードの情報のみでは分岐先アドレスを特定できず、分岐先アドレスの情報を特定するために、後続に続くAddressパケットが使われます。このため間接分岐命令のトレースの場合、AtomとAddressパケットが出力されます。この例では、間接分岐命令で 0x0000aaaaceaa090c に分岐し、アセンブリファイルの 0x90c に制御が戻ったことを意味します。

図7. デコーダの動作
図7. デコーダの動作


Addressパケットに記録されているアドレスは実アドレスです。そのため、同じコントロールフローをたどる実行であっても、ファジングキャンペーン毎にAddressパケットの中身は変化することがありえます。そこで、エッジカバレッジの計算には、実アドレスではなくELFファイル上のオフセットを用いています。さらに、トレース対象のプログラムバイナリが複数ある場合、それぞれを区別するために、プログラムバイナリの識別子を用いています。

Intel PTとCoreSight間で、デコード手順の違いはほとんどありません。CoreSightのAtomパケットはIntel PTのTNTパケットに、AddressパケットはTIPパケットにそれぞれ対応します。また、CoreSightが生成するトレースデータは各コアから得たトレースデータを集約して記録します。そのため、上述のデコードの前処理として実際に必要なトレースのみをトレースデータから取り出す作業が必要です。

高速化

前節では、CoreSightのトレースデータからエッジカバレッジを復元するデコード手法を示しました。ファジングでは繰り返しPUTを実行します。つまり、トレースデータのデコードはPUTの実行回数分だけ発生します。よって、デコード処理にかかる時間がハードウェアトレースを用いたファジングのボトルネックとなりえます。残念ながら、予備実験を行ったところ、前述の単純なデコード手法はパフォーマンスの観点で実用に足らないことがわかりました。そこで、デコードの平行実行と、2つのソフトウェアキャッシュレイヤの導入により、さらなる高速化を図りました。

デコードの平行実行の目的は、トレースからデコードまでの一連の実行時間のレイテンシを小さくし、ファジングの1サイクルを高速化することです。トレース処理がすべて完了した後に、デコードの処理を開始すると、ファジングの1サイクルにかかる時間が長くなります。そこで、トレースの処理が一部終わるごとに、デコード処理を開始・再開できるようにしました。つまり、トレースとデコードを非同期に並列実行できるような工夫を設けました。これにより、トレース処理の開始からデコード処理の完了までにかかる実行時間が短くなり、ファジング全体の高速化が実現できます。断片的なトレースデータに対しても、デコード処理を実現できるようにしています。

キャッシュレイヤの目的は、ファジングサイクル中に反復的に行われる作業の結果を記録・再利用可能にし、デコード処理を高速化することです。ファジングでは、同じPUTのバイナリに対してトレースデータを複数回デコードし、エッジカバレッジを計算します。1回あたりのファジングサイクルごとに得られるカバレッジはほとんど同じであることが多いため、ファジングサイクルごとにディスアセンブルするプログラムバイナリの領域やトレースデータ自体はほぼ同一です。また、ループを含むようなプログラムの場合も同じ領域を複数回ディスアセンブルすることになります。要するに、ファジングではPUTを繰り返し実行する過程で同じ処理を繰り返すことが多いため、そうした作業結果をキャッシュすることで高速化が図れる、ということです。

実際に、Intel PT用のデコーダであるlibxdcも、ソフトウェアキャッシュを用いることで、デコード処理を高速化しています。本プロジェクトでも、libxdcと同様の方法で、2つのキャッシュレイヤを導入し、デコード処理を高速化しています。ただし、libxdcのコードベースはわたしたちにとって不要な箇所を多く含むことから、libxdcを参考にしつつもフルスクラッチでデコーダを開発しています。

1つ目のキャッシュレイヤはプログラムバイナリのディスアセンブル結果を保存し、無駄なプログラムバイナリの解析を省くために使われます。前節で例示したように、条件分岐のトレースを表すAtomパケットには分岐先のアドレスが記録されていません。そのため、デコーダはプログラムバイナリをディスアセンブル、分岐命令を発見し、分岐先のアドレスを解析する必要があります。同じプログラム領域に対するこの一連の作業は、ファジングサイクル中に何度も実行されます。そこで、この一連の作業の結果をキャッシュし、不必要なプログラムバイナリの解析処理を省いています。キャッシュはハッシュテーブルとして実装され、キーは開始アドレス、データはその開始アドレスから最も近くにある分岐命令の分岐先アドレスとしています。

2つ目のキャッシュレイヤはエッジカバレッジの計算結果を保存し、無駄なAtomパケットの解析を省くために使われます。前章で説明したように、1つのAtomパケットは複数の分岐履歴を含む場合があります。この場合、Atomパケットに保存されているEN列をもとに、それぞれの分岐先アドレスを解析し、エッジカバレッジを計算します。トレースデータ自体が同じである場合は多いため、この一連の作業はファジングサイクル中に何度も実行されます。仮にキャッシュレイヤ1があったとしても、ハッシュテーブルに保存されているデータを使いながら、エッジカバレッジを復元する作業を繰り返し行う必要があります。そこで、この一連の作業の結果をキャッシュし、不必要なAtomパケットの解析を省いています。キャッシュはハッシュテーブルとして実装され、キーは開始アドレスとAtomパケットで、データはエッジカバレッジの計算結果です。

実際に、この2つのキャッシュの動作について、下の図8を用いて説明します。この例では先程説明した単純なデコードした結果がキャッシュに保存されており、2回目のファジングキャンペーンで図8に示すパケットが得られた場合に、どのようにデコードが行われるかを示しています。① 0x900 から開始するトレースでAtomのEが記録されている場合に、その時のエッジカバレッジが1段階目のキャッシュに保存されていないか検索します。② キャッシュヒットし、即座にエッジカバレッジを得ることができます。③ 0x71c から開始するトレースでAtomのEEENが得られた場合に、その時のエッジカバレッジが1段階目のキャッシュに保存されていないか検索します。④ キャッシュヒットし、即座にエッジカバレッジを得ることができます。⑤ 0x71c から開始するトレースでAtomのEが得られた場合に、その時のエッジカバレッジが1段階目のキャッシュに保存されていないか検索します。⑥ キャッシュミスしたため、2段階目のキャッシュに対して、0x71c 以降の最初の分岐命令の分岐先アドレスが保存されているか検索します。⑦キャッシュヒットし、分岐がTakenの場合の分岐先アドレスが 0x72c だと分かり、エッジカバレッジが計算できます。この例のように、仮に同じコントロールフローをたどるトレースであっても、割り込みなどの外的要因によりトレースデータが一致するとは限りません。このような場合に、2段階目のキャッシュが役立ちます。⑧ 1段階目のキャッシュにデコード結果を追加し、キャッシュを更新します。⑨ 0x750 から開始するトレースでAtomのEが得られた場合に、その時のエッジカバレッジが1段階目のキャッシュに保存されていないか検索します。⑩ キャッシュミスしたため、2段階目のキャッシュに対して、0x750 以降の最初の分岐命令の分岐先アドレスが保存されているか検索します。⑪ 再びキャッシュミスしたため、 ターゲットバイナリをディスアセンブルし、0x750 から順に一命令ずつ確認し、分岐命令を探します。⑫ 分岐命令 b.ne 780 を見つけ、分岐先アドレスを特定し、エッジカバレッジが計算できます。⑬ 1段階目のキャッシュと2段階目のキャッシュにデコード結果を追加し、キャッシュを更新します。

図8. 2つのキャッシュを利用したデコーダの動作
図8. 2つのキャッシュを利用したデコーダの動作


以上、Intel PTにおけるプラクティスをCoreSight向けに再現することで、デコーダのボトルネックの解消を図りました。

PTrixスタイルのパスカバレッジの設計と実装

本節ではPTrixで提案されているIntel PT向けの実行パスに敏感なフィードバックをCoreSight向けに実装したPTrixスタイルのパスカバレッジの設計と実装について解説します。

前節で説明したように、Intel PTのトレースを用いたエッジカバレッジの生成には、Intel PTのトレースだけでなくバイナリのディスアセンブルも必要です。このことから、PTrixの論文ではIntel PTのトレースから従来のAFLで利用されているエッジカバレッジを生成する手法は非効率であると指摘されています。そこで元のバイナリのディスアセンブル結果を使わずにトレースのデコード結果から直接フィードバックとなるビットマップを生成する手法を提案しています。この手法ではTNTパケットとTIPパケットからハッシュを計算し、得られたハッシュをエンコードしてビットマップを更新します。ハッシュ値はこれまでのトレースパケットの履歴を表現しているため、実質的にパスカバレッジとみなすことができます。PTrixの論文ではこのフィードバック表現によりエッジカバレッジよりも実行パスをよりよくとらえることができるとしています。

今回PTrixスタイルのパスカバレッジのCoreSight向け実装ではTNTパケットがAtomパケットに相当し、TIPパケットがAddressパケットに相当することを踏まえてハッシュを計算するように実装しました。また、AtomパケットのEN列を”0”と”1”の文字列として扱うことによりハッシュ計算の高速化を図りました。

以降の節でパスカバレッジの有効性を検証します。

ベンチマーク

以上、数々の高速化を取り入れてきましたが、その効果はどれほどのものでしょうか。ベンチマークを取得しました。

今回のベンチマーク取得では、AFL++ LTO mode(ソースコードのビルド時にinstrumentationを行うモード)、AFL++ QEMU mode、AFL++ CoreSight mode(エッジカバレッジを取得する設定)、AFL++ CoreSight mode(パスカバレッジを取得する設定)の4種類のファザーを用いました。また、void main(void) {}のような非常に簡単なアプリケーションをPUTとし、各ファザーを用いて60秒間実行した回数を比較しました。PUTを簡単なアプリケーションとしている理由は、そのパスの性質にファザーが左右されないようにするためです。

# of exec exec/sec
LTO mode 169169 2819.5
QEMU mode 64307 1071.8
CoreSight mode (Edge cov.) 96490 1608.2
CoreSight mode (Path cov.) 99744 1662.4

結果として、CoreSight modeはエッジカバレッジとパスカバレッジどちらの設定でもQEMU modeより高速でした。また、エッジカバレッジよりもPTrixに準拠したパスカバレッジを用いた方がやや速い結果となりました。この結果は、PTrixの論文で示されている実験結果とも一致します。残念ながら、Binary-only Fuzzingはソースコードありのファジングの速度を越えることはできませんでした。これは、CoreSightを扱う処理のオーバーヘッドによるものだと考えられます。しかし、ARM向けバイナリ、ことファームウェアの解析においてソースコードを入手できる機会はまれであり、実用上QEMUより速いBinary-only Fuzzingの効果は十分に見込めるものと考えています。

パスカバレッジモードの有効性

次にパスカバレッジモードの有効性を検証しました。前節で用いた4種類のファザーについて、次のようなアプリケーションをPUTとして最初にクラッシュを観測するまでの実行回数と生成されたシード数を比較しました。このPUTは標準入力から入力を受け取り、その先頭10文字を比較してaaaaabbbbbと一致すればcrash()に到達してクラッシュするように作られており、到達するにはその実行パスが重要となります。このPUTはPTrixの実装から参照しました。

  read(0, src, SIZE);

  for (int i = 0; i < SIZE; i++) {
    if (src[i] == 'a') {
      dst[i] = src[i];
      continue;
    }
    if (src[i] == 'b') {
      dst[i] = src[i];
      continue;
    }
    break;
  }

  if (!strncmp(dst, "aaaaabbbbb", 10)) {
    crash(0);
  }

得られた結果を次の表に示します。

# of exec # of seeds
LTO mode 153260 11
QEMU mode 144645 12
CoreSight mode (Edge cov.) 146844 16
CoreSight mode (Path cov.) 135308 98

この結果では、LTO modeとQEMU mode、CoreSight modeでのエッジカバレッジはほぼ同じとなりました。一方、CoreSight modeのパスカバレッジは最も少ない実行回数でクラッシュに到達しています。シード数では、パスカバレッジはより多くのシードを生成しており、意図した通りに実行パスの違いにエッジカバレッジより敏感なフィードバックであることを示唆しています。

おわりに

本記事では、Binary-only Fuzzingの高速化のためにCoreSightを活用する取り組みを紹介しました。この取り組みにより、ARMにおける高速なBinary-only Fuzzingの実現可能性に一定の答えを見いだせたと思っています。わたしたちRicerca Securityは、オフェンシブセキュリティの新たな価値を開拓するべく、今後もファジングの研究開発を多方面から積極的に進めてまいります。その検討を加速化するために、システムプログラミングに対する理解や最新の研究にキャッチアップするスキルのある方を募集しています。ご興味のある方は採用情報をご覧ください。

謝辞

本研究は防衛装備庁令和3年度安全保障技術研究推進制度委託事業 (JPJ004596) の一環として実施したものです。また本システムのプロトタイプ開発に携わった大塚馨氏に感謝いたします。