Visualização de leitura

I pointed an agent at a bootloader. It found bugs but not useful ones

I pointed an agent at a bootloader. It found bugs but not useful ones

Now that we're past the sensational headline, let's be real. This is the first post in a series about using AI/LLMs to do security work. I know, I know. Everybody and their grandma is using AI for this nowadays. Every time I'm opening up any kind of social media, I feel like this graph still holds true up to now:

I pointed an agent at a bootloader. It found bugs but not useful ones
AI startup growth visualized, 2026.

Also, this blog series will not be about "AI will replace us" (at least not yet) nor about "prompt engineering tips" (albeit an overlap will be there). What this post in particular will be about is some kind of retrospective combined with what it actually looks like when you put a capable model down in front of a real target and ask it to do the whole job. I want to take that apart and rebuild it into something that isn't a party trick. So who knows, maybe the further we get along in this series, the closer you're going to get to witnessing me putting my name in the above graph as well 😎.

I started experimenting with "AI-powered" solutions around the beginning of 2023 at an earlier company (the same time Google came out of the closet with their first public findings). If I recall correctly, when I started, it was still the "GPT-3" era. Asking an LLM about automated security work often resulted in major hallucination backed by a strong sense of confidence (from the LLM). If I had to visualize using AI for security work a few years back, this would come to mind:

I pointed an agent at a bootloader. It found bugs but not useful ones
GPT-3 thinking

The above may be explained with what everybody was trying to do at the time: 0-shot prompting for a 0-day. This was largely due to the tiny context window of 2048, then 8192, and later a very much welcomed 128000 tokens. Tiny by today's standards. A lot has changed since then, and I hope we're catching up to the current developments, as the development speed at which not just AI security works but also AI advances is scarily fast in my humble opinion.

Anyhow, to do all of this properly, I have to start where everyone started. So this post is deliberately the 2023/2024 version of the idea: one agent, one (big) context window, one repository, and a prompt that basically says, "Here, go find me something." No framework, no orchestration, no pipeline. Just me giving a model a multifaceted job that would normally take a person a couple of days to weeks. I ran the experiment against a public Qualcomm source. It found bugs. The bugs are not good (as expected). That combination is the whole point, so let me walk you through it in detail before I explain why.

Note If you are here for a dramatic 0-day, this is not that post. It is the post that explains why it wasn't, and I think the "why" is worth more than a CVE would have been.

The idea

