Binary analysis is essential for protecting software, running on various devices, when you do not have access to resources such as source code, open communications with the manufacturer, and otherwise private information that has not been made public. In each of these cases, traditional cybersecurity measures such as static source code analysis and applying the latest patch are not an option.

Most firmware analysis and SBOM generation vendors only report known and/or publicly published vulnerabilities (i.e., CVEs). These vendors do not deeply inspect the ground truth of a program’s behavior; they do not analyze the machine code being executed as defined at the binary level. This leaves much to be desired, as novel exploits that are not yet published in the National Vulnerability Database still pose a daily threat to the client organizations.

This poses the following question: to what extent can novel binary exploit detection be automated? In this blog post, we will explore this problem through the lens of a user of our ObjectSecurity™ OT.AI Platform™ and walk through the steps they would take to detect a novel exploit in a binary executable program.

Creating a Target Binary Executable

Imagine you have a program that reads some form of user input. Although the source of this user input may vary (e.g., from the terminal, over the network, from a UI form, etc.), in most cases the program will define an internal function to parse the input and perform some operation. The previous statement is quite general, so let’s provide a concrete example.

When compiled, the source code produces a binary that takes as input a single command line argument, and then performs one of two operations depending upon it.

  1. If the argument is not equal to the value 123, the program performs a safe operation that causes no issues (heap_safe()).
  2. If the argument is equal to the value 123, the program performs an unsafe operation that causes a heap overflow, segmentation fault, and program crash (heap_overflow()).

This behavior is demonstrated in the screenshot below:

Although this example is contrived, it does mimic a simplified version of how vulnerable programs typically behave. Vulnerable programs read input data from an external source (in our case, a command line argument), and perform different operations depending on this input. These operations may contain certain weaknesses, some of which can be exploited if the program’s developer wasn’t careful enough.

In a real-world example, these unsafe operations are hidden deep within a binary file, only occurring in certain obtuse cases. Many times, developers might lack the knowledge, time, or resources to account for every possible edge case. This makes automating the detection of unsafe operations critical.

Let’s assume that we don’t have the source code for this binary (called sample_1), and that we don’t know what inputs cause it to crash. How then could we determine if the binary is exploitable? More specifically, how can we reliably determine that input 123 causes the program to crash, if we were never told this information?

A Primer on Fuzzing

Fuzzing is an automated testing method that feeds pseudo-random input into a target program to generate exploits. This input is typically malformed, invalid, and/or unexpected. Fuzzing a vulnerable program sometimes results in the exploitation of one or more weaknesses, causing the program to crash or perform some other kind of unintended behavior. Some weaknesses include:

  • Buffer overflows
  • Use-after-free
  • Memory leaks
  • Divide by zero errors
  • Integer and floating-point overflows
  • NULL dereferences

Each of these weaknesses, and many others, are detectable by our ObjectSecurity™ OT.AI Platform™. We can use fuzzing in conjunction with the assessment information produced by our ObjectSecurity™ OT.AI Platform™ to prove or disprove if any detected weakness is exploitable. In the case of sample_1, our binary contains a heap overflow. A heap overflow occurs when memory on the program’s heap is written to memory outside of the bounds that were allocated on the heap. Heap overflows can lead to segmentation faults, program crashes, data leaks, and data corruption.

Fuzzing is useful when a program consumes untrusted inputs. For example:

  • Devices that receive input from peripherals
  • Network protocols
  • Network scanners
  • Browsers
  • Text editors or parsers
  • Databases

Once again, our trivial sample_1 case receives input from the command line, but you could imagine a more general case where the input is received from another source. The approach we will take to exploit the heap overflow weakness in sample_1 will involve in-memory fuzzing. In-memory fuzzing alleviates the need to know where the input to a program is coming from, making it more generally applicable than something like interface fuzzing.

In Memory Fuzzing using GDB

Our approach to determine if sample_1 is exploitable consists of the following steps:

  1. Identify the program counter (PC) address containing the weakness that we wish to exploit.
  2. Identify the function which contains this weakness, and its corresponding PC address.
  3. Identify the arguments to this function, including their type, count, and possible variations.
  4. Fuzz the arguments to this function by iteratively mutating their values in memory.
  5. Crash the program and report the details as an exploit.

Each step in this process can be generalized to varying degrees for other binary programs outside of just sample_1. Although how step 3 is accomplished depends highly on the CPU architecture, compiler, and other factors, step 3 can be completed for pretty much any binary program you could envision. The same is true for the other steps. For the sake of simplicity, this article will stick to how the steps apply to sample_1, and not focus too much on how each step applies to other binaries in general. That is a focus for another time.

Steps 1 and 2 are fully automated by our ObjectSecurity™ OT.AI Platform™. When scanned, various assessments performed by the platform identified PC address 0x1220 as the location of a weakness. For example, the Weak Pointers assessment reported 0x1220 as a containing a weak pointer:

The Dangerous Functions assessment reported a potentially dangerous instance of memcpy() at 0x1220.

Other CWE-120 assessments also reported memcpy() at 0x1220, including a general buffer overflow detection assessment:

Viewing the 0x1220 address in OT.AI’s disassembler/decompiler showed that the function containing the weakness is called heap_overflow(),and that heap_overflow() is called from memory_operation().

OT.AI took under 5 minutes to fully reverse engineer sample_1 (both disassembly and decompilation) and to produce these results.

For step 3, the GNU Project Debugger (GDB) can be used to determine the argument(s) to the memory_operation() function. GDB lets you see what is going on ‘inside’ another program while it executes (in this case, ‘inside’ sample_1). GDB is typically used during the development process to find and address problems in source code but can also be used on binaries exclusively.