I had this blog post series idea on my pile of side projects for ages, but life kept me busy. However, recently I finished my secure-boot writeup. If you have not read it, it was about how a cryptographically flawless signature check can still leave the parsers behind it exposed. So my headspace was still kind of stuck in that whole "embedded security" world when I (finally) started writing this one. So this blog will overlap with the discussed targets from the aforementioned write-up. I figured Qualcomm's Android Boot Loader would be a good place to start because it is one of the few pieces of this stack that is actually public. It is proper C, and it is full of parsers that need to handle attacker-influenced data: sparse images, boot image headers, partition tables, and device trees. When looking at a typical Qualcomm Android boot chain it roughly looks like this:

  PBL                  on-die mask ROM
   |
   v
  XBL                  Qualcomm's UEFI core: edk2-based, but
   |                   PROPRIETARY and closed (xbl.elf)
   v
  ABL                  a UEFI application launched by XBL (abl.elf)
   |  \
   |   `--> QcomModulePkg              [ OUR TARGET ]
   |          from CodeLinaro  clo/le/abl/tianocore/edk2
   |          LinuxLoaderEntry (the app entry), BootLib,
   |          FastbootLib, AVB, boot.img / slot / DTB-DTBO
   v
  Linux / Android

So what I set out to do was fuzz the Android Boot Loader, in particular the QcomModulePkg. To the best of my knowledge, this public tree lives on CodeLinaro. The clo/main branch appears to have been frozen since June 2022. However, there are per-BSP tags (LA.UM mobile, LE.UM embedded, LY.AU automotive). There are two I looked at closer, which we will talk about in more detail in a bit:

  1. LU.UM.3.5.1.r1-00700-QCS6490.0, last commit was February 2023.
  2. LE.UM.3.2.3.c17-10200-SA2150p, last commit was April 2026

One more reason I chose this codebase is the moderate complexity due to the number of files and total lines of code:

# edk2 on LE.UM.3.2.3.c17-10200-SA2150p
$ tokei QcomModulePkg
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 GNU Style Assembly        1          224          140           67           17
 C                        59        31111        23768         3957         3386
 C Header                 97        21959         7471        12428         2060
 Lauterbach PRACTI|        7          430          167          211           52
 Python                    2          599          367          154           78
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                   166        54323        31913        16817         5593
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

If I were to manually hunt for bugs, I'd would clone the tree, spend a few evenings reading, pick a couple of functions, hand-write harnesses, and grind. Instead, I handed the entire thing to an agent and stayed mostly out of the way. I gave it a Linux box with clang and AFL++, pointed it at those two tags, and said, roughly,

"Analyze this repository, find what's worth fuzzing. Requirement: Build up to five harnesses, run them, monitor them and save the results. You have access to a Linux sandbox via <CREDENTIALS>. Make use of libfuzzer or AFL++, both are available. Use a single TMUX session for running. Lift code as is when necessary, don't ever change it. Keep going until the requirement is fullfilled, don't stop and don't prompt me for input."

This was done with a single model, which was responsible for understanding the repo, the attack-surface reasoning, the harness code, the build system, and the triage all at once. Not very efficient in times of overcomplicated, distributed, multi-agent harnesses. Especially considering we're "competing" not even just with random startups but with those companies that build the LLM capabilities. They claim thousands and thousands of (high severity) bugs. Just to link a few:

You get the idea. When doing the research for this series, the longer I kept digging, the more I felt like cybersecurity is a solved problem, and I really need to advance my plans for buying some farmland and planting some mango trees and coffee plants in some remote rural off-the-grid place. While I don't think all those headlines are fake, and I seriously feel like job security is on the line for those that are adamant about becoming an AI plumber, I think the bubble in which this all happens is insane. The money in it and the pace in which people claim they found another breakthrough are mental. So let's join this gold rush and dig up some dirt.

Note This full experiment that follows has been conducted using Claude Code 2.1.234 using claude-opus-4.8 on xHigh effort.

Building a harness that does not lie

I truly haven't done one of these 0-shot attempts in a while with newer models, as based on that, everybody, including myself, was shitting on a model's capabilities. We were optimizing for this by building a modular framework with split workloads. That said, the first thing worth judging for us is not any potential bugs but whether the requested harnesses are decent. Obviously, as LLMs are by design non-deterministic, your mileage may vary here when you attempt to reproduce any of the following.

ABL is UEFI code. Typically you wouldn't be able to compile a function out of it and call it, as it depends on boot services, protocols, allocation pools, debug macros, and an entire environment. So when looking at the generated harnesses, I found that the LLM settled for lifting the picked fuzzing entrypoint verbatim, byte-for-byte. It even created a thin shim in front for missing types and macros. Only those calls that are touching an outside environment were stubbed. Any targeted parsing routine was left unmodified. This is something that 100% did not work back in the day. Even when pointing an old LLM at a source file, it would come up with a different function name, wrong function arguments, or other random nonsense. So the "quantity" of code produced that ended up making the harness compile and run is already on a way different level.

Let's take a look at the created shim. From my understanding this was rather small. The LLM did not have to "re-invent" the wheel here. This is not a creative type of work in a sense, such as creating a harness would, where one would need to think of what APIs to call, in which order, basically creating something from scratch. The lifting "just" requires understanding of which types were missing and where they are located in the original source. So without more rambling, here is the shim:

// file: edk2_shim.h
/*
 * Minimal EDK2 / UEFI shim: just enough for the lifted QcomModulePkg sparse
 * code to compile and run on a host libFuzzer/ASan build. Every macro/type
 * here mirrors the real EDK2 semantics the lifted code relies on.
 */
#ifndef EDK2_SHIM_H
#define EDK2_SHIM_H

#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>

/* --- EDK2 source annotations (no-ops on host) ------------------------- */
#ifndef IN
#define IN
#endif
#ifndef OUT
#define OUT
#endif
#ifndef OPTIONAL
#define OPTIONAL
#endif
#ifndef CONST
#define CONST const
#endif
#ifndef STATIC
#define STATIC static
#endif

/* --- base types ------------------------------------------------------- */
typedef uint8_t   UINT8;
typedef uint16_t  UINT16;
typedef uint32_t  UINT32;
typedef uint64_t  UINT64;
typedef int8_t    INT8;
typedef int16_t   INT16;
typedef int32_t   INT32;
typedef int64_t   INT64;
typedef uintptr_t UINTN;
typedef intptr_t  INTN;
typedef unsigned char BOOLEAN;
typedef void      VOID;
typedef char      CHAR8;
typedef uint16_t  CHAR16;
typedef UINTN     EFI_STATUS;
typedef VOID     *EFI_HANDLE;

#ifndef TRUE
#define TRUE  ((BOOLEAN)1)
#endif
#ifndef FALSE
#define FALSE ((BOOLEAN)0)
#endif

#define MAX_UINT32 ((UINT32)0xFFFFFFFFU)
#define MAX_UINT64 ((UINT64)0xFFFFFFFFFFFFFFFFULL)

/* --- EFI_STATUS (high bit = error, matches EDK2 ENCODE_ERROR) ---------- */
#define ENCODE_ERROR(a) ((EFI_STATUS)(((UINTN)1 << (sizeof(UINTN) * 8 - 1)) | (a)))
#define EFI_ERROR(s)    (((INTN)(UINTN)(s)) < 0)
#define EFI_SUCCESS           ((EFI_STATUS)0)
#define EFI_INVALID_PARAMETER ENCODE_ERROR(2)
#define EFI_BAD_BUFFER_SIZE   ENCODE_ERROR(4)
#define EFI_OUT_OF_RESOURCES  ENCODE_ERROR(9)
#define EFI_DEVICE_ERROR      ENCODE_ERROR(7)
#define EFI_NO_MEDIA          ENCODE_ERROR(12)
#define EFI_VOLUME_CORRUPTED  ENCODE_ERROR(10)
#define EFI_VOLUME_FULL       ENCODE_ERROR(11)
#define EFI_NOT_FOUND         ENCODE_ERROR(14)
#define EFI_UNSUPPORTED       ENCODE_ERROR(3)

#define MAX_GPT_NAME_SIZE 72

/* --- DEBUG(): single-arg no-op that swallows the (LEVEL, fmt, ...) tuple */
#define EFI_D_ERROR   0
#define EFI_D_INFO    0
#define EFI_D_VERBOSE 0
#define DEBUG(Expression)

/* --- overflow guard the lifted code calls ----------------------------- */
#define CHECK_ADD64(a, b) (((UINT64)(a) + (UINT64)(b)) < (UINT64)(a))

/* --- fake BlockIo protocol -------------------------------------------- */
/* sparse/META read only Media->BlockSize (positional init { BlockSize });
 * the GPT path also needs Media->MediaId and a WriteBlocks() stub. New
 * fields are APPENDED so the existing positional initializers stay valid. */
typedef UINT64 EFI_LBA;
typedef struct { UINT32 BlockSize; UINT32 MediaId; } EFI_BLOCK_IO_MEDIA;
struct EFI_BLOCK_IO_PROTOCOL_s;
typedef EFI_STATUS (*EFI_BLOCK_WRITE_BLOCKS) (
    struct EFI_BLOCK_IO_PROTOCOL_s *This, UINT32 MediaId, EFI_LBA Lba,
    UINTN BufferSize, VOID *Buffer);
typedef struct EFI_BLOCK_IO_PROTOCOL_s {
  EFI_BLOCK_IO_MEDIA    *Media;
  EFI_BLOCK_WRITE_BLOCKS WriteBlocks;
} EFI_BLOCK_IO_PROTOCOL;

/* --- pool allocators -------------------------------------------------- */
static inline VOID *AllocateZeroPool(UINTN Size) { return calloc(1, (size_t)Size); }
static inline VOID  FreePool(VOID *P) { free(P); }

#ifndef ARRAY_SIZE
#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
#endif

#endif /* EDK2_SHIM_H */

It is types, a DEBUG that expands to nothing and pool allocators that are just calloc/free, so the lifted code compiles and runs, but its logic is exactly as shipped. Every harness in this post includes it...

In my initial prompt I was strict about one thing. The lifted function stays exactly as shipped. Around that premise, to my surprise, a core driver was built that shapes fuzzer bytes into something the parser will accept, the device-side calls are stubbed, and an oracle is watching the write operations. Two properties make the result trustworthy-ish, and I checked both when inspecting what has been delivered:

  1. A canary proves the oracle works. Each harness gets a deliberately broken input that must crash under AddressSanitizer. If the canary does not fire, the harness is lying. I'll omit for brevity, but every harness had a macro definition that would test the API, and compilation worked with a deliberate crash to show it,
  2. Coverage proves reachability. If the fuzzer never hits the branch we wanted to hit, then basing our interpretation of "no crashes found" is wrong. "No coverage" or "wrong coverage" was the culprit. So each target got a coverage run as well.

Ultimately what I observed when I gave the LLM the task is that the agent was running a small loop without me having ever prompted it to do so.

lift verbatim -> plant canary -> build (libFuzzer or AFL++) -> seeds
        -> run -> triage -> guard-and-refuzz ---+
                    ^                           |
                    +---------------------------+

This is beyond anything that would have happened a few years ago. Again, I'm repeating myself here, but if we were lucky back in the day (gosh, that sounds weird), an LLM maybe got as far as to create a LLVMFuzzerTestOneInput-style libfuzzer harness (when explicitly prompted) that makes a single API call with hopefully correctly typed arguments and then attempts to compile it. It was often dumbfounded when any of this wouldn't have worked. So yes, seeing the progress here is actually very nice. That said, I'm not going into much detail now about why this single agent loop it produced may not be very efficient or cost-effective. We're getting to that eventually. With the method that was used repeatedly by the LLM explained, the rest of the post is a mini technical deep dive, one harness at a time.

Seeds and coverage: proving a parser was reached

Before I start throwing coverage percentages around, two things have to hold: the fuzzer has to actually reach the code (it targeted), and I have to be able to prove it did. Skipping either and a run really doesn't mean much. So looking at this from a fuzzing point of view, we could say that if we point a mutator at raw random bytes, it will burn a lot of budget just to bypass some magic constants or size constraint checks. It will likely only by chance (if even) touch the core logic we care about and could potentially break. So obviously one way to analyze this is coverage information, and the LLM decided on its own accord that analyzing coverage metrics is the way to go to determine whether a fuzzing harness is making legit progress or whether it just compiles and runs. Every target ships a small generator that hands the fuzzer a structurally valid input to start from.

random bytes           ->  [ magic + size gate ]  ->  rejected     (0% of the parser)
a seed (valid header)  ->  [ magic + size gate ]  ->  real logic   (the part that breaks)
                          ^
                          the generator writes that valid header, so run #1 lands
                          past the gate instead of grinding toward it

Obviously the specifics on how that looks like differ per target and I'll talk about them later when we discuss the harnesses itself. The point here being, raw byte mutations and coverage tracking are one half of the equation that the LLM attempted to solve. The other half are good seeds. For the LLM those were not "nice-to-have things", it went ahead and made sure every harness gets kickstarted with some. So the "thought process" if you want to call it that, of the LLM I used must have reached a state that said, "Having no crashes from a fuzzer that never arrived where it was supposed to arrive is worthless. I cannot trust a harness without a coverage number sitting next to it". So, for each harness, it self-reviewed the coverage information by building the harness target like this:

build: clang -fprofile-instr-generate -fcoverage-mapping
             |
             v   run over the corpus
          default.profraw
             |
             v   llvm-profdata merge
          app.profdata
             |
             v   llvm-cov report   over   *_extract.c
          lines / functions / branches actually reached

That in itself was again interesting, as my prompt I provided was not necessarily guiding it towards this approach. I kept it vague on purpose to see how far we've actually come. With that introduced, let's check the harnesses and their performance.

Three parsers that held up

So as stated before, I requested up to five harnesses. I was kind of pushing it with that, but I wanted to see just how much a 2026 LLM can achieve without looking at cost, tokens spent, and time taken to finish the request. Those are all metrics for another part in this series. That said, me specifically mentioning "up to" was a test from my side to see if the LLM was taking this upper limit into consideration or if it just tunnel visioned hard on the five. It did the latter. It produced five harnesses. Three out of those five found nothing. They built correctly, and they were exercising real code, not just dummies or shim sections, and they produced coverage, just no crashes. I'd argue these are still worth a section to explore what has been fuzzed.

So in good academic fashion, first some stats. The fuzzers have been running close to 44 hours (whoops, I wanted to let them run for a few, but then life happened). These three harnesses I'll quickly walk through logged like 80 billion executions in total (about 5B on sparse, 36B on META, and 42B on the boot header), each pinned to a single core of a 14-core box (laptop with Intel(R) Core(TM) Ultra 7 155U) at anywhere from ~10k to ~70k executions per second. So the bottom line here is: They were running for a considerable amount of time and at excellent speeds. However, a shallow fuzzer with nothing to exercise will always be excellent in speed...

Sparse images: guarded arithmetic, no crash

This one is interesting. Sparse image flashing (HandleSparseImgFlash, plus HandleChunkTypeRaw/HandleChunkTypeFill and ValidateChunkDataAndFlash, in FastbootCmds.c) is a textbook target: an attacker could supply a flashed image, and the loader needs to parse it before it can trust it. I assume this target was chosen for this exact reason, with the premise that a loader parsing potentially untrusted data could be worth a look.

To give some technical background on this one. An Android sparse image is a small header followed by a run of chunks. The sparse_header carries the block size, the total block count, and how many chunks follow. Each chunk_header then announces what kind of chunk it is (raw, fill, don't-care, or CRC) and how big it is. HandleSparseImgFlash walks them in order, and for every chunk, it multiplies the chunk's block count by the block size to work out how many bytes to move, accumulating an offset as it goes. That multiply-and-accumulate over attacker-controlled counts is the whole reason this is worth a look (I assume). Putting this into some structural diagram:

Android sparse image
====================
   +---------------------------------------------------------+
   | sparse_header : magic 0xed26ff3a, blk_sz, total_blks,   |
   |                 total_chunks                            |
   +---------------------------------------------------------+
   | chunk_header  : chunk_type, chunk_sz (blocks), total_sz |
   | payload       : RAW = chunk_sz*blk_sz bytes, FILL = 4,  |
   |                 DONT_CARE / CRC = 0 / 4                 |
   +---------------------------------------------------------+
   |  ... repeated total_chunks times ...                   |
   +---------------------------------------------------------+

  the walk (HandleSparseImgFlash):

     for chunk in 0 .. total_chunks:
         bytes = blk_sz * chunk_sz          <-- attacker-controlled multiply
         RAW       -> WriteToDisk(payload, bytes)
         FILL      -> WriteToDisk(fill,    bytes)
         DONT_CARE -> advance the offset, no write
         CRC32     -> checksum only

So this walking the structure and calculating offsets and the total bytes is an arithmetic. Arithmetic operations are often prone to overflows. So it definitely kind of checks out that this could be worth fuzzing. The harness built around this follows exactly that logic. A lifted HandleSparseImgFlash and its chunk handlers stay as the core logic, WriteToDisk becomes a memcpy into a 64MiB buffer, the partition lookups are stubbed, and the driver keeps the header valid on every iteration so the fuzzer stays down in the chunk loop instead of dying on the magic number. 

// file: sparse_harness.c
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdlib.h>

#include "edk2_shim.h"
#include "sparse_format.h"

/* Defined in FastbootCmds_extract.c: sets up the stub partition and
 * calls the lifted HandleSparseImgFlash(). */
extern EFI_STATUS SparseFuzzEntry(VOID *Image, UINT64 sz);

int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size)
{
    if (Size < sizeof(sparse_header_t))
        return 0;

    /* The parser writes into the buffer in place, so hand it a private,
     * exactly-sized allocation and let ASan police the bounds. */
    uint8_t *Image = (uint8_t *)malloc(Size);
    if (!Image)
        return 0;
    memcpy(Image, Data, Size);

#ifdef NORMALIZE_HEADER
    /* AFL++ build: it does not call LLVMFuzzerCustomMutator, so keep the
     * sparse header valid here so the bytes still reach the chunk loop. */
    {
        sparse_header_t *h = (sparse_header_t *)Image;
        h->magic         = SPARSE_HEADER_MAGIC;
        h->major_version = 1;
        h->file_hdr_sz   = (uint16_t)sizeof(sparse_header_t);
        h->chunk_hdr_sz  = (uint16_t)sizeof(chunk_header_t);
        h->blk_sz        = 512u * (1u + (h->blk_sz & 7u));
    }
#endif

    SparseFuzzEntry(Image, (UINT64)Size);   /* -> lifted HandleSparseImgFlash */

    free(Image);
    return 0;
}

#ifndef AFL_BUILD
/* libFuzzer build: keep the sparse header valid after each mutation so inputs
 * reach the chunk loop instead of dying at the magic / size gates. The chunk
 * stream is left free to mutate, because that is the target. */
size_t LLVMFuzzerMutate(uint8_t *Data, size_t Size, size_t MaxSize);
size_t LLVMFuzzerCustomMutator(uint8_t *Data, size_t Size, size_t MaxSize,
                               unsigned int Seed)
{
    (void)Seed;
    size_t n = LLVMFuzzerMutate(Data, Size, MaxSize);
    if (n >= sizeof(sparse_header_t)) {
        sparse_header_t *h = (sparse_header_t *)Data;
        h->magic         = SPARSE_HEADER_MAGIC;
        h->major_version = 1;
        h->file_hdr_sz   = (uint16_t)sizeof(sparse_header_t);  /* 28 */
        h->chunk_hdr_sz  = (uint16_t)sizeof(chunk_header_t);   /* 12 */
        h->blk_sz        = 512u * (1u + (h->blk_sz & 7u));
    }
    return n;
}
#endif

With the harness, the LLM created a header file as well:

// file: sparse_format.h
/*
 * Verbatim from QcomModulePkg/Library/FastbootLib/SparseFormat.h
 * (CodeLinaro tag LU.UM.3.5.1.r1-00700-QCS6490.0). Original AOSP/Qualcomm
 * license headers apply. Kept byte-identical so struct layout matches the
 * lifted parser exactly.
 */
#ifndef SPARSE_FORMAT_H
#define SPARSE_FORMAT_H

#include "edk2_shim.h"

typedef struct sparse_header {
  UINT32 magic;         /* 0xed26ff3a */
  UINT16 major_version; /* (0x1) - reject images with higher major versions */
  UINT16 minor_version; /* (0x0) - allow images with higer minor versions */
  UINT16 file_hdr_sz;   /* 28 bytes for first revision of the file format */
  UINT16 chunk_hdr_sz;  /* 12 bytes for first revision of the file format */
  UINT32 blk_sz;       /* block size in bytes, must be a multiple of 4 (4096) */
  UINT32 total_blks;   /* total blocks in the non-sparse output image */
  UINT32 total_chunks; /* total chunks in the sparse input image */
  UINT32
      image_checksum; /* CRC32 checksum of the original data, counting "don't
                         care" */
} sparse_header_t;

#define SPARSE_HEADER_MAGIC 0xed26ff3a

#define CHUNK_TYPE_RAW 0xCAC1
#define CHUNK_TYPE_FILL 0xCAC2
#define CHUNK_TYPE_DONT_CARE 0xCAC3
#define CHUNK_TYPE_CRC 0xCAC4

typedef struct chunk_header {
  UINT16 chunk_type; /* 0xCAC1 -> raw; 0xCAC2 -> fill; 0xCAC3 -> don't care */
  UINT16 reserved1;
  UINT32 chunk_sz; /* in blocks in output image */
  UINT32 total_sz; /* in bytes of chunk input file including chunk header and
                      data */
} chunk_header_t;

typedef struct SparseImgParams {
  UINT32 Chunk;
  UINT32 TotalBlocks;
  UINT64 ChunkDataSz;
  UINT64 ImageEnd;
  UINT64 WrittenBlockCount;
  UINT64 BlockCountFactor;
  UINT64 PartitionSize;
  EFI_BLOCK_IO_PROTOCOL *BlockIo;
  EFI_HANDLE *Handle;
} SparseImgParam;

#endif /* SPARSE_FORMAT_H */

There's one thing we haven't touched yet. The sparse_harness.c shows a call to SparseFuzzEntry but never defines it in the harness. That function symbol got placed in the lifted code:

// file: FastbootCmds_extract.c
EFI_STATUS
SparseFuzzEntry (VOID *Image, UINT64 sz)
{
  /* PartitionName is only touched by the (stubbed) partition lookup. */
  return HandleSparseImgFlash ((CHAR16 *)u"system", 6u, Image, sz);
}

HandleSparseImgFlash in that same file is a byte-identical copy of the repository code. It wants a real partition to flash to, so the extract fakes precisely that and nothing more: a 64 MiB heap buffer stands in for the system partition, GetPartitionSize returns its size, and WriteToDisk is replaced by a bounds-checked copy into that buffer. This is what the fuzzing harness that got created targets. The created flow looks like this:

fuzzer bytes
  -> sparse_harness.c : LLVMFuzzerTestOneInput
  -> SparseFuzzEntry             (adapter, in the harness)
  -> HandleSparseImgFlash        <- verbatim Qualcomm code
  -> HandleChunkTypeRaw / Fill   <- verbatim Qualcomm code
  -> WriteToDisk                 <- the ONLY stub in the chain (the oracle)

Everything above WriteToDisk is Qualcomm's unmodified code. That was more to discuss about the created structure than I had anticipated, so let's leave it at that, and I'll shorten it for the other examples. The takeaway is that the created setup around the harness is far from naive. The LLM tried to achieve a lot. Whether that was the correct choice is a discussion for another day.

Ultimately, what we care about is the following: Did the fuzzer reach that arithmetic we discussed, or just bounce off the header? I did analyze the coverage, which says it got all the way in: over 75% of lines and 100% of functions, and all four chunk types were covered. The fuzzer managed to run the whole chunk loop and reached the size math many, many times.

$ llvm-cov report ./sparse_fuzz -instr-profile=sparse.profdata FastbootCmds_extract.c

Filename                  Regions  Miss   Cover   Funcs  Miss    Cover    Lines  Miss   Cover   Branch  Miss   Cover
-------------------------------------------------------------------------------------------------------------------
FastbootCmds_extract.c        268    60  77.61%      8     0  100.00%      322    68  78.88%      116    32  72.41%

This resulted in zero crashes, and one thing that stands out as why that is seems to be the CHECK_ADD64 routine that guards every one of those add operations:

/* Return True if integer overflow will occur */
#define CHECK_ADD64(a, b) ((MAX_UINT64 - b < a) ? TRUE : FALSE)

This is something a human reviewer likely would have caught. Source code that's littered with safe-math checks. Even if the macro is defined in a different file, a modern IDE makes this a one-shortcut jump. It was a good effort. How good is this harness, really? Structurally, better than I went in expecting. The parser is lifted byte-for-byte, so I'm fuzzing Qualcomm's code here. Having this end-to-end harness + stub + shim + libfuzzer and AFL++ support in a single query would not have worked before. This makes this blog/research worthwhile.

This brings us to the end of harness one. For the other two that produced no crashes, I'll shorten some of the background story and focus on the what has been fuzzed, and reason about why that was the case. I'll spare you the full walkthrough as with the sparse_harness.c whenever the produced artifact(s) are nearly identical. Without further ado, let's go for the next one.

META images: offsets checked before use

META flashing (HandleMetaImgFlash, the same file and tag as the sparse one). This looks like another fastboot flash path. Instead of a single image, this takes a blob that packs several sub-images together and flashes them in one shot. The header also has some magic bytes (0xce1ad63c) and is followed by a table of entries. One entry per included image. Each entry contains a partition name and some start_offset and size values that point into the actual payload. Again, we have a loader that walks the structure, and when doing so, each section gets handed to a single image flasher: HandleRawImgFlash. This looks very similar to our sparse case. I can see why an LLM would pick this after the earlier harness.

META image
==========
   +---------------------------------------------------------+
   | meta_header : magic 0xce1ad63c, meta_hdr_sz, img_hdr_sz |
   +---------------------------------------------------------+
   | img_header_entry[0] : ptn_name[72], start_offset, size  |
   | img_header_entry[1] : ...                               |
   |  ... up to MAX_IMAGES_IN_METAIMG (32) entries ...       |
   +---------------------------------------------------------+
   | payload : sub-image bytes, addressed by each entry's    |
   |           (start_offset, size) into this region         |
   +---------------------------------------------------------+

As before, this path is interesting for fuzzing, as it would contain potentially attacker-controlled offset and size values. These are used in the loader to access the image structure and, from a naive first thought, could potentially be used to access out-of-bounds addresses. So yes, this is similar to sparse. When looking at the produced artifacts, the LLM used the same formula for this one too. As promised I will spare you with the details here. The LLM lifted the function HandleMetaImgFlash and wrote a similar-style harness with an adapter function:

// file: meta_harness.c
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdlib.h>

#include "edk2_shim.h"
#include "meta_format.h"

extern EFI_STATUS MetaFuzzEntry(VOID *Image, UINT64 Size);

int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size)
{
    if (Size < sizeof(meta_header_t))
        return 0;
    uint8_t *Image = (uint8_t *)malloc(Size);
    if (!Image)
        return 0;
    memcpy(Image, Data, Size);
    MetaFuzzEntry(Image, (UINT64)Size);
    free(Image);
    return 0;
}

Checking the coverage information shows it ran and covered what it set out to do:

$ llvm-cov report ./meta_fuzz -instr-profile=meta.profdata MetaImg_extract.c -show-functions

Name                  Regions  Miss   Cover    Lines  Miss   Cover   Branch  Miss   Cover
-----------------------------------------------------------------------------------------
HandleMetaImgFlash         80    18  77.50%       89    25  71.91%       36    10  72.22%
HandleRawImgFlash           5     0 100.00%        9     0 100.00%        2     0 100.00%
TOTAL                     100    21  79.00%      111    27  75.68%       42    10  76.19%

HandleMetaImgFlash sits at 71.9% coverage, with HandleRawImgFlash fully exercised. Again, we still found zero crashes. Yes, I know coverage doesn't guarantee crashes, but at least having it covered would have given us a chance... Doing some quick root-cause analysis on why no crashes have been spotted, it's sadly the same shape and form as with the sparse harness: Before a single byte is copied, each entry runs through our known CHECK_ADD64 on its offset arithmetic and then is followed by a hard range check, ImageEnd < Image + start_offset + size, that rejects the entry with EFI_INVALID_PARAMETER. Where the first harnesses fully relied on CHECK_ADD64 around its-size math, META adds an explicit end-of-buffer bound on top of it. Fair enough. The bottom line here is that there's not much to say about the shape and quality. It's almost an identical copy from start (why it was picked) to finish (how it was fuzzed) compared to before. Now for the third harness... sadly, it doesn't shake things up yet.

Boot image headers: overflow checks do their job

The third harness targets the boot image header validator in the function CheckImageHeader. This function is responsible for validating a boot.img header before the kernel is unpacked. Different things are getting computed, like kernel size, ramdisk, and dtb. These all go through macros like ROUND_TO_PAGE or ADD_OF. These are designed to prevent overflows:

/* ADD_OF: BootLib/LinuxLoaderLib.h 
 * ROUND_TO_PAGE: Include/Library/BootLinux.h 
 */
#define ADD_OF(a, b)         ((MAX_UINT32 - (b) > (a)) ? ((a) + (b)) : ZERO)
#define ROUND_TO_PAGE(x, y)  ((ADD_OF ((x), (y))) & (~(y)))

The fuzzed codebase has three types of header versions it checks: v0, v1, and v2. The harness exercised all three versions, plus the recovery DTBO branch. The harness is the same lift-and-stub recipe as sparse and META, so I will omit the "analysis" for brevity. Here's the generated harness:

// file: bootimg_harness.c
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdlib.h>

#include "edk2_shim.h"
#include "bootimg_format.h"

extern EFI_STATUS BootImgFuzzEntry(VOID *Buf, UINT32 Sz, BOOLEAN Recovery);

#define HDRBUF 4096   /* a boot header page; >= v0(1632)+v1(16)+v2(12) */

int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size)
{
    uint8_t *buf = (uint8_t *)calloc(1, HDRBUF);   /* zero-padded page buffer */
    if (!buf)
        return 0;
    memcpy(buf, Data, Size < HDRBUF ? Size : HDRBUF);
    memcpy(buf, BOOT_MAGIC, BOOT_MAGIC_SIZE);      /* pass the magic gate */

    BootImgFuzzEntry(buf, HDRBUF, FALSE);          /* non-recovery path */
    BootImgFuzzEntry(buf, HDRBUF, TRUE);           /* recovery (v1/v2 dtbo) path */

    free(buf);
    return 0;
}

Checking the coverage, if that were our only metric to go by, we'd be happy:

$ llvm-cov report ./bootimg_fuzz -instr-profile=bootimg.profdata BootImg_extract.c

Filename            Regions  Miss  Cover    Funcs  Miss   Cover    Lines  Miss  Cover   Branch  Miss  Cover
---------------------------------------------------------------------------------------------------------
BootImg_extract.c       152     7  95.39%       3     0  100.00%      140     7  95.00%      62     3  95.16%

95% of lines and every function were entered, with a handful of missed lines sitting in a branch, which was gated behind something the harness did not model: DTBO_MAX_SIZE_ALLOWED. Again, we have seen no crashes. The constant use of ADD_OF returns ZERO instead of wrapping, and CheckImageHeader reads a zero result as "integer overflow" and bails out with EFI_BAD_BUFFER_SIZE. As soon as the fuzzer triggers a 32-bit wraparound, the fuzzed code exits early.

This brings me to the end of the third harness. This harness again re-used the same formula of lifting, shimming, and targeting a single API. What the LLM failed to grasp, for a third time in a row now, is that the function is "gated" behind overflow-safe math macros. The LLM targeted the function for the right reasons: attacker-controlled input data and potentially unsafe size and offset math, but it stopped there with the "analysis" of "is this worth fuzzing". Let's take a look at the remaining two. They at least bring something new to the table.

GUID Partition Tables: the first crash

This is the first harness that, when looking at the results, surprised me. The GPT writer (PatchGptWriteGpt, ParseGptHeader) could lead to a partition table being rewritten from an attacker-supplied image, using header-driven pointer arithmetic.

GPT flash image (attacker-supplied, in the download buffer)
==========================================================

   LBA 0   +-------------------------------------------------+
           | Protective MBR                                  |
   LBA 1   +-------------------------------------------------+
           | Primary GPT header : "EFI PART", HeaderCRC,     |  <- ParseGptHeader
           |   PartEntrySz = 128, MaxPtCnt (<= 128)          |     validates (CRC-32)
   LBA 2+  +-------------------------------------------------+
           | Partition entry array : MaxPtCnt slots x 128 B  |  <- PatchGpt walks it,
           |   [entry 0][entry 1] ... [entry MaxPtCnt-1]     |     counting populated
           +-------------------------------------------------+
           |  ... data ...  backup array  ...  backup header |
           +-------------------------------------------------+

At first glance it looks like the LLM used the same "winning recipe" (for creating the harness, not for finding 0-days) once more. It found a parser. The parser is doing some arithmetic operations. Those, based on historic knowledge, have a tendency to be prone to over- or underflows. A quick read shows a partition-entry walk that computes (count - 1) * entry_size with no guard on count being zero. I assume this was as well yet another reason a harness was built around this section of the code. The harness itself follows the same recipe once more:PatchGptWriteGpt, and ParseGptHeader are lifted verbatim. The on-storage device I/O is stubbed.

// file: harness_gpt.c
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdlib.h>

#include "edk2_shim.h"
#include "gpt_format.h"

extern EFI_STATUS GptFuzzEntry (VOID *Buf, UINT32 Sz);

static void put_u32 (uint8_t *p, uint32_t v)
{
  p[0] = v; p[1] = v >> 8; p[2] = v >> 16; p[3] = v >> 24;
}
static void put_u64 (uint8_t *p, uint64_t v)
{
  for (int i = 0; i < 8; i++) p[i] = (v >> (8 * i)) & 0xff;
}
static uint32_t get_u32 (const uint8_t *p)
{
  return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
         ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}

/* Make one GPT header pass ParseGptHeader while leaving MaxPtCnt and the LBAs
 * fuzzer-derived (clamped into the accepted range). Primary headers must carry
 * CurrentLba == GPT_LBA; secondary headers skip that check. */
static void repair_header (uint8_t *h, int primary)
{
  put_u32 (h + 0, GPT_SIGNATURE_2);
  put_u32 (h + 4, GPT_SIGNATURE_1);
  put_u32 (h + HEADER_SIZE_OFFSET, GPT_HEADER_SIZE);      /* 92 */
  put_u32 (h + PENTRY_SIZE_OFFSET, GPT_PART_ENTRY_SIZE);  /* 128 */
  if (primary)
    put_u64 (h + PRIMARY_HEADER_OFFSET, GPT_LBA);         /* CurrentLba == 1 */
  /* keep LBAs within DeviceDensity/BlkSz so the capacity checks pass */
  put_u64 (h + FIRST_USABLE_LBA_OFFSET, get_u32 (h + FIRST_USABLE_LBA_OFFSET) & 0xffff);
  put_u64 (h + LAST_USABLE_LBA_OFFSET,  get_u32 (h + LAST_USABLE_LBA_OFFSET)  & 0xffff);
  /* clamp MaxPtCnt into [0,128] (0 is accepted by the real validation) */
  put_u32 (h + PARTITION_COUNT_OFFSET,
           get_u32 (h + PARTITION_COUNT_OFFSET) % (MAX_NUM_PARTITIONS + 1));
  /* recompute header CRC over HeaderSz bytes with the CRC field zeroed */
  put_u32 (h + HEADER_CRC_OFFSET, 0);
  uint32_t crc = 0;
  ShimCalculateCrc32 (h, GPT_HEADER_SIZE, &crc);
  put_u32 (h + HEADER_CRC_OFFSET, crc);
}

int LLVMFuzzerTestOneInput (const uint8_t *Data, size_t Size)
{
  uint8_t *buf = (uint8_t *)calloc (1, GPT_SCRATCH);
  if (!buf)
    return 0;
  memcpy (buf, Data, Size < GPT_SCRATCH ? Size : GPT_SCRATCH);

  /* protective MBR at LBA0 -> route PartitionGetType to the GPT branch */
  buf[MBR_SIGNATURE]     = MBR_SIGNATURE_BYTE_0;
  buf[MBR_SIGNATURE + 1] = MBR_SIGNATURE_BYTE_1;
  buf[MBR_PARTITION_RECORD + OS_TYPE] = GPT_PROTECTIVE;

  /* PartEntrySz==128 & MaxPtCnt<=128 pin PartEntryArrSz to MIN_PARTITION_ARRAY_SIZE,
   * so WriteGpt places the backup header at:
   *   SecondaryGptHdr = Gpt + 2*BlkSz + 2*PartEntryArrSz
   * (PrimaryGptHdr = Gpt + BlkSz, then Offset=2*PartEntryArrSz + BlkSz on top). */
  repair_header (buf + GPT_BLKSZ, 1);                                       /* primary */
  repair_header (buf + 2 * GPT_BLKSZ + 2 * MIN_PARTITION_ARRAY_SIZE, 0);    /* backup  */

  /* Sz models the download size; keep the trailing SetMem(PrimaryGptHdr, Sz)
   * inside the scratch region (buf + BlkSz + Sz <= GPT_SCRATCH). */
  GptFuzzEntry (buf, GPT_SCRATCH - GPT_BLKSZ);

  free (buf);
  return 0;
}

The GptFuzzEntry stub looks like this:

EFI_STATUS
GptFuzzEntry (VOID *Buf, UINT32 Sz)
{
  FlashingGpt = FALSE;
  ParseSecondaryGpt = FALSE;
  return UpdatePartitionTable ((UINT8 *)Buf, Sz, 0, (struct StoragePartInfo *)0);
}

Two choices the LLM made here are specific to this target:

  1. The driver keeps a real CRC-32 in the loop instead of stubbing it to always pass. The harness also repairs the header each iteration so the fuzzer reaches the arithmetic through a valid checksum rather than around a disabled one. ShimCalculateCrc32 is a real CRC calculation. It's not just a stub that returns "OK". This in turn should mean the GPT image has a valid shape.
  2. The driver backs the parser with a 512 KiB scratch buffer that models the fastboot download region.

To reach the aforementioned arithmetic at all, ParseGptHeader has to accept the image twice, once for the primary header and once for the backup that sits after the entry array. Additionally, it requires a valid EFI PART signature, a header size between 92 and the block size, a correct CRC32, first and last usable LBAs inside the device capacity, a partition-entry size of exactly 128, and a partition count no larger than 128. These constraints were fully identified by the LLM and put inside the harness in the repair_header function. The discussed double parsing can be seen in the WriteGpt function:

// file: PartitionTableUpdate.c
STATIC UINT32 
WriteGpt (INT32 Lun, UINT32 Sz, UINT8 *Gpt) 
{
  // <SNIP>

  /* Verity that passed block has valid GPT primary header */
  PrimaryGptHdr = (Gpt + BlkSz);
  Ret = ParseGptHeader (&GptHeader, PrimaryGptHdr, DeviceDensity, BlkSz);
  if (Ret) {
    DEBUG ((EFI_D_ERROR, "GPT: Error processing primary GPT header\n"));
    return Ret;
  }

  /* Check if a valid back up GPT is present */
  PartEntryArrSz = GptHeader.PartEntrySz * GptHeader.MaxPtCnt;
  if (PartEntryArrSz < MIN_PARTITION_ARRAY_SIZE)
    PartEntryArrSz = MIN_PARTITION_ARRAY_SIZE;

  /* Back up partition is stored in the reverse order with back GPT, followed by
   * part entries, find the offset to back up GPT */
  Offset = (2 * PartEntryArrSz);
  SecondaryGptHdr = Offset + BlkSz + PrimaryGptHdr;
  Ret = ParseGptHeader (&GptHeader, SecondaryGptHdr, DeviceDensity, BlkSz);
  if (Ret) {
    DEBUG ((EFI_D_ERROR, "GPT: Error processing backup GPT header\n"));
    return Ret;
  }

  Ret = PatchGpt (Gpt, DeviceDensity, PartEntryArrSz, &GptHeader, BlkSz);

  // <SNIP>
}

The backup header is located 2 * PartEntryArrSz + BlkSz past the primary, so it sits after the entry array, and both calls have to return zero before control ever reaches PatchGpt. The LLM seems to kind of understood the difficulty for a fuzzer to reach deep here and added the header repairs accordingly. The fuzzer was left on autopilot for two things specifically: the partition count and the entry-array bytes. And surprisingly, the fuzzer found something:

$ ./gpt_fuzz findings/gpt_underflow_repro_maxptcnt0.bin
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 3122206987
INFO: Loaded 1 modules   (959 inline 8-bit counters): 959 [0x55d047914318, 0x55d0479146d7),
INFO: Loaded 1 PC tables (959 PCs): 959 [0x55d0479146d8,0x55d0479182c8),
./gpt_fuzz: Running 1 inputs 1 time(s) each.
Running: findings/gpt_underflow_repro_maxptcnt0.bin
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1553839==ERROR: AddressSanitizer: SEGV on unknown address 0x7f0c83679ba8 (pc 0x55d047892639 bp 0x7ffe36738380 sp 0x7ffe36738160 T0)
==1553839==The signal is caused by a WRITE memory access.
    #0 0x55d047892639 in PatchGpt /home/pwn/abl-sparse-fuzz/PartitionTable_extract.c:290:3
    #1 0x55d047892639 in WriteGpt /home/pwn/abl-sparse-fuzz/PartitionTable_extract.c:396:9
    #2 0x55d0478913e7 in UpdatePartitionTable /home/pwn/abl-sparse-fuzz/PartitionTable_extract.c:489:11
    #3 0x55d04788f9d4 in LLVMFuzzerTestOneInput /home/pwn/abl-sparse-fuzz/harness_gpt.c:89:3
    #4 0x55d0475fcefb in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) fuzzer.o
    #5 0x55d0475e2338 in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) fuzzer.o
    #6 0x55d0475eb644 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) fuzzer.o
    #7 0x55d0475d0bd7 in main (/home/pwn/abl-sparse-fuzz/gpt_fuzz+0x46bd7) (BuildId: 0129ff498bbb865459d0fc684e4591092aafebb0)
    #8 0x7f0b83427c8d  (/usr/lib/libc.so.6+0x27c8d) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #9 0x7f0b83427dca in __libc_start_main (/usr/lib/libc.so.6+0x27dca) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #10 0x55d0475d0ca4 in _start (/home/pwn/abl-sparse-fuzz/gpt_fuzz+0x46ca4) (BuildId: 0129ff498bbb865459d0fc684e4591092aafebb0)

==1553839==Register values:
rax = 0x0000000000000000  rbx = 0x00007ffe36738160  rcx = 0x0000000000000200  rdx = 0x0000000007ffffde
rdi = 0x0000000000000002  rsi = 0x00007f0b83679a00  rbp = 0x00007ffe36738380  rsp = 0x00007ffe36738160
 r8 = 0x0000000000000021   r9 = 0x0000000000000000  r10 = 0x0000000000000000  r11 = 0x0000000000000000
r12 = 0x00000000ffffffa8  r13 = 0x00007f0c83679ba8  r14 = 0x00007f0b83679c00  r15 = 0x0000000000004000
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV /home/pwn/abl-sparse-fuzz/PartitionTable_extract.c:290:3 in PatchGpt
==1553839==ABORTING

The bug sits in PatchGpt, here is the relevant section:

while ((TotalPart < GptHeader->MaxPtCnt) &&
       ((*LastPartitionEntry != 0) || (*(LastPartitionEntry + 1) != 0))) {
  TotalPart++;
  LastPartitionEntry = (UINT64 *)
    (PrimaryGptHeader + BlkSz + TotalPart * PARTITION_ENTRY_SIZE);
}
LastPartOffset = (TotalPart - 1) * PARTITION_ENTRY_SIZE + PARTITION_ENTRY_LAST_LBA;
PUT_LONG_LONG (PrimaryGptHeader + BlkSz + LastPartOffset, (UINT64)(NumSectors - 34));

If the entry array is empty, the loop never runs, TotalPart stays zero, and (TotalPart - 1) underflows the UINT32LastPartOffset resolves to 0xFFFFFFA8, so PUT_LONG_LONG writes eight bytes about four gigabytes past the buffer. The saved reproducer literally contains nothing but zeros:

$ xxd findings/gpt_underflow_repro_maxptcnt0.bin
00000000: 0000 0000 0000 0000 0000 0000 0000 0000  ................
<...>
000085f0: 0000 0000 0000 0000 0000 0000 0000 0000  ................

The thing that makes this an awkward bug to talk about is the fact that the required input to trigger this bug in particular is just the empty input as well as minimized/found by libfuzzer:

$ xxd crash-da39a3ee5e6b4b0d3255bfef95601890afd80709
ls -lh ./crash-da39a3ee5e6b4b0d3255bfef95601890afd80709
-rw-r--r-- 1 pwn pwn 0 Aug 22 17:58 ./crash-da39a3ee5e6b4b0d3255bfef95601890afd80709
$ ./gpt_fuzz ./crash-da39a3ee5e6b4b0d3255bfef95601890afd80709
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 4152867095
INFO: Loaded 1 modules   (959 inline 8-bit counters): 959 [0x5618b5018318, 0x5618b50186d7),
INFO: Loaded 1 PC tables (959 PCs): 959 [0x5618b50186d8,0x5618b501c2c8),
./gpt_fuzz: Running 1 inputs 1 time(s) each.
Running: ./crash-da39a3ee5e6b4b0d3255bfef95601890afd80709
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1556384==ERROR: AddressSanitizer: SEGV on unknown address 0x7fdbe83f1ba8 (pc 0x5618b4f96639 bp 0x7ffde6f712e0 sp 0x7ffde6f710c0 T0)
==1556384==The signal is caused by a WRITE memory access.
    #0 0x5618b4f96639 in PatchGpt /home/pwn/abl-sparse-fuzz/PartitionTable_extract.c:290:3
<SNIP>

It's definitely not a useful bug. Whether it is a bug worth anyone's time is out of scope for now. I did take a look at this when triaging , and it seems to be at best a low severity one:

  • Reachability - CmdFlash -> UpdatePartitionTable -> WriteGpt -> ParseGptHeader (primary) -> ParseGptHeader (backup) -> PatchGpt. The input could be a downloaded flash image that needs to be fully attacker-controlled. This seems like it could be somehow pulled off, but the path to trigger the bug itself is more than gated.
  • Preconditions - From a quick look, CmdFlash seems to refuse flashing at all unless the device is unlocked and refuses critical partitions unless unlock-critical is also set. Also, as seen above, the maliciously crafted GPT image needs primary and backup headers, both of which need to pass through ParseGptHeader, with either a declared partition count of zero or a zeroed first entry so the walk ends at TotalPart == 0.
  • Impact - Meh

So this is not even worth reporting, so I did not. It's just a bug, not a vulnerability as far as I'm concerned. I did not have high hopes to find anything to begin with, so having this at all at this stage is surprising to me as I picked that repo at random. But on the bright side of things, we still got our last harness and, actually, a second bug. One interesting thing with this one is that the LLM picked up on all the conditions that needed to be satisfied. It built the repair_header for that. This is a significantly better understanding about the environment compared to the three earlier harnesses that were "only" gates by some arithmetic-safe math.

Device-tree glue: valid trees, unsafe strings

Okay, close to the end, last harness, last bugs. Let's get into it right away. This one is by far the most interesting one for multiple reasons. The device-tree glue (UpdateDeviceTree.c) that this resolves around is not a hand-rolled parser as in all cases before. It really is just glue on top of libfdt. libfdt is the standard flattened device tree library. While the library itself has not been fuzzed to death in OSS-FUZZ (as far as I could tell), it's definitely being pulled in by U-Boot and QEMU. Maybe fuzzing libfdt itself could be a nice endeavor, but this here is all about fuzzing the Qualcomm code sitting on top: the parts that take a property libfdt hands back and treat it like a trusted, null-terminated C string with a sane length.

Appended device tree (inside the AVB-verified boot image)
=========================================================

  boot.img
    +-- kernel
    +-- ramdisk
    +-- dtb  -->  flattened device tree, parsed by libfdt
                    |
                    +-- /firmware/android/fstab/<x>/dev = "...,/soc/..."  <- UpdateFstabNode
                    +-- /firmware/android/vbmeta         parts = "odm,..." <- UpdateVbmetaNode

The harness that was being built is mostly re-using the same recipe as all others as well. The functions of interest that are the bridge between the Qualcomm code and the libfdt side are lifted verbatim (UpdateFstabNodeUpdateVbmetaNodeQueryMemoryCellSize, and UpdateGranuleInfo). Instead of stubbing the device-tree library, the LLM decided to link the real one that was present on the sandbox I provided (libfdt 1.7.2 dynamically linked via -lfdt, and uninstrumented).

So before we jump into the findings, I noticed that the LLM made a particular decision for the harness that seemed to have made the whole thing work in the first place. The core problem with a byte mutator from a fuzzer is that no amount of random mutations (without guidance) will likely yield a valid device tree (as this is a complex structure). On the other hand, if we provide a semi-malformed blob to the Qualcomm glue code, it gets handed straight to the libfdt side of things. This likely would cause libfdt to crash or, more likely, discard such an input for further processing due to its own internal checks. The goal of this harness was not to fuzz libfdt itself but the Qualcomm-written glue. So what the LLM did now was that every fuzzer-generated input goes through fdt_check_full, a libfdt internal function that checks for malformations. Only those inputs that pass this check are structurally valid and "deemed" good enough to be passed to the lifted Qualcomm code. The harness itself is following the same shape and form as highlighted in the first half of the article, so I'll just dump the DtbFuzzEntry function, which is the actual entry point the LLVMFuzzerTestOneInput harness calls. I'm doing so because this time around it's not a single API but a linear execution of these also-aforementioned multiple API calls.

// file: UpdateDeviceTree_lifted.c
EFI_STATUS
DtbFuzzEntry (VOID *FdtBuf, UINTN Cap)
{
  UINT32 CellLen = 0;

  /* gate: only structurally valid device trees get past here */
  if (fdt_check_full (FdtBuf, (size_t)Cap) != 0)
    return EFI_NOT_FOUND;
  if (fdt_open_into (FdtBuf, FdtBuf, (int)Cap) != 0)
    return EFI_NOT_FOUND;

  /* everything below is lifted QcomModulePkg glue, run on a tree libfdt called valid */
  fdt_check_header_ext (FdtBuf);
  QueryMemoryCellSize (FdtBuf, &CellLen);
  UpdateGranuleInfo (FdtBuf);
  UpdateVbmetaNode (FdtBuf, (CHAR8 *)"odm", NULL);
  UpdateFstabNode (FdtBuf);

  return EFI_SUCCESS;
}

To summarize: libfdt vouches for the structure, which I think was a smart move by the LLM, and the glue then trusts whatever content sits inside that structure. So the harness ends up testing the exact thing I care about: does the Qualcomm code hold up when a device tree is well-formed but its property values are hostile?

Limitation The device tree these functions rewrite is not a loose file an attacker can drop on the device. It is baked inside a boot image itself, and on Android the boot image is checked by AVB (Android Verified Boot) before anything in it is used. Tamper with the tree on a locked device and verification fails. The phone stops booting and the modified tree never reaches the bug site.

Therefore, read everything below as post-unlock. Any of the following bugs will only be reached if the device is unlocked or if there's already a separate AVB bypass. Now let's dive into the findings!

fstab: a missing slash becomes a null dereference

UpdateFstabNode has a small job. The device tree ships with an fstab entry, the table that tells Android which storage partition to mount as root, and this function rewrites the boot-device path in that entry before the kernel reads it. To do the rewrite, it takes the existing dev string, finds the /soc/ marker inside it, and then searches for the next / after that marker to find where the old path ends. That linked search is where the bug sits:

// file: UpdateDeviceTree_lifted.c

// <SNIP>

ReplaceStr += AsciiStrLen (Table.DevicePathId);
NextStr = AsciiStrStr ((ReplaceStr + 1), "/");
DevNodeBootDevLen = NextStr - ReplaceStr;  // NextStr may be NULL
if (DevNodeBootDevLen >= AsciiStrLen (BootDevBuf)) {
  gBS->CopyMem (ReplaceStr, BootDevBuf, AsciiStrLen (BootDevBuf));
  PaddingEnd = DevNodeBootDevLen - AsciiStrLen (BootDevBuf);
  if (PaddingEnd) {
    gBS->CopyMem (ReplaceStr + AsciiStrLen (BootDevBuf), NextStr,
                  AsciiStrLen (NextStr));  // reads through NULL
    for (Index = 0; Index < PaddingEnd; Index++) {  // wild write, never reached
      ReplaceStr[AsciiStrLen (BootDevBuf) + AsciiStrLen (NextStr) + Index] = ' ';
    }
  }
}

In the above snippet, we can see the relevant code. NextStr is being used in the calculation of DevNodeBootDevLen without ever verifying whether the / was actually found.

Limitation After some investigation I found that this whole branch that was fuzzed only runs on builds where IsDynamicPartitionSupport() is false, so a modern device (e.g. Android 10+) using dynamic partitions never reaches it this bug at all.

Given a dev value that has the marker but no trailing /  in it, the search returns NULL. NextStr - ReplaceStr is then NULL minus a valid pointer (0 - ReplaceStr), which first underflows into a gigantic length and straight after runs into a call to AsciiStrLen(NextStr) which will cause a NULL-ptr dereference:

$ ./dtb_fuzz findings/dtb_fstab_nullderef_repro.dtb
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 3038691496
INFO: Loaded 1 modules   (243 inline 8-bit counters): 243 [0x55b073317a00, 0x55b073317af3),
INFO: Loaded 1 PC tables (243 PCs): 243 [0x55b073317af8,0x55b073318a28),
./dtb_fuzz: Running 1 inputs 1 time(s) each.
Running: findings/dtb_fstab_nullderef_repro.dtb
dtb_format.h:47:67: runtime error: null pointer passed as argument 1, which is declared to never be null
/usr/include/string.h:440:33: note: nonnull attribute specified here
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior dtb_format.h:47:67
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1553786==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x7f5dda3aeddd bp 0x7ffce9ec7110 sp 0x7ffce9ec68b8 T0)
==1553786==The signal is caused by a READ memory access.
==1553786==Hint: address points to the zero page.
    #0 0x7f5dda3aeddd  (/usr/lib/libc.so.6+0x1aeddd) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #1 0x55b07318a419 in strlen.part.0 asan_interceptors.cpp.o
    #2 0x55b07329e753 in AsciiStrLen /home/pwn/abl-sparse-fuzz/./dtb_format.h:47:59
    #3 0x55b07329e753 in UpdateFstabNode /home/pwn/abl-sparse-fuzz/UpdateDeviceTree_extract.c:348:25
    #4 0x55b07329ee41 in DtbFuzzEntry /home/pwn/abl-sparse-fuzz/UpdateDeviceTree_extract.c:389:3
    #5 0x55b07329b99a in LLVMFuzzerTestOneInput /home/pwn/abl-sparse-fuzz/harness_dtb.c:30:3
    #6 0x55b073008fbb in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) fuzzer.o
    #7 0x55b072fee3f8 in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) fuzzer.o
    #8 0x55b072ff7704 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) fuzzer.o
    #9 0x55b072fdcc97 in main (/home/pwn/abl-sparse-fuzz/dtb_fuzz+0x40c97) (BuildId: e8bb876687682f2b52c55a9bc654d4d309e8d47a)
    #10 0x7f5dda227c8d  (/usr/lib/libc.so.6+0x27c8d) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #11 0x7f5dda227dca in __libc_start_main (/usr/lib/libc.so.6+0x27dca) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #12 0x55b072fdcd64 in _start (/home/pwn/abl-sparse-fuzz/dtb_fuzz+0x40d64) (BuildId: e8bb876687682f2b52c55a9bc654d4d309e8d47a)

==1553786==Register values:
rax = 0x0000000000000000  rbx = 0x0000000000000000  rcx = 0x0000000000000000  rdx = 0x0000000000000000
rdi = 0x0000000000000000  rsi = 0x0000000000000000  rbp = 0x00007ffce9ec7110  rsp = 0x00007ffce9ec68b8
 r8 = 0x00007b5dd7e003d0   r9 = 0x000055b073374b00  r10 = 0x00007ffce9ec7140  r11 = 0x0000000000000202
r12 = 0x00007f5dd9d318b6  r13 = 0xffff80a2262ce74a  r14 = 0x00000000e102dbd3  r15 = 0x0000000000000000
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (/usr/lib/libc.so.6+0x1aeddd) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
==1553786==ABORTING

We can take a closer look at the reproducer, and we will see at offset 0xa0 the fstab device that got thrown into the parser: /soc/x:

$ xxd findings/dtb_fstab_nullderef_repro.dtb
00000000: d00d feed 0000 0105 0000 0038 0000 00e0  ...........8....
00000010: 0000 0028 0000 0011 0000 0010 0000 0000  ...(............
00000020: 0000 0025 0000 00a8 0000 0000 0000 0000  ...%............
00000030: 0000 0000 0000 0000 0000 0001 0000 0000  ................
00000040: 0000 0003 0000 0004 0000 0000 0000 0002  ................
00000050: 0000 0003 0000 0004 0000 000f 0000 0002  ................
00000060: 0000 0001 6669 726d 7761 7265 0000 0000  ....firmware....
00000070: 0000 0001 616e 6472 6f69 6400 0000 0001  ....android.....
00000080: 6673 7461 6200 0000 0000 0001 7665 6e64  fstab.......vend
00000090: 6f72 0000 0000 0003 0000 0007 0000 001b  or..............
000000a0: 2f73 6f63 2f78 0000 0000 0002 0000 0002  /soc/x..........
000000b0: 0000 0001 7662 6d65 7461 0000 0000 0003  ....vbmeta......
000000c0: 0000 0001 0000 001f 0000 0000 0000 0002  ................
000000d0: 0000 0002 0000 0002 0000 0002 0000 0009  ................
000000e0: 2361 6464 7265 7373 2d63 656c 6c73 0023  #address-cells.#
000000f0: 7369 7a65 2d63 656c 6c73 0064 6576 0070  size-cells.dev.p
00000100: 6172 7473 00                             arts.

A real entry would look something like /dev/block/platform/soc/1d84000.ufshc/by-name/system, where /soc/ is followed by a device node and then another /.

  • Preconditions - To trigger this, we need a build that has dynamic partitions disabled, which, for example, is pre-Android 10 era. Older embedded devices may still reach here by default.
  • Impact - More Meh

Sadly, yet another boring bug, but let's continue ... we have more!

vbmeta: a focused harness finds two memory-safety bugs

The next bug is in the same file, in UpdateVbmetaNode, and its job is the mirror image of the last one. Instead of splicing a string in, it takes the vbmeta node's parts property (a comma-separated list of partition names) and removes one entry, odm, from it. To accomplish that, it first copies the entire parts string into a fixed 12800-byte scratch bufferusing the string's own length as the copy size with no upper bound. So if one hands this a parts string longer than 12800 bytes, it will cause a heap buffer overflow.

That said, there's a catch. The harness created by the LLM mutates the whole DTB. So where's the issue? We recall that a device-tree object is a complex structure. Each property in it records its own length right before its data, and the header records the total size of the tree. To make parts bigger, a fuzzer would have to make the underlying data larger, increase the size field accordingly, and update the header. All in a single mutation pass. That's too complex of a job for a basic mutation strategy. Random byte-flipping never lands that combination, so any mutation large enough to overflow leaves the tree structurally broken, and libfdt's own fdt_check_full discards such a broken tree before any parsing happens. Somehow the LLM caught this and built another second harness (technically we're sitting at six harnesses now) around this specific issue. So it not only disregarded my "build up to 5 harnesses", it even went beyond that. This, let's call it "optimized" harness focuses solely on the parts value and then wraps a minimal valid device tree around by using libfdt:

// file: harness_dtb_vbmeta.c
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdlib.h>

#include "edk2_shim.h"
#include <libfdt.h>

extern EFI_STATUS UpdateVbmetaNode (VOID *fdt, CHAR8 *OldPartStr, CHAR8 *NewPartStr);

#define VB_CAP (256u * 1024u)

/* Build /firmware/android/vbmeta with parts = data[0..len) (NUL-terminated so
 * AsciiStrLen == the fuzzer-controlled length), then run the glue. */
static void run_parts (const uint8_t *data, size_t len)
{
  if (len > VB_CAP / 3)               /* keep the DTB build well inside VB_CAP */
    return;
  uint8_t *buf = (uint8_t *)calloc (1, VB_CAP);
  char    *parts = (char *)malloc (len + 1);
  if (!buf || !parts) { free (buf); free (parts); return; }
  if (len)
    memcpy (parts, data, len);
  parts[len] = '\0';                  /* AsciiStrLen(parts) == first NUL, else len */

  if (fdt_create_empty_tree (buf, VB_CAP) == 0) {
    int fw = fdt_add_subnode (buf, 0, "firmware");
    int an = fw >= 0 ? fdt_add_subnode (buf, fw, "android") : fw;
    int vb = an >= 0 ? fdt_add_subnode (buf, an, "vbmeta") : an;
    if (vb >= 0 &&
        fdt_setprop (buf, vb, "parts", parts, (int)len + 1) == 0) {
      UpdateVbmetaNode (buf, (CHAR8 *)"odm", NULL);
    }
  }
  free (parts);
  free (buf);
}

int LLVMFuzzerTestOneInput (const uint8_t *Data, size_t Size)
{
  run_parts (Data, Size);
  return 0;
}

With the fuzzer input going straight in as the parts value. It triggers the CopyMem overflow immediately.

$ ./dtb_vbmeta_fuzz findings/vbmeta_copymem_overflow_repro.bin
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1724910399
INFO: Loaded 1 modules   (254 inline 8-bit counters): 254 [0x55a8e4fe0a80, 0x55a8e4fe0b7e),
INFO: Loaded 1 PC tables (254 PCs): 254 [0x55a8e4fe0b80,0x55a8e4fe1b60),
./dtb_vbmeta_fuzz: Running 1 inputs 1 time(s) each.
Running: findings/vbmeta_copymem_overflow_repro.bin
=================================================================
==1554415==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7d6d3c5edb00 at pc 0x55a8e4f03988 bp 0x7ffc8b2cf8f0 sp 0x7ffc8b2cf0b0
WRITE of size 13000 at 0x7d6d3c5edb00 thread T0
    #0 0x55a8e4f03987 in __asan_memmove (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x29e987) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)
    #1 0x55a8e4f681f6 in ShimCopyMem /home/pwn/abl-sparse-fuzz/./dtb_format.h:68:55
    #2 0x55a8e4f66630 in UpdateVbmetaNode /home/pwn/abl-sparse-fuzz/UpdateDeviceTree_extract.c:177:5
    #3 0x55a8e4f64b5b in run_parts /home/pwn/abl-sparse-fuzz/harness_dtb_vbmeta.c:50:7
    #4 0x55a8e4f64b5b in LLVMFuzzerTestOneInput /home/pwn/abl-sparse-fuzz/harness_dtb_vbmeta.c:59:3
    #5 0x55a8e4cd1fbb in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) fuzzer.o
    #6 0x55a8e4cb73f8 in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) fuzzer.o
    #7 0x55a8e4cc0704 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) fuzzer.o
    #8 0x55a8e4ca5c97 in main (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x40c97) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)
    #9 0x7efd3d427c8d  (/usr/lib/libc.so.6+0x27c8d) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #10 0x7efd3d427dca in __libc_start_main (/usr/lib/libc.so.6+0x27dca) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #11 0x55a8e4ca5d64 in _start (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x40d64) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)

0x7d6d3c5edb00 is located 0 bytes after 12800-byte region [0x7d6d3c5ea900,0x7d6d3c5edb00)
allocated by thread T0 here:
    #0 0x55a8e4f07869 in calloc (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x2a2869) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)
    #1 0x55a8e4f664bf in AllocateZeroPool /home/pwn/abl-sparse-fuzz/./edk2_shim.h:100:59
    #2 0x55a8e4f664bf in UpdateVbmetaNode /home/pwn/abl-sparse-fuzz/UpdateDeviceTree_extract.c:150:21
    #3 0x55a8e4f64b5b in run_parts /home/pwn/abl-sparse-fuzz/harness_dtb_vbmeta.c:50:7
    #4 0x55a8e4f64b5b in LLVMFuzzerTestOneInput /home/pwn/abl-sparse-fuzz/harness_dtb_vbmeta.c:59:3
    #5 0x55a8e4cd1fbb in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) fuzzer.o
    #6 0x55a8e4cb73f8 in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) fuzzer.o
    #7 0x55a8e4cc0704 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) fuzzer.o
    #8 0x55a8e4ca5c97 in main (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x40c97) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)
    #9 0x7efd3d427c8d  (/usr/lib/libc.so.6+0x27c8d) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #10 0x7ffc8b2d1c02  (<unknown module>)

SUMMARY: AddressSanitizer: heap-buffer-overflow (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x29e987) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4) in __asan_memmove
Shadow bytes around the buggy address:
  0x7d6d3c5ed880: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7d6d3c5ed900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7d6d3c5ed980: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7d6d3c5eda00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7d6d3c5eda80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x7d6d3c5edb00:[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7d6d3c5edb80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7d6d3c5edc00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7d6d3c5edc80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7d6d3c5edd00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7d6d3c5edd80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==1554415==ABORTING

Reading that trace top to bottom had me questioning the result at first, as frame 0 is in the harness itself. Frame 1 is inside the shim for ShimCopyMem:

// file: dtb_format.h

// <SNIP>
/* --- gBS subset the glue calls --------------------------------------- */
static VOID ShimCopyMem (VOID *d, VOID *s, UINTN n) { memmove (d, s, (size_t)n); }
static VOID ShimSetMem  (VOID *b, UINTN n, UINT8 v) { memset (b, v, (size_t)n); }
typedef struct {
  VOID (*CopyMem) (VOID *Dst, VOID *Src, UINTN Len);
  VOID (*SetMem)  (VOID *Buf, UINTN Len, UINT8 Val);
} SHIM_BOOT_SERVICES;
static SHIM_BOOT_SERVICES ShimBS = { ShimCopyMem, ShimSetMem };
static SHIM_BOOT_SERVICES *gBS = &ShimBS;

// <SNIP>

ShimCopyMem is just a memmove, standing in for the real gBS->CopyMem (a length-bounded, overlap-safe copy, which is exactly what memmove is), so it is mostly a truthful stub and not the source of the bug. The part that matters is frame 2: UpdateVbmetaNode calling that copy with AsciiStrLen(Prop->data) as the length and nothing bounding it against the 12800-byte destination (see earlier). Interestingly enough, in the same function, right next to the above bug is a string operation that removes a partition from the list ends with a decrement and a write:

// file: UpdateDeviceTree_extract.c  
if (!NewPartStr && !RestParts)
  ReplaceStr = ReplaceStr - 1;
*ReplaceStr = '\0';            // one byte before PartitionString[0]

UpdateVbmetaNode is called with "odm" as the partition to strip. If parts begins with odm and has no comma after it, RestParts will turn into NULL and ReplaceStr still points at the first byte of the buffer, so ReplaceStr - 1 walks one byte before it and the null-terminator write lands out of bounds:

$ ./dtb_vbmeta_fuzz findings/vbmeta_replacestr_underflow_repro.bin
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1978897154
INFO: Loaded 1 modules   (254 inline 8-bit counters): 254 [0x56216c878a80, 0x56216c878b7e),
INFO: Loaded 1 PC tables (254 PCs): 254 [0x56216c878b80,0x56216c879b60),
./dtb_vbmeta_fuzz: Running 1 inputs 1 time(s) each.
Running: findings/vbmeta_replacestr_underflow_repro.bin
=================================================================
==1554499==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7e00f75e00ff at pc 0x56216c7feb41 bp 0x7ffcaaa0cdd0 sp 0x7ffcaaa0cdc8
WRITE of size 1 at 0x7e00f75e00ff thread T0
    #0 0x56216c7feb40 in UpdateVbmetaNode /home/pwn/abl-sparse-fuzz/UpdateDeviceTree_extract.c:207:17
    #1 0x56216c7fcb5b in run_parts /home/pwn/abl-sparse-fuzz/harness_dtb_vbmeta.c:50:7
    #2 0x56216c7fcb5b in LLVMFuzzerTestOneInput /home/pwn/abl-sparse-fuzz/harness_dtb_vbmeta.c:59:3
    #3 0x56216c569fbb in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) fuzzer.o
    #4 0x56216c54f3f8 in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) fuzzer.o
    #5 0x56216c558704 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) fuzzer.o
    #6 0x56216c53dc97 in main (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x40c97) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)
    #7 0x7f90f8427c8d  (/usr/lib/libc.so.6+0x27c8d) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #8 0x7f90f8427dca in __libc_start_main (/usr/lib/libc.so.6+0x27dca) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #9 0x56216c53dd64 in _start (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x40d64) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)

0x7e00f75e00ff is located 1 bytes before 12800-byte region [0x7e00f75e0100,0x7e00f75e3300)
allocated by thread T0 here:
    #0 0x56216c79f869 in calloc (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x2a2869) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)
    #1 0x56216c7fe4bf in AllocateZeroPool /home/pwn/abl-sparse-fuzz/./edk2_shim.h:100:59
    #2 0x56216c7fe4bf in UpdateVbmetaNode /home/pwn/abl-sparse-fuzz/UpdateDeviceTree_extract.c:150:21
    #3 0x56216c7fcb5b in run_parts /home/pwn/abl-sparse-fuzz/harness_dtb_vbmeta.c:50:7
    #4 0x56216c7fcb5b in LLVMFuzzerTestOneInput /home/pwn/abl-sparse-fuzz/harness_dtb_vbmeta.c:59:3
    #5 0x56216c569fbb in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) fuzzer.o
    #6 0x56216c54f3f8 in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) fuzzer.o
    #7 0x56216c558704 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) fuzzer.o
    #8 0x56216c53dc97 in main (/home/pwn/abl-sparse-fuzz/dtb_vbmeta_fuzz+0x40c97) (BuildId: 5d41d9df1ba440e50b0522eabcadbec513d43aa4)
    #9 0x7f90f8427c8d  (/usr/lib/libc.so.6+0x27c8d) (BuildId: da90c940060d13f3bc8a337f9c591b40ca12815e)
    #10 0x7ffcaaa0dbfe  (<unknown module>)

SUMMARY: AddressSanitizer: heap-buffer-overflow /home/pwn/abl-sparse-fuzz/UpdateDeviceTree_extract.c:207:17 in UpdateVbmetaNode
Shadow bytes around the buggy address:
  0x7e00f75dfe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e00f75dfe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e00f75dff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e00f75dff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e00f75e0000: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x7e00f75e0080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa[fa]
  0x7e00f75e0100: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e00f75e0180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e00f75e0200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e00f75e0280: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e00f75e0300: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==1554499==ABORTING

This one needs no oversized property, just a parts value starting with odm and no comma, so it is reachable through the ordinary full-DTB flow as well, not only through this focused harness.

  • Preconditions - A build for Android below version 10, because the in-tree UpdateVbmetaNode(fdt, "odm", NULL) call is compiled only under ANDROID_PLATFORM_VERSION < 10. Moreover, the overflow needs a parts property longer than 12800 bytes, and the one-byte underflow needs parts to start with odm and carry no comma after it.
  • Impact - Again, still kinda meh

The overflow is the only somewhat interesting primitive of the four bugs. It's a linear heap overflow with attacker-controlled length and contents, which can corrupt adjacent ABL heap allocations rather than only fault. The underflow writes a single fixed 0x00 one byte before the buffer, enough to clobber the preceding chunk's metadata. That said, all of them are still post-unlock. So exploitability is abysmal. Impact is negligible.

Conclusion

This brings me to the end of the quick and dirty triage and, at the same time, to the end of this first article. I could have gotten in more depth about the Qualcomm codebase itself, but this was not the point. The point was to understand and see what a modern-day LLM (as of the time of writing) is capable of when throwing a multi-step task at it. Can it keep context? How does it handle context switches? What's the quality of the output like? And so forth. Before anyone comes at me for "this was not a very academic benchmark". I fully get that. It was not the point. This was a baseline: one current model, one repository, one broad prompt, and no framework around the run. It was all about getting a feel for what the ceiling is presently (for this particular LLM) and where and how we could improve.

The bottom line is that what I encountered is still a very 2023/2024 era result. On a more serious note, I have to acknowledge that the model did more than I expected. It selected targets, lifted real code, built working harnesses and shims, checked coverage, and found four reproducible bugs without asking me to steer it.

One of the weak points back then is still one of the weak points today: prioritization of tasks and foresight. The targets that were fuzzed were all behind either some checks or conditions that some dataflow/code review should have spotted. Code generation itself was already quite neat a few years ago, just more limited in quantity.

That said, in my initial prompt I did not explicitly ask for the chaining of the identification and ranking of fuzzing candidates before making an educated guess. I just told the LLM to "analyze". Again, this showed me that precisely prompting your intent matters, not that this is any news in 2026. The same applies for splitting a huge workload into isolated subtasks. That's where LLMs excel right now, and we will get to that.

In the next post I'll start building this in public, benchmarked and reproducible so the results can actually be checked. The aim is an orchestrator that focuses on fuzzing and works from source as a first-class citizen. The goal will be to weigh severity and reachability as it goes. One major precondition will be that it's working with a "production-grade" and large codebase without a human holding its hand the whole way. I don't intend to publish yet another "autonomous AI hacking tool that solved JuiceShop".

References

Fuzzing projects with american fuzzy lop (AFL)

Preface

Fuzzing projects with american fuzzy lop (AFL)

This quick article will give a short introduction on what fuzzers are, how they work and how to properly setup the afl - american fuzzy lop fuzzer to find flaws in arbitrary projects.

Well known alternatives to afl (for the same or other purposes):

What is fuzzing?

In short, we can define fuzzing as the following

"Fuzzing is a Black Box software testing technique, which basically consists in finding implementation bugs using malformed/semi-malformed data injection in an automated fashion."

This approach can be done on the whole application, specific protocols and even single file formats. Depending on the attack vector the output changes obviously and can lead to a varying number of bugs.

Cool stuff about fuzzing

  • simple design, hence a basic fuzzer can be easily implemented from scratch
  • finds possible bugs/flaws via a random approach, which often are overlooked by human QA
  • Combinations of different input mutations and symbolic execution!

Not so cool stuff...

  • Often 'simple bugs' only
  • black box testing makes it difficult to evaluate impact of found results
  • Many fuzzers are limited to a certain protocol/architecture/...

How to set up afl for fuzzing with exploitable and gdb

Let's get right into setting up our environment... Not much else to say before that.
Juicy stuff ahead!

Get afl running by cloning the repos

git clone https://github.com/mirrorer/afl.git afl
cd afl
make && sudo make install
su root
echo core >/proc/sys/kernel/core_pattern
cd /sys/devices/system/cpu && echo performance | tee cpu*/cpufreq/scaling_governor
exit
sudo apt install gnuplot
# --------------------------------------------------------------------------- #
git clone https://github.com/rc0r/afl-utils.git afl-utils
cd afl-utils
sudo python setup.py install
# --------------------------------------------------------------------------- #
# -----------------------------------optional-------------------------------- #
# --------------------------------------------------------------------------- #
# check the official git repo for needed/supported architectures #
git clone https://github.com/shellphish/afl-other-arch.git afl-qemu-patch
cd afl-qemu-patch
./build.sh <list,of,arches,you,need>

Once installed you're ready to start fuzzing your favorite project. We'll come to this in the next paragraph by picking a random github project. I'll provide the used afl commands for the later shown results at the end of the article, but won't name the fuzzed repository for privacy reasons.


Instrument afl and  start pwning help to secure GitHub repositories

If the source code is available compile it with CC=afl-gcc make, or CC=afl-gcc cmake CMakeLists.txt && make to instrument afl.

$ cd targeted_application
CC=afl-gcc cmake CMakeLists.txt && make
-- The C compiler identification is GNU 5.4.0
-- Check for working C compiler: /usr/local/bin/afl-gcc
-- Check for working C compiler: /usr/local/bin/afl-gcc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/lab/Git/<target>
Scanning dependencies of target <target>
[ 14%] Building C object <target>
afl-cc 2.52b by <lcamtuf@google.com>
afl-as 2.52b by <lcamtuf@google.com>
[+] Instrumented 5755 locations (64-bit, non-hardened mode, ratio 100%).
[ 28%] Linking C static library <target>
[ 28%] Built target <target>
Scanning dependencies of target md2html
[ 42%] Building C object <target>
afl-cc 2.52b by <lcamtuf@google.com>
afl-as 2.52b by <lcamtuf@google.com>
[+] Instrumented 165 locations (64-bit, non-hardened mode, ratio 100%).
[ 57%] Building C object <target>
afl-cc 2.52b by <lcamtuf@google.com>
afl-as 2.52b by <lcamtuf@google.com>
[+] Instrumented 8 locations (64-bit, non-hardened mode, ratio 100%).
[ 71%] Building C object <target>
afl-cc 2.52b by <lcamtuf@google.com>
afl-as 2.52b by <lcamtuf@google.com>
[+] Instrumented 58 locations (64-bit, non-hardened mode, ratio 100%).
[ 85%] Building C object <target>
afl-cc 2.52b by <lcamtuf@google.com>
afl-as 2.52b by <lcamtuf@google.com>
[+] Instrumented 407 locations (64-bit, non-hardened mode, ratio 100%).
[100%] Linking C executable <target>
afl-cc 2.52b by <lcamtuf@google.com>
[100%] Built target <target>

To start local application fuzzing we can execute afl via the following command chain:

$ afl-fuzz -i input_sample_dir -o output_crash_dir ./binary @@
-i  defines a folder which holds sample data for the fuzzer to use
-o defines a folder where afl will save the fuzzing results
./binary describes the targeted application

If you have the resources to start more processes of afl keep in mind that each process takes up one CPU core and pretty much leverages 100% of its power. To do so a change up of the afl command chain is needed!

$ afl-fuzz -i input_sample_dir -o output_crash_dir -M master ./binary @@
$ afl-fuzz -i input_sample_dir -o output_crash_dir -S slaveX ./binary @@

The only difference between the master and slave modes is that the master instance will still perform deterministic checks. The slaves will proceed straight to random tweaks. If you don't want to do deterministic fuzzing at all you can straight up just spawn slaves. For statistic- and behavior-research having one master process is always a nice thing tho.

Note: For programs that take input from a file, use '@@' to mark the location in the target's command line where the input file name should be placed. The fuzzer will substitute this for you.
Note2: You can either provide an empty file in the input_sample_dir and let afl find some fitting input,  or give some context specfic input for the program you're fuzzing that is parsable!

To instrument afl-QEMU for blackbox fuzzing install needed dependencies sudo apt-get install libtool libtool-bin automake bison libglib2.0-dev zlib1g-dev and execute ./build_qemu_support.sh within the afl repo ~/afl/qemu_mode/.

Next up compile target program without CC=afl-gcc and change the afl-fuzz command chain to:

$ afl-fuzz -Q -i input_sample_dir -o output_crash_dir -M master ./binary @@

The emulation should work on its own already at this point. To support different, more exotic architectures in afl apply said patch from the prep work above!

Fuzzing projects with american fuzzy lop (AFL)
Fuzzing projects with american fuzzy lop (AFL)

Above we can see the difference between master and slaves as well as the general interface of afl after starting the fuzzing process. As displayed here, our slave found a bunch of unique crashes after only measly 12 minutes with its random fuzzing behavior. The master slave on the other hand didn't quite catch up to that yet...

The crashes and hangs can be manually examined within the output_crash_dir/process_name/crashes and  output_crash_dir/process_name/hangs folders. Since this manual labor is neither interesting nor effective some smart people offered us the afl-utils package, which automatizes the crash analysis and pairs it with a sweet output from a gdb script.


Automatic analysis of produced crashes

To automatically collect and analysis crashes with afl-collect + exploitable from the afl-utils package do the following while the fuzzing processes are still up and running:

$ afl-collect -d crashes.db -e gdb_script -r -rr ./output_crash_dir_from_afl_fuzz ./afl_collect_output_dir -j 8 -- /path/to/target

The only two parameters to change here  are the ./output_crash_dir_from_afl_fuzz, which is the folder where the afl-fuzz process stores its output. Next up is the /path/to/target, which is the fuzzed application. Depending on your hardware you can adjust the -j 8 parameters, which is used to specify the amount of threads to analyze the output. If everything works accordingly you'll stumble upon an output like this:

afl-collect -d crashes.db -e gdb_script -r -rr ./out ./output_aflc -j 8 -- ./path/to/target
afl-collect 1.33a by rc0r <hlt99@blinkenshell.org> # @_rc0r
Crash sample collection and processing utility for afl-fuzz.

[*] Going to collect crash samples from '/home/lab/Git/code/path/to/target/out'.
[!] Table 'Data' not found in existing database!
[*] Creating new table 'Data' in database '/home/lab/Git/code/path/to/target/crashes.db' to store data!
[*] Found 3 fuzzers, collecting crash samples.
[*] Successfully indexed 56 crash samples.
*** Error in `/home/lab/Git/code/path/to/target': double free or corruption (out): 0x000000000146c5a0 ***
======= Backtrace: =========
/lib/x86_64-linux-gnu/libc.so.6(+0x777e5)[0x7f0acaeb67e5]
/lib/x86_64-linux-gnu/libc.so.6(+0x8037a)[0x7f0acaebf37a]
/lib/x86_64-linux-gnu/libc.so.6(cfree+0x4c)[0x7f0acaec353c]
/home/lab/Git/code/path/to/target(<func_a>+0x93fd)[0x4627ed]
/home/lab/Git/code/path/to/target(<func_b>+0xaa)[0x40e75a]
/home/lab/Git/code/path/to/target(main+0x4c4)[0x4017f4]
/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf0)[0x7f0acae5f830]
/home/lab/Git/code/path/to/target(_start+0x29)[0x402169]
======= Memory map: ========
00400000-00401000 r--p 00000000 fd:00 38669039                           /home/lab/Git/code/path/to/target/
00401000-00476000 r-xp 00001000 fd:00 38669039                           /home/lab/Git/code/path/to/target/l
00476000-0048a000 r--p 00076000 fd:00 38669039                           /home/lab/Git/code/path/to/target/
0048a000-0048b000 r--p 00089000 fd:00 38669039                           /home/lab/Git/code/path/to/target
0048b000-0048c000 rw-p 0008a000 fd:00 38669039                           /home/lab/Git/code/path/to/target
01461000-0148a000 rw-p 00000000 00:00 0                                  [heap]
7f0ac4000000-7f0ac4021000 rw-p 00000000 00:00 0
7f0ac4021000-7f0ac8000000 ---p 00000000 00:00 0
7f0acac29000-7f0acac3f000 r-xp 00000000 fd:00 40899039                   /lib/x86_64-linux-gnu/libgcc_s.so.1
7f0acac3f000-7f0acae3e000 ---p 00016000 fd:00 40899039                   /lib/x86_64-linux-gnu/libgcc_s.so.1
7f0acae3e000-7f0acae3f000 rw-p 00015000 fd:00 40899039                   /lib/x86_64-linux-gnu/libgcc_s.so.1
7f0acae3f000-7f0acafff000 r-xp 00000000 fd:00 40895232                   /lib/x86_64-linux-gnu/libc-2.23.so
7f0acafff000-7f0acb1ff000 ---p 001c0000 fd:00 40895232                   /lib/x86_64-linux-gnu/libc-2.23.so
7f0acb1ff000-7f0acb203000 r--p 001c0000 fd:00 40895232                   /lib/x86_64-linux-gnu/libc-2.23.so
7f0acb203000-7f0acb205000 rw-p 001c4000 fd:00 40895232                   /lib/x86_64-linux-gnu/libc-2.23.so
7f0acb205000-7f0acb209000 rw-p 00000000 00:00 0
7f0acb209000-7f0acb22f000 r-xp 00000000 fd:00 40895230                   /lib/x86_64-linux-gnu/ld-2.23.so
7f0acb401000-7f0acb404000 rw-p 00000000 00:00 0
7f0acb42d000-7f0acb42e000 rw-p 00000000 00:00 0
7f0acb42e000-7f0acb42f000 r--p 00025000 fd:00 40895230                   /lib/x86_64-linux-gnu/ld-2.23.so
7f0acb42f000-7f0acb430000 rw-p 00026000 fd:00 40895230                   /lib/x86_64-linux-gnu/ld-2.23.so
7f0acb430000-7f0acb431000 rw-p 00000000 00:00 0
7ffd1292a000-7ffd1294b000 rw-p 00000000 00:00 0                          [stack]
7ffd129c9000-7ffd129cc000 r--p 00000000 00:00 0                          [vvar]
7ffd129cc000-7ffd129ce000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

As you can see we are getting a memory map and a backtrace for every crash. Since 56 crash samples were shown here I shortened the output to make it more easy to follow, but I hope it visualizes the point well enough. The real beefy part follows now tho!

Fuzzing projects with american fuzzy lop (AFL)

We're getting a complete overview about which process and what algorithm produced the error. Additionally, we can see the type of error coupled with an estimate on if it is exploitable or not. This gives us the chance dig deeper into the /afl_out/process_name/crash_id/, which is the used input to generate certain crash. We can then analyze it and try to conclude why crash occurred and maybe even produce one or multiple PoCs to abuse this behavior! A big disadvantage as of right now is that the exploitable script can only handle the most common architectures (x86 and ARM)! If you want to fuzz MIPS and PowerPC you need to fork the official repository and write your own logic for this!

Creating a PoC for our target application gets even easier, since  we can directly jump into gdb and execute the crash on our fuzzed program! Simply run the following from the command line:

$ gdb ./fuzzed_application
gdb> run /path/to/crash_folder/crash_id

If we have a gdb extension like pwndbg, or gdb-peda inspecting what went wrong makes it a breeze!

Fuzzing projects with american fuzzy lop (AFL)

We can see the state of the register at one glance, while also getting an overview of which function crashed from the generated input. Now we could dig through the actual source code and find an answer on why the heck it crashed there. Why did the used input make the program go haywire? When finding an answer to this you can manually create a malformed input yourself and write a PoC for this.

To show you an overview on how much afl managed to deform my actual input for this crash I'll show you a side by side comparison of the original input and the one afl managed to produce to crash the target at the shown state:

Fuzzing projects with american fuzzy lop (AFL)

Green bytes indicate that the files are still identical in that exact location. Red bytes indicate a difference, meaning afl mutated these bytes on its own accord (the ones on the right are the afl mutated ones).


Plotting the results from afl

For those among us, who are number and statistic nerds, afl provides a great feature for us! For every spawned process we get plottable data!

$ ls
crashes  fuzz_bitmap  fuzzer_stats  hangs  out  plot_data  queue

$ afl-plot --help
progress plotting utility for afl-fuzz by <lcamtuf@google.com>

This program generates gnuplot images from afl-fuzz output data. Usage:

/usr/local/bin/afl-plot afl_state_dir graph_output_dir

$ afl-plot . out
progress plotting utility for afl-fuzz by <lcamtuf@google.com>

[*] Generating plots...
[*] Generating index.html...
[+] All done - enjoy your charts!

This generates 3 plots:

  • One for the execution speed/sec,
  • One for the path coverage,
  • And one for the found crashes and hangs.

For my particular fuzzing example for the sake of this article they look like this:

Fuzzing projects with american fuzzy lop (AFL)
Fuzzing projects with american fuzzy lop (AFL)
Fuzzing projects with american fuzzy lop (AFL)

Final note on this: The stats shown in the afl fuzzing interface during the process fuzzing up until termination are stored for each process in a separate file too!


Conclusion

Fuzzing creates a powerful way to test projects on faults and flaws within the code. Depending on the used fuzzer the generated output can directly be used to deduct a possible exploit or PoC.

In the case of american fuzzy lop the base functionality already is great and definitely one of the faster fuzzing tools out there. The possible combination with afl-utils and the exploitable gdb script makes it even more awesome.

Last but not least it would be nice to test OSS, boofuzz or other not mentioned fuzzing frameworks to see how they can compete against each other.

I hope this quick and dirty overview showed that fuzzing is a strong approach to try to harden an application by finding critical flaws one could easily overlook with human QA. Please keep in mind that his demo presented here was done using a fairly broken repository.. If you start fuzzing things and not many crashes come around that's a good thing and you should not be sad about that, especially if it is your code, or widely used one :) !

With that in mind: Happy fuzzing!

Bugs that survive the heat of continuous fuzzing

Even when a project has been intensively fuzzed for years, bugs can still survive.

​​OSS-Fuzz is one of the most impactful security initiatives in open source. In collaboration with the OpenSSF Foundation, it has helped to find thousands of bugs in open-source software.

Today, OSS-Fuzz fuzzes more than 1,300 open source projects at no cost to maintainers. However, continuous fuzzing is not a silver bullet. Even mature projects that have been enrolled for years can still contain serious vulnerabilities that go undetected. In the last year, as part of my role at GitHub Security Lab, I have audited popular projects and have discovered some interesting vulnerabilities.

Below, I’ll show three open source projects that were enrolled in OSS-Fuzz for a long time and yet critical bugs survived for years. Together, they illustrate why fuzzing still requires active human oversight, and why improving coverage alone is often not enough.

Gstreamer

GStreamer is the default multimedia framework for the GNOME desktop environment. On Ubuntu, it’s used every time you open a multimedia file with Totem, access the metadata of a multimedia file, or even when generating thumbnails for multimedia files each time you open a folder.
In December 2024, I discovered 29 new vulnerabilities, including several high-risk issues.

To understand how 29 new vulnerabilities could be found in a software that has been continuously fuzzed for seven years, let’s have a look at the public OSS-Fuzz statistics available here. If we look at the GStreamer stats, we can see that it has only two active fuzzers and a code coverage of around 19%. By comparison, a heavily researched project like OpenSSL has 139 fuzzers (yes, 139 different fuzzers, that is not a typo).

Comparing OSS-Fuzz statistics for OpenSSL and GStreamer.

And the popular compression library bzip2 reports a code coverage of 93.03%, a number that is almost five times higher than GStreamer’s coverage.

OSS-Fuzz project statistics for the bzip2 compression library.

Even without being a fuzzing expert, we can guess that GStreamer’s numbers are not good at all.

And this brings us to our first reason: OSS-Fuzz still requires human supervision to monitor project coverage and to write new fuzzers for uncovered code. We have good hope that AI agents could soon help us fill this gap, but until that happens, a human needs to keep doing it by hand.

The other problem with OSS-Fuzz isn’t technical. It’s due to its users and the false sense of confidence they get once they enroll their projects. Many developers are not security experts, so for them, fuzzing is just another checkbox on their security to-do list. Once their project is “being fuzzed,” they might feel it is “protected by Google” and forget about it. Even if the project actually fails during the build stage and isn’t being fuzzed at all (which happens to more than one project in OSS-Fuzz).

This shows that human security expertise is still required to maintain and support fuzzing for each enrolled project, and that doesn’t scale well with OSS-Fuzz’s success!

Poppler

Poppler is the default PDF parser library in Ubuntu. It’s the library used to render PDFs when you open them with Evince (the default document viewer in Ubuntu versions prior to 25.04) or Papers (the default document viewer for GNOME desktop and the default document viewer from newer Ubuntu releases).

If we check Poppler stats in OSS-Fuzz, we can see it includes a total of 16 fuzzers and that its code coverage is around 60%. Those are quite solid numbers; maybe not at an excellent level, but certainly above average.

That said, a few months ago, my colleague Kevin Backhouse published a 1-click RCE affecting Evince in Ubuntu. The victim only needs to open a malicious file for their machine to be compromised. The reason a vulnerability like this wasn’t found by OSS-Fuzz is a different one: external dependencies.

Poppler relies on a good bunch of external dependencies: freetype, cairo, libpng… And based on the low coverage reported for these dependencies in the Fuzz Introspector database, we can safely say that they have not been instrumented by libFuzzer. As a result, the fuzzer receives no feedback from these libraries, meaning that many execution paths are never tested.

Coverage report table showing line coverage percentages for various Poppler dependencies.

But it gets even worse: Some of Evince’s default dependencies aren’t included in the OSS-Fuzz build at all. That’s the case with DjVuLibre, the library where I found the critical vulnerability that Kevin later exploited.

DjVuLibre is a library that implements support for the DjVu document format, an open source alternative to PDF that was popular in the late 1990s and early 2000s for compressing scanned documents. It has become much less widely used since the standardization of the PDF format in 2008.

The surprising thing is that while this dependency isn’t included among the libraries covered by OSS-Fuzz, it is shipped by default with Evince and Papers. So these programs were relying on a dependency that was “unfuzzed” and at the same time, installed on millions of systems by default.

This is a clear example of how software is only as secure as the weakest dependency in its dependency graph.

Exiv2

Exiv2 is a C++ library used to read, write, delete, and modify Exif, IPTC, XMP, and ICC metadata in images. It’s used by many mainstream projects such as GIMP and LibreOffice among others.

Back in 2021, my teammate Kevin Backhouse helped improve the security of the Exiv2 project. Part of that work included enrolling Exiv2 in OSS-Fuzz for continuous fuzzing, which uncovered multiple vulnerabilities, like CVE-2024-39695, CVE-2024-24826, and CVE-2023-44398.

Despite the fact that Exiv2 has been enrolled in OSS-Fuzz for more than three years, new vulnerabilities have still been reported by other vulnerability researchers, including CVE-2025-26623 and CVE-2025-54080.

In that case, the reason is a very common scenario when fuzzing media formats: Researchers always tend to focus on the decoding part, since it is the most obviously exploitable attack surface, while the encoding side receives less attention. As a result, vulnerabilities in the encoding logic can remain unnoticed for years.

From a regular user perspective, a vulnerability in an encoding function may not seem particularly dangerous. However, these libraries are often used in many background workflows (such as thumbnail generation, file conversions, cloud processing pipelines, or automated media handling) where an encoding vulnerability can be more critical.

The five-step fuzzing workflow

At this point it’s clear that fuzzing is not a magic solution that will protect you from everything. To assure minimum quality, we need to follow some criteria.

In this section, you’ll find the fuzzing workflow I’ve been using with very positive results in the last year: the five-step fuzzing workflow (preparation – coverage – context – value – triaging).

Five-step fuzzing workflow diagram. (preparation - coverage - context - value - triaging)

Step 1: Code preparation

This step involves applying all the necessary changes to the target code to optimize fuzzing results. These changes include, among others:

  • Removing checksums
  • Reducing randomness
  • Dropping unnecessary delays
  • Signal handling

If you want to learn more about this step, check out this blog post

Step 2: Improving code coverage

From the previous examples, it is clear that if we want to improve our fuzzing results, the first thing we need to do is to improve the code coverage as much as possible.

In my case, the workflow is usually an iterative process that looks like this:

Run the fuzzers > Check the coverage > Improve the coverage > Run the fuzzers > Check the coverage > Improve the coverage > …

The “check the coverage” stage is a manual step where i look over the LCOV report for uncovered code areas and the “improve the coverage” stage is usually one of the following:

  • Writing new fuzzing harnesses to hit new code that would otherwise be impossible to hit
  • Creating new input cases to trigger corner cases

For an automated, AI-powered way of improving code coverage, I invite you to check out the Plunger module in my FRFuzz framework. FRFuzz is an ongoing project I’m working on to address some of the caveats in the fuzzing workflow. I will provide more details about FRFuzz in a future blog post.

Another question we can ask ourselves is: When can we stop increasing code coverage? In other words, when can we say the coverage is good enough to move on to the next steps?

Based on my experience fuzzing many different projects, I can say that this number should be >90%. In fact, I always try to reach that level of coverage before trying other strategies, or even before enabling detection tools like ASAN or UBSAN.

To reach this level of coverage, you will need to fuzz not only the most obvious attack vectors such as decoding/demuxing functions, socket-receivers, or file-reading routines, but also the less obvious ones like encoders/muxers, socket-senders, and file-writing functions.

You will also need to use advanced fuzzing techniques like:

  • Fault injection: A technique where we intentionally introduce unexpected conditions (corrupted data, missing resources, or failed system calls) to see how the program behaves. So instead of waiting for real failures, we simulate these failures during fuzzing. This helps us to uncover bugs in execution paths that are rarely executed, such as:
    • Failed memory allocations (malloc returning NULL)
    • Interrupted or partial reads/writes
    • Missing files or unavailable devices
    • Timeouts or aborted network connections

A good example of fault injection is the Linux kernel Fault injection framework

  • Snapshot fuzzing: Snapshot fuzzing takes a snapshot of the program at any interesting state, so the fuzzer can then restore this snapshot before each test case. This is especially useful for stateful programs (operating systems, network services, or virtual machines). Examples include the QEMU mode of AFL++ and the AFL++ Nyx mode.

Step 3: Improving context-sensitive coverage

By default, the most common fuzzers (aka AFL++, libfuzzer, and honggfuzz) track the code coverage at the edge level. We can define an “edge” as a transition between two basic blocks in the control-flow graph. So if execution goes from block A to block B, the fuzzer records the edge A → B as “covered.” For each input the fuzzer runs, it updates a bitmap structure marking which edges were executed as a 0 or 1 value (currently implemented as a byte in most fuzzers).

In the following example, you can see a code snippet on the left and its corresponding control-flow graph on the right:

Edge coverage explanation.
Edge coverage = { (0,1), (0,2), (1,2), (2,3), (2,4), (3,6), (4,5), (4,6), (5,4) }

Each numbered circle corresponds to a basic block, and the graph shows how those blocks connect and which branches may be taken depending on the input. This approach to code coverage has demonstrated to be very powerful given its simplicity and efficiency.

However, edge coverage has a big limitation: It doesn’t track the order in which blocks are executed. 

So imagine you’re fuzzing a program built around a plugin pipeline, where each plugin reads and modifies some global variables. Different execution orders can lead to very different program states, while the edge coverage can still look identical. Since the fuzzer thinks it has already explored all the paths, the coverage-guided feedback won’t keep guiding it, and the chances of finding new bugs will drop.

To address this, we can make use of context-sensitive coverage. Context-sensitive coverage not only tracks which edges were executed, but it also tracks what code was executed right before the current edge.

For example, AFL++ implements two different options for context-sensitive coverage:

  • Context- sensitive branch coverage: In this approach, every function gets its own unique ID. When an edge is executed, the fuzzer takes the IDs from the current call stack, hashes them together with the edge’s identifier, and records the combined value.

You can find more information on AFL++ implementation here

  • N-Gram Branch Coverage: In this technique, the fuzzer combines the current location with the previous N locations to create a context-augmented coverage entry. For example:
    • 1-Gram coverage: looks at only the previous location
    • 2-Gram coverage: considers the previous two locations
    • 4-Gram coverage: considers the previous four

You can see how to configure it in AFL++ here

In contrast to edge coverage, it’s not realistic to aim for a coverage >90% when using context-sensitive coverage. The final number will depend on the project’s architecture and on how deep into the call stack we decide to track. But based on my experience, anything above 60% can be considered a very good result for context-sensitive coverage.

Step 4: Improving value coverage

To explain this section, I’m going to start with an example. Take a look at the following web server code snippet:

Example of a simple webserver code snippet.

Here we can see that the function unicode_frame_size has been executed 1910 times. After all those executions, the fuzzer didn’t find any bugs. It looks pretty secure, right?

However, there is an obvious div-by-zero bug when r.padding == FRAME_SIZE * 2:

Simple div-by-zero vulnerability.

Since the padding is a client-controlled field, an attacker could trigger a DoS in the webserver, sending a request with a padding size of exactly 2156 * 2 = 4312 bytes. Pretty annoying that after 1910 iterations the fuzzer didn’t find this vulnerability, don’t you think?

Now we can conclude that even having 100% code coverage is not enough to guarantee that a code snippet is free of bugs. So how do we find these types of bugs? And my answer is: Value Coverage.

We can define value coverage as the coverage of values a variable can take. Or in other words, the fuzzer will now be guided by variable value ranges, not just by control-flow paths. 

If, in our earlier example, the fuzzer had value-covered the variable r.padding, it could have reached the value 4312 and in turn, detected the divide-by-zero bug.

So, how can we make the fuzzer to transform variable values in different execution paths? The first naive implementation that came to my mind was the following one:

inline uint32_t value_coverage(uint32_t num) {

   uint32_t no_optimize = 0;
  
   if (num < UINT_MAX / 2) {
       no_optimize += 1;
       if(num < UINT_MAX / 4){
           no_optimize += 2;
           ...
       }else{
           no_optimize += 3
           ...
       }

   }else{
       no_optimize += 4;
       if(num < (UINT_MAX / 4) * 3){
           no_optimize += 5;
           ...
       }else{
           no_optimize += 6;
           ...
       }
   }

   return no_optimize;
}

In this code, I implemented a function that maps different values of the variable num to different execution paths. Notice the no_optimize variable to avoid the compiler from optimizing away some of the function’s execution paths.

After that, we just need to call the function for the variable we want to value-cover like this:

static volatile uint32_t vc_noopt;

uint32_t webserver::unicode_frame_size(const HttpRequest& r) {

   //A Unicode character requires two bytes
   vc_noopt = value_coverage(r.padding); //VALUE_COVERAGE
   uint32_t size = r.content_length / (FRAME_SIZE * 2 - r.padding);

   return size;
}

Given the huge number of execution paths this can generate, you should only apply it to certain variables that we consider “strategic.” By strategic, I mean those variables that can be directly controlled by the input and that are involved in critical operations. As you can imagine, selecting the right variables is not easy and it mostly comes down to the developers and researchers experience.

The other option we have to reduce the total number of execution paths is by using the concept of “buckets”: Instead of testing all 2^32 possible values of a 32 bits integer, we can group those values into buckets, where each bucket transforms into a single execution path. With this strategy, we don’t need to test every single value and can still achieve good results.

These buckets also don’t need to be symmetrically distributed across the full range. We can emphasize certain subranges by creating smaller buckets or, create bigger buckets for ranges we are not so interested in.

Now that I’ve explained the strategy, let’s take a look at what real-world options we have to get value coverage in our fuzzers:

  • AFL++ CmpLog / Clang trace-cmp: These focus on tracing comparison values (values used in calls to ==, memcmp, etc.). They wouldn’t help us find our divide-by-zero bug, since they only track values used in comparison instructions.
  • Clang trace-div + libFuzzer -use_value_profile=1: This one would work in our example, since it traces values involved in divisions. But it doesn’t give us variable-level granularity, so we can only limit its scope by source file or function, not by specific variable. That limits our ability to target only the “strategic” variables.

To overcome these problems with value coverage, I wrote my own custom implementation using the LLVM FunctionPass functionality. You can find more details about my implementation by checking the FRFuzz code here.

The last mile: almost undetectable bugs

Even when you make use of all up-to-date fuzzing resources, some bugs can still survive the fuzzing stage. Below are two scenarios that are especially hard to tackle with fuzzing.

Big input cases

These are vulnerabilities that require very large inputs to be triggered (on the order of megabytes or even gigabytes). There are two main reasons they are difficult to find through fuzzing:

  • Most fuzzers cap the maximum input size (for example 1 MB in the case of AFL), because larger inputs lead to longer execution times and lower overall efficiency.
  • The total possible input space is exponential: O(256ⁿ), where n is the size in bytes of the input data. Even when coverage-guided fuzzers use heuristic approaches to tackle this problem, fuzzing is still considered a sub-exponential problem, with respect to input size. So the probability of finding a bug decreases rapidly as the input size grows.

For example, CVE-2022-40303 is an integer overflow bug affecting libxml2 that requires an input larger than 2GB to be triggered.

Bugs that require “extra time” to be triggered

These are vulnerabilities that can’t be triggered within the typical per-execution time limit used by fuzzers. Keep in mind that fuzzers aim to be as fast as possible, often executing hundreds or thousands of test cases per second. In practice, this means per-execution time limits on the order of 1–10 milliseconds, which is far too short for some classes of bugs.

As an example, my colleague Kevin Backhouse found a vulnerability in the Poppler code that fits well in this category: the vulnerability is a reference-count overflow that can lead to a use-after-free vulnerability.

Reference counting is a way to track how many times a pointer is referenced, helping prevent vulnerabilities such as use-after-free or double-free. You can think of it as a semi-manual form of garbage collection.

In this case, the problem was that these counters were implemented as 32-bit integers. If an attacker can increment the counter up to 2^32 times, it will wrap the value back to 0 and then trigger a use-after-free in the code.

Kevin wrote a proof of concept that demonstrated how to trigger this vulnerability. The only problem is that it turned out to be quite slow, making exploitation unrealistic: The PoC took 12 hours to finish.

That’s an extreme example of a bug that needs “extra time” to manifest, but many vulnerabilities require at least seconds of execution to trigger. Even that is already beyond the typical limits of existing fuzzers, which usually set per-execution timeouts well under one second.

That’s why finding vulnerabilities that require seconds to trigger is almost a chimera for fuzzers. And this effectively discards a lot of real-world exploitation scenarios from what fuzzers can find.

It’s important to note that although fuzzer timeouts frequently turn out to be false alarms, it’s still a good idea to inspect them. Occasionally they expose real performance-related DoS bugs, such as quadratic loops.

How to proceed in these cases?

I would like to be able to give you a how-to guide on how to proceed in these scenarios. But the reality is we don’t have effective fuzzing strategies for these case corners yet.

At the moment, mainstream fuzzers are not able to catch these kinds of vulnerabilities. To find them, we usually have to turn to other approaches: static analysis, concolic (symbolic + concrete) testing, or even the old-fashioned (but still very profitable) method of manual code review.

Conclusion

Despite the fact that fuzzing is one of the most powerful options we have for finding bugs in complex software, it’s not a fire-and-forget solution. Continuous fuzzing can identify vulnerabilities, but it can also fail to detect some attack vectors. Without human-driven work, entire classes of bugs have survived years of continuous fuzzing in popular and crucial projects. This was evident in the three OSS-Fuzz examples above.

I proposed a five-step fuzzing workflow that goes further than just code coverage, covering also context-sensitive coverage and value coverage. This workflow aims to be a practical roadmap to ensure your fuzzing efforts go beyond the basics, so you’ll be able to find more elusive vulnerabilities.

If you’re starting with open source fuzzing, I hope this blog post helped you better understand current fuzzing gaps and how to improve your fuzzing workflows. And if you’re already familiar with fuzzing, I hope it gives you new ideas to push your research further and uncover bugs that traditional approaches tend to miss.

Want to learn how to start fuzzing? Check out our Fuzzing 101 course at gh.io/fuzzing101 >

The post Bugs that survive the heat of continuous fuzzing appeared first on The GitHub Blog.

Uncovering GStreamer secrets


In this blog post, I’ll show the results of my recent security research on GStreamer, the open source multimedia framework at the core of GNOME’s multimedia functionality.

I’ll also go through the approach I used to find some of the most elusive vulnerabilities, generating a custom input corpus from scratch to enhance fuzzing results.

GStreamer

GStreamer is an open source multimedia framework that provides extensive capabilities, including audio and video decoding, subtitle parsing, and media streaming, among others. It also supports a broad range of codecs, such as MP4, MKV, OGG, and AVI.

GStreamer is distributed by default on any Linux distribution that uses GNOME as the desktop environment, including Ubuntu, Fedora, and openSUSE. It provides multimedia support for key applications like Nautilus (Ubuntu’s default file browser), GNOME Videos, and Rhythmbox. It’s also used by tracker-miners, the Ubuntu’s metadata indexer–an application that my colleague, Kev, was able to exploit last year.

This makes GStreamer a very interesting target from a security perspective, as critical vulnerabilities in the library can open numerous attack vectors. That’s why I picked it as a target for my security research.

It’s worth noting that GStreamer is a large library that includes more than 300 different sub-modules. For this research, I decided to focus on only the “Base” and “Good” plugins, which are included by default in the Ubuntu distribution.

Results

During my research I found a total of 29 new vulnerabilities in GStreamer, most of them in the MKV and MP4 formats.

Below you can find a summary of the vulnerabilities I discovered:

GHSL CVE DESCRIPTION
GHSL-2024-094 CVE-2024-47537 OOB-write in isomp4/qtdemux.c
GHSL-2024-115 CVE-2024-47538 Stack-buffer overflow in vorbis_handle_identification_packet
GHSL-2024-116 CVE-2024-47607 Stack-buffer overflow in gst_opus_dec_parse_header
GHSL-2024-117 CVE-2024-47615 OOB-Write in gst_parse_vorbis_setup_packet
GHSL-2024-118 CVE-2024-47613 OOB-Write in gst_gdk_pixbuf_dec_flush
GHSL-2024-166 CVE-2024-47606 Memcpy parameter overlap in qtdemux_parse_theora_extension leading to OOB-write
GHSL-2024-195 CVE-2024-47539 OOB-write in convert_to_s334_1a
GHSL-2024-197 CVE-2024-47540 Uninitialized variable in gst_matroska_demux_add_wvpk_header leading to function pointer ovewriting
GHSL-2024-228 CVE-2024-47541 OOB-write in subparse/gstssaparse.c
GHSL-2024-235 CVE-2024-47542 Null pointer dereference in id3v2_read_synch_uint
GHSL-2024-236 CVE-2024-47543 OOB-read in qtdemux_parse_container
GHSL-2024-238 CVE-2024-47544 Null pointer dereference in qtdemux_parse_sbgp
GHSL-2024-242 CVE-2024-47545 Integer underflow in FOURCC_strf parsing leading to OOB-read
GHSL-2024-243 CVE-2024-47546 Integer underflow in extract_cc_from_data leading to OOB-read
GHSL-2024-244 CVE-2024-47596 OOB-read in FOURCC_SMI_ parsing
GHSL-2024-245 CVE-2024-47597 OOB-read in qtdemux_parse_samples
GHSL-2024-246 CVE-2024-47598 OOB-read in qtdemux_merge_sample_table
GHSL-2024-247 CVE-2024-47599 Null pointer dereference in gst_jpeg_dec_negotiate
GHSL-2024-248 CVE-2024-47600 OOB-read in format_channel_mask
GHSL-2024-249 CVE-2024-47601 Null pointer dereference in gst_matroska_demux_parse_blockgroup_or_simpleblock
GHSL-2024-250 CVE-2024-47602 Null pointer dereference in gst_matroska_demux_add_wvpk_header
GHSL-2024-251 CVE-2024-47603 Null pointer dereference in gst_matroska_demux_update_tracks
GHSL-2024-258 CVE-2024-47778 OOB-read in gst_wavparse_adtl_chunk
GHSL-2024-259 CVE-2024-47777 OOB-read in gst_wavparse_smpl_chunk
GHSL-2024-260 CVE-2024-47776 OOB-read in gst_wavparse_cue_chunk
GHSL-2024-261 CVE-2024-47775 OOB-read in parse_ds64
GHSL-2024-262 CVE-2024-47774 OOB-read in gst_avi_subtitle_parse_gab2_chunk
GHSL-2024-263 CVE-2024-47835 Null pointer dereference in parse_lrc
GHSL-2024-280 CVE-2024-47834 Use-After-Free read in Matroska CodecPrivate

Fuzzing media files: The problem

Nowadays, coverage-guided fuzzers have become the “de facto” tools for finding vulnerabilities in C/C++ projects. Their ability to discover rare execution paths, combined with their ease of use, has made them the preferred choice among security researchers.

The most common approach is to start with an initial input corpus, which is then successively mutated by the different mutators. The standard method to create this initial input corpus is to gather a large collection of sample files that provide a good representative coverage of the format you want to fuzz.

But with multimedia files, this approach has a major drawback: media files are typically very large (often in the range of megabytes or gigabytes). So, using such large files as the initial input corpus greatly slows down the fuzzing process, as the fuzzer usually goes over every byte of the file.

There are various minimization approaches that try to reduce file size, but they tend to be quite simplistic and often yield poor results. And, in the case of complex file formats, they can even break the file’s logic.

It’s for this reason that for my GStreamer fuzzing journey, I opted for “generating” an initial input corpus from scratch.

The alternative: corpus generators

An alternative to gathering files is to create an input corpus from scratch. Or in other words, without using any preexisting files as examples.

To do this, we need a way to transform the target file format into a program that generates files compliant with that format. Two possible solutions arise:

  1. Use a grammar-based generator. This category of generators makes use of formal grammars to define the file format, and subsequently generate the input corpus. In this category, we can mention tools like Grammarinator, an open source grammar-based fuzzer that creates test cases according to an input ANTLR v4 grammar. In this past blog post, I also explained how I used AFL++ Grammar-Mutator for fuzzing Apache HTTP server.
  2. To create a generator specifically for the target software. In this case, we rely on analyzing how the software parses the file format to create a compatible input generator.

Of course, the second solution is more time-consuming, as we need not only to understand the file format structure but also to analyze how the target software works.

But at the same time, it solves two problems in one shot:

  • On one hand, we’ll generate much smaller files, drastically speeding up the fuzzing process speed.
  • On the other hand, these “custom” files are likely to produce better code coverage and potentially uncover more vulnerabilities.

This is the method I opted for and it allowed me to find some of the most interesting vulnerabilities in the MP4 and MKV parsers–vulnerabilities that until then, had not been detected by the fuzzer.

Implementing an input corpus generator for MP4

In this section, I will explain how I created an input corpus generator for the MP4 format. I used the same approach for fuzzing the MKV format as well.

MP4 format

To start, I will show a brief description of the MP4 format.

MP4, officially known as MPEG-4 Part 14, is one of the most widely used multimedia container formats today, due to its broad compatibility and widespread support across various platforms and devices. It supports packaging of multiple media types such as video, audio, images, and complex metadata.

MP4 is basically an evolution of Apple’s QuickTime media format, which was standardized by ISO as MPEG-4. The .mp4 container format is specified by the “MPEG-4 Part 14: MP4 file format” section.

MP4 files are structured as a series of “boxes” (or “atoms”), each containing specific multimedia data needed to construct the media. Each box has a designated type that describes its purpose.

These boxes can also contain other nested boxes, creating a modular and hierarchical structure that simplifies parsing and manipulation.

Each box/atom includes the following fields:

  • Size: A 32-bit integer indicating the total size of the box in bytes, including the header and data.
  • Type: A 4-character code (FourCC) that identifies the box’s purpose.
  • Data: The actual content or payload of the box.

Some boxes may also include:

  • Extended size: A 64-bit integer that allows for boxes larger than 4GB.
  • User type: A 16-byte (128-bit) UUID that enables the creation of custom boxes without conflicting with standard types.

Mp4 box structure

An MP4 file is typically structured in the following way:

  • ftyp (File Type Box): Indicates the file type and compatibility.
  • mdat (Media Data Box): Contains the actual media data (for example, audio and video frames).
  • moov (Movie Box): Contains metadata for the entire presentation, including details about tracks and their structures:
  • trak (Track Box): Represents individual tracks (for example, video, audio) within the file.
  • udta (User Data Box): Stores user-defined data that may include additional metadata or custom information.

Common MP4 file structure

Once we understand how an MP4 file is structured, we might ask ourselves, “Why are fuzzers not able to successfully mutate an MP4 file?”

To answer this question, we need to take a look at how coverage-guided fuzzers mutate input files. Let’s take AFL–one of the most widely used fuzzers out there–as an example. AFL’s default mutators can be summarized as follows:

  • Bit/Bytes mutators: These mutators flip some bits or bytes within the input file. They don’t change the file size.
  • Block insertion/deletion: These mutators insert new data blocks or delete sections from the input file. They modify the file size.

The main problem lies in the latter category of mutators. As soon as the fuzzer modifies the data within an mp4 box, the size field of the box should be also updated to reflect the new size. Furthermore, if the size of a box changes, the size fields of all its parent boxes must also be recalculated and updated accordingly.

Implementing this functionality as a simple mutator can be quite complex, as it requires the fuzzer to track and update the implicit structure of the MP4 file.

Generator implementation

The algorithm I used for implementing my generator follows these steps:

Step 1: Generating unlabelled trees

Structurally, an MP4 file can be visualized as a tree-like structure, where each node corresponds to an MP4 box. Thus, the first step in our generator implementation involves creating a set of unlabelled trees.

In this phase, we create trees with empty nodes that do not yet have a tag assigned. Each node represents a potential MP4 box. To make sure we have a variety of input samples, we generate trees with various structures and different node counts.

3 different 9-node unlabelled trees

In the following code snippet, we see the constructor of the RandomTree class, which generates a random tree structure with a specified total nodes (total_nodes):

RandomTree::RandomTree(uint32_t total_nodes){
uint32_t curr_level = 0;

//Root node
new_node(-1, curr_level);
curr_level++;

uint32_t rem_nodes = total_nodes - 1;
uint32_t current_node = 0;

while(rem_nodes > 0){

uint32_t num_children = rand_uint32(1, rem_nodes);
uint32_t min_value = this->levels[curr_level-1].front();
uint32_t max_value = this->levels[curr_level-1].back();

for(int i=0; i<num_children; i++){
uint32_t parent_id = rand_uint32(min_value, max_value);
new_node(parent_id, curr_level);
}

curr_level++;
rem_nodes -= num_children;
}
}

This code traverses the tree level by level (Level Order Traversal), adding a random number (rand_uint32) of children nodes (num_children). This approach of assigning a random number of child nodes to each parent node will generate highly diverse tree structures.

Random generation of child nodes

After all children are added for the current level, curr_level is incremented to move to the next level.

Once rem_nodes is 0, the RandomTree generation is complete, and we move on to generate another new RandomTree.

Step 2: Assigning tags to nodes

Once we have a set of unlabelled trees, we proceed to assign random tags to each node.

These tags correspond to the four-character codes (FOURCCs) used to identify the types of MP4 boxes, such as moov, trak, or mdat.

In the following code snippet, we see two different fourcc_info structs: FOURCC_LIST which represents the leaf nodes of the tree, and CONTAINER_LIST which represents the rest of the nodes.

The fourcc_info struct includes the following fields:

  • fourcc: A 4-byte FourCC ID
  • description: A string describing the FourCC
  • minimum_size: The minimum size of the data associated with this FourCC
const fourcc_info CONTAINER_LIST[] = {

{FOURCC_moov, "movie", 0,},
{FOURCC_vttc, "VTTCueBox 14496-30", 0},
{FOURCC_clip, "clipping", 0,},
{FOURCC_trak, "track", 0,},
{FOURCC_udta, "user data", 0,},
…

const fourcc_info FOURCC_LIST[] = {

{FOURCC_crgn, "clipping region", 0,},
{FOURCC_kmat, "compressed matte", 0,},
{FOURCC_elst, "edit list", 0,},
{FOURCC_load, "track load settings", 0,},

Then, the MP4_labeler constructor takes a RandomTree instance as input, iterates through its nodes, and assigns a label to each node based on whether it is a leaf (no children) or a container (has children):

…

MP4_labeler::MP4_labeler(RandomTree *in_tree) {
…
for(int i=1; i < this->tree->size(); i++){

Node &node = this->tree->get_node(i);
…
if(node.children().size() == 0){
//LEAF
uint32_t random = rand_uint32(0, FOURCC_LIST_SIZE-1);
fourcc = FOURCC_LIST[random].fourcc;
…
}else{
//CONTAINER
uint32_t random = rand_uint32(0, CONTAINER_LIST_SIZE-1);
fourcc = CONTAINER_LIST[random].fourcc;
…
}
…
node.set_label(label);
}
}

After this stage, all nodes will have an assigned tag:

Labeled trees with MP4 box tags

Step 3: Adding random-size data fields

The next step is to add a random-size data field to each node. This data simulates the content within each MP4 box.
In the following code, at first we set the minimum size (min_size) of the padding specified in the selected fourcc_info from FOURCC_LIST. Then, we append padding number of null bytes (\x00) to the label:

if(node.children().size() == 0){
//LEAF
…
padding = FOURCC_LIST[random].min_size;
random_data = rand_uint32(4, 16);
}else{
//CONTAINER
…
padding = CONTAINER_LIST[random].min_size;
random_data = 0;
}
…
std::string label = uint32_to_string(fourcc);
label += std::string(padding, '\x00');
label += std::string(random_data, '\x41');

By varying the data sizes, we make sure the fuzzer has sufficient space to inject data into the box data sections, without needing to modify the input file size.

Step 4: Calculating box sizes

Finally, we calculate the size of each box and recursively update the tree accordingly.

The traverse method recursively traverses the tree structure serializing the node data and calculating the resulting size box (size). Then, it propagates size updates up the tree (traverse(child)) so that parent boxes include the sizes of their child boxes:

std::string MP4_labeler::traverse(Node &node){
…
for(int i=0; i < node.children().size(); i++){ Node &child = tree->get_node(node.children()[i]);

output += traverse(child);
}

uint32_t size;
if(node.get_id() == 0){
size = 20;
}else{
size = node.get_label().size() + output.size() + 4;
}

std::string label = node.get_label();
uint32_t label_size = label.size();

output = uint32_to_string_BE(size) + label + output;
…
}

The number of generated input files can vary depending on the time and resources you can dedicate to fuzzing. In my case, I generated an input corpus of approximately 4 million files.

Code

You can find my C++ code example here.

Acknowledgments

A big thank you to the GStreamer developer team for their collaboration and responsiveness, and especially to Sebastian Dröge for his quick and effective bug fixes.

I would also like to thank my colleague, Jonathan Evans, for managing the CVE assignment process.

References

The post Uncovering GStreamer secrets appeared first on The GitHub Blog.

❌