A breakpoint at the memory_operation() function can be set using the break memory_operation command. This breakpoint will halt sample_1’s execution and allow us to run other GDB command at that time.

We can then begin program execution. Because sample_1 requires us to input a command line argument, we include the command line argument as “arg”:

As shown above, GDB pauses the binary’s execution at the start of the memory_operation() function. At this point, we can determine the arguments to this function:

The command above prints the value of the RDI register (in this case, the memory address 0x7fffffffdeda) and the value found at memory address pointed to by the RDI register (in this case, “arg”). As you can see, the RDI register points to memory containing the command line argument we gave to the program when we began running it.

(NOTE: This could have been in any register. Arguments are not always stored exclusively in the RDI register. This behavior varies between CPUs, compilers, and programming languages.)

For step 4, we want to fuzz the arguments to the memory_operation() function by manipulating the value found at memory address 0x7fffffffdeda: the memory address pointed to by the RDI register. To do this, I have provided a Python script called function_fuzzer.py which makes use Python’s GDB library to instrument GDB. The source code for this script can be found at the bottom of this article. The gist of this script is as follows:

[4.1]   Set a breakpoint at the start of the memory_operation() function.

[4.2]  Start executing the binary.

[4.3]  Once at the breakpoint, create a checkpoint. This checkpoint allows GDB to rewind the binary’s state (e.g., current PC address, all register values, values stored in memory, etc.) to how it was at the time the checkpoint was created.

[4.4] Overwrite the value at the memory address pointed to by the RDI register with a random value.

[4.5] Finish executing the memory_operation() function. If the program does not crash, rewind to the checkpoint created in [4.3]. If the program crashes, report the exploit.

(NOTE: In [4.4], you could alternatively allocate the random value to a different memory address and mutate the RDI register to point to this different memory address. This approach may be more applicable in certain scenarios.)

Running this script produced the follow output, some of which has been condensed as to not bloat the content of this article:

As you can see, on iteration 1180 the input 123 was attempted. This input caused the program to perform a heap overflow, segmentation fault, and ultimately crash. The script took under a minute to find the exploit, although this speed was hastened because we only fuzzed with 3-byte long numeric values.

Conclusion

This article demonstrates how parts of the novel binary exploit generation process can be automated using our ObjectSecurity™ OT.AI Platform™, most notably target weakness detection. Because fuzzing is a time-consuming and computationally intensive process, we hope that the time saved by our ObjectSecurity™ OT.AI Platform™ in detecting and cataloging binary weaknesses allows for faster and more informed threat hunting. In the future, we plan on further automating the novel exploit detection process and improving our automated binary reverse engineering capabilities.

Caveats

  • This approach assumes that the targeted binary is unstripped. When a binary is unstripped, it retains function names and other metadata. Knowing the exact function name isn’t too important, as GDB can set breakpoints at PC addresses, although the procedure outlined in this article would need to be altered slightly to work with stripped binaries.
  • This approach assumes the binary is compiled for an x86 instruction set. Different instruction sets would require different methods for determining function arguments and other factors, although the broad strokes of this article still apply.
  • This approach assumes that the host environment running GDB has the same CPU architecture and operating system as the target binary; the target binary was compiled on the same host that is running GDB. There are work arounds for this as well, including binary rehosting and/or various other methods.

Resources

"""
(c) 2024 ObjectSecurity LLC

This script performs in-memory fuzzing of the 
arguments to the `memory_operation` function in 
the `sample_1` binary. It uses GDB.

The `memory_operation` function takes one argument.
This argument is a char* to the commandline arg
passed into the binary from `main`. This script
pauses program execution at the start of the
`memory_operation` function, and overwrites
this argument by writing to the memory address
pointed to by the RDI register. It does this
iteratively, until the program segfaults and 
crashes.

Usage: `gdb --command function_fuzzer.py`
"""

import gdb
import string
import random

BINARY_FILE = "sample_1"
FUNCTION_NAME = "memory_operation"
INPUT_SIZE = 3

def main():
   print(f"\n\nFuzzing binary `{BINARY_FILE}` at function `{FUNCTION_NAME}`.\n\n---\n")

   # select binary file to fuzz
   gdb.execute(f"file {BINARY_FILE}")

   # set breakpoint a function to fuzz
   gdb.execute(f"break {FUNCTION_NAME}")

   # turn off things that require user input
   gdb.execute("set confirm off")
   gdb.execute("set pagination off")

   # begin executing the binary
   gdb.execute("run cmd")
   fuzz_func()

   # exit GDB
   gdb.execute("quit")

   
def fuzz_func():
   iteration = 1
   while True:
      fuzzed_input = gen_rand_input(INPUT_SIZE)
      print(f"\n\nPerforming iteration {iteration} with input {fuzzed_input}.")

      # save current program state, such that we can return
      # to it at the start of each loop
      gdb.execute("checkpoint")
      gdb.execute("restart 1")

      # overwrite memory address stored in RDI register 
      # (e.g., the first argument to the function)
      rdi_address = gdb.parse_and_eval("$rdi")
      gdb.inferiors()[0].write_memory(rdi_address, fuzzed_input, INPUT_SIZE)
      gdb.execute("finish")

      # restore program state from checkpoint
      try:
         gdb.execute("restart 0")
         gdb.execute("delete checkpoint 1")
         iteration += 1
      except:
         break

def gen_rand_input(size: int):
   return "".join(random.choices(string.digits, k=size))

if __name__ == "__main__": 
   main